item 28. 유효한 상태만 표현하는 타입을 지향하기
•
타입을 잘 설계하면 코드는 직관적으로 작성할 수 있다. 그러나 타입 설계가 엉망이라면 어떠한 기억이나 문서도 도움이 되지 못한다.
•
효과적으로 타입을 설계하려면 유효한 상태만 표현할 수 있는 타입을 만들어 내는 것이 가장 중요하다.
•
웹 어플리케이션을 만든다고 가정해보자.
interface State {
pageText: string;
isLoading: boolean;
error?: string;
}
TypeScript
복사
•
다음은 어플리케이션의 상태를 좀 더 제대로 표현한 방법이다.
interface RequestPending {
state: 'pending';
}
interface RequestError {
state: 'error';
error: string;
}
interface RequestSuccess {
state: 'ok';
pageText: string;
}
type RequestState = RequestPending | RequestError | RequestSuccess;
interface State {
currentPage: string;
requests: {[page: string]: RequestState};
}
TypeScript
복사
◦
무효한 상태를 허용하지 않도록 개선되었다. 현재 페이지는 발생하는 모든 요청의 상태로서, 명시적으로 모델링되었다.
•
타입을 설계할 때는 어떤 값들을 포함하고 어떤 값들을 제외할지 신중하게 생각해야 한다.
◦
유효한 상태를 표현하는 값만 허용한다면 코드를 작성하기 쉬워지고 타입 체크가 용이해진다.
◦
유효한 상태만 허용한다는 것은 매우 일반적인 원칙이다.
•
요약
◦
유효한 상태와 무효한 상태를 둘 다 표현하는 타입은 혼란을 초래하기 쉽고 오류를 유발하게 된다.
◦
유효한 상태만 표현하는 타입을 사용해야 한다. 코드가 길어지거나 표현하기 어렵지만 결국은 시간을 절약하고 고통을 줄일 수 있다.
item 29. 사용할 때는 너그럽게, 생성할 때는 엄격하게
•
함수의 매개변수는 타입의 범위가 넓어도 되지만 결과를 반환할 때는 일반적으로 타입의 범위가 더 구체적이어야 한다.
◦
예를 들어 3D Mapping API는 카메라의 위치를 지정하고 경계 박스의 뷰포트를 계산하는 방법을 제공한다.
declare function setCamera(camera: CameraOptions): void;
declare function viewportForBounds(bounds: LngLatBounds): CameraOptions;
TypeScript
복사
◦
먼저 CameraOptions와 LngLat 타입의 정의를 살펴보자.
interface CameraOptions {
center?: LngLat;
zoom?: number;
bearing?: number;
pitch?: number;
}
type LngLat =
{ lng: number; lat: number; } |
{ lon: number; lat: number; } |
[number, number];
TypeScript
복사
◦
일부 값은 수정하지 않으면서 동시에 다른 값을 설정할 수 있어야 하므로 CameraOptions의 필드는 모두 선택적이다.
◦
유사하게 LngLat 타입도 setCamera의 매개변수 범위를 넓혀 준다.
▪
매개변수로 {lng, lat} 객체, {lon, lat} 객체 또는 [lng, lat] 쌍도 넣을 수 있다.
▪
이러한 편의성을 통해서 함수 호출을 쉽게 할 수 있다.
◦
viewportForBounds 함수는 또 다른 속성이 자유로운 타입을 매개변수로 받는다.
type LngLatBounds =
{northeast: LngLat, southwest: LngLat} |
[LngLat, LngLat] |
[number, number, number, number];
TypeScript
복사
▪
이름이 주어진 모서리, 위도/경도 쌍 또는 순서만 맞다면 4-튜플을 사용하여 경계를 지정할 수 있다.
▪
LngLat은 세 형태를 할당할 수 있으므로 LngLatBounds의 형태는 19가지 이상으로 조합이 가능하다.
•
이제 GeoJSON 기능을 지원하도록 뷰포트를 조절하고 새 뷰포트를 URL에 할당하는 함수를 작성해보자.
function focusOnFeature(f: Feature) {
const bounds = calculateBoundingBox(f);
const camera = viewportForBounds(bounds);
setCamera(camera);
const {center: {lat, lng}, zoom} = camera;
// ~~~ Property 'lat' does not exist on type ...
// ~~~ Property 'lng' does not exist on type ...
zoom; // Type is number | undefined
window.location.search = `?v=@${lat},${lng}z${zoom}`;
}
TypeScript
복사
◦
이 예제의 오류는 lat과 lng 속성이 없고 zoom 속성만 존재하기 때문에 발생했으며 zoom의 타입이 number | undefined으로 추론되는 것 또한 문제다.
◦
근본적인 문제는 viewportForBounds()의 반환 타입 선언이 너무 자유롭기 때문이다.
◦
수많은 선택적 속성을 가지는 반환 타입과 유니온 타입은 viewportForBounds를 사용하기 어렵게 만든다.
•
즉 매개변수 타입의 범위가 넓으면 유용하지만 반환 타입의 범위가 넓으면 불편하다.
•
요약
◦
보통 매개변수 타입은 반환 타입에 비해 범위가 넓은 경향이 있다. 선택적 속성과 유니온 타입은 반환 타입보다 매개변수 타입에 더 일반적이다.
◦
매개변수와 반환 타입의 재사용을 위해서 기본 형태(반환 타입)와 느슨한 형태(매개변수 타입)를 도입하는 것이 좋다.
item 30. 문서에 타입 정보를 쓰지 않기
•
주석에 타입 정보를 작성하지 마라.
•
타입스크립트의 타입 구문 시스템은 간결하고 구체적이며 쉽게 읽을 수 있도록 설계되었다.
◦
함수의 입력과 출력의 타입을 코드로 표현하는 것이 주석보다 더 나은 방법이다.
◦
누군가 강제하지 않는 이상 주석은 코드와 동기화되기 힘들다. 그러나 타입 구문은 타입스크립트 타입 체커가 타입 정보를 동기화하도록 강제한다.
•
주석에서 특정 매개변수를 설명하고 싶다면 JSDoc의 @param 구문을 사용하라.
•
예외로 단위가 있는 숫자들은 단위가 무엇인지 확실하지 않다면 변수명 또는 속성 이름에 단위를 포함하는 것은 나쁘지 않은 선택이다.
•
요약
◦
주석과 변수명에 타입 정보를 적는 것은 피해야 한다. 타입 선언이 중복되는 것으로 끝나면 다행이고 최악의 경우, 정보의 불일치가 발생한다.
◦
타입이 명확하지 않은 경우는 변수명에 단위 정보를 포함하는 것을 고려하는 것이 좋다.
⇒ timeMs, temperatureC 등
item 31. 타입 주변에 null 값 배치하기
•
값이 전부 null이거나 전부 null이 아니거나로 분명히 구분된다면 값이 섞여있는 경우보다 확실히 다루기 쉽다.
◦
타입에 null을 추가하는 방식으로 이러한 경우를 모델링할 수 있다.
•
부가적인 null 체크없이도 객체를 사용할 수 있도록 객체를 모델링해야 한다.
•
요약
◦
한 값의 null 여부가 다른 값의 null 여부에 암시적으로 관여되도록 설계하면 안 된다.
◦
API 작성 시에는 반환 타입을 큰 객체로 만들고 반환 타입 전체가 null이거나 null이 아니게 만들어야 한다.
◦
클래스를 만들 때는 필요한 모든 값이 준비되었을때 생성하여 객체가 온전하도록하는 것이 좋다.
◦
strictNullChecks를 설정하면 코드에 많은 오류가 표시되겠지만 null 값과 관련된 문제점을 찾을 수 있기 때문에 반드시 필요하다.
item 32. 유니온의 인터페이스보다는 인터페이스의 유니온 사용하기
•
유니온 타입의 속성을 가지는 인터페이스를 작성 중이라면 혹시 인터페이스의 유니온 타입을 사용하는 게 더 낫지는 않은지 검토해봐야 한다.
•
다음과 같은 예시가 있다고 가정하자.
interface Layer {
layout: FillLayout | LineLayout | PointLayout;
paint: FillPaint | LinePaint | PointPaint;
}
TypeScript
복사
•
이런 형태로 Layer를 정의하면 layout과 paint 속성이 올바르지 못한 조합으로 섞이는 경우를 방지할 수 있다.
interface FillLayer {
layout: FillLayout;
paint: FillPaint;
}
interface LineLayer {
layout: LineLayout;
paint: LinePaint;
}
interface PointLayer {
layout: PointLayout;
paint: PointPaint;
}
type Layer = FillLayer | LineLayer | PointLayer;
TypeScript
복사
•
태그된 유니온은 타입스크립트 타입 체커와 잘 맞기 때문에 타입스크립트 코드 어디에서나 쉽게 볼 수 있다.
interface Layer {
type: 'fill' | 'line' | 'point';
layout: FillLayout | LineLayout | PointLayout;
paint: FillPaint | LinePaint | PointPaint;
}
function drawLayer(layer: Layer) {
if (layer.type === 'fill') {
const {paint} = layer; // Type is FillPaint
const {layout} = layer; // Type is FillLayout
} else if (layer.type === 'line') {
const {paint} = layer; // Type is LinePaint
const {layout} = layer; // Type is LineLayout
} else {
const {paint} = layer; // Type is PointPaint
const {layout} = layer; // Type is PointLayout
}
}
TypeScript
복사
◦
어떤 데이터 타입을 태그된 유니온으로 표시할 수 있다면 보통은 그렇게하는 것이 좋다.
•
요약
◦
유니온 타입의 속성을 여럿 가지는 인터페이스에서는 속성 간의 관계가 분명하지 않기 때문에 실수가 자주 발생할 수 있으므로 주의해야 한다.
◦
유니온의 인터페이스보단 인터페이스의 유니온이 더 정확하고 타입스크립트가 이해하기도 좋다.
◦
타입스크립트가 제어 흐름을 분석할 수 있도록 타입에 태그를 넣는 것을 고려해야 한다. 태그된 유니온은 타입스크립트와 매우 잘 맞기 때문에 자주 볼 수 있는 패턴이다.
▪
타입에 태그를 제공하는 경우, 타입을 효과적으로 좁힐 수 있다.
item 33. string 타입보다 더 구체적인 타입 사용하기
•
string 타입은 굉장히 넓은 타입에 속하므로 string 타입으로 변수를 선언하려 한다면 그보다 더 좁은 타입이 적절하지 않은지 검토해 보아야 한다.
•
음악 컬렉션을 만들기 위해 앨범의 타입을 정의한다고 가정해보자.
interface Album {
artist: string;
title: string;
releaseDate: string; // YYYY-MM-DD
recordingType: string; // E.g., "live" or "studio"
}
TypeScript
복사
•
오류를 방지하기 위해서 다음처럼 타입의 범위를 좁히는 방법을 생각해볼 수 있다.
type RecordingType = 'studio' | 'live';
interface Album {
artist: string;
title: string;
releaseDate: Date;
recordingType: RecordingType;
}
TypeScript
복사
•
위 코드처럼 수정하면 타입스크립트의 타입체커는 오류를 더 세밀하게 체크해준다.
const kindOfBlue: Album = {
artist: 'Miles Davis',
title: 'Kind of Blue',
releaseDate: new Date('1959-08-17'),
recordingType: 'Studio'
// ~~~~~~~~~~~~ Type '"Studio"' is not assignable to type 'RecordingType'
};
TypeScript
복사
•
이러한 방식에는 두 가지 장점이 존재한다.
◦
첫 번째, 타입을 명시적으로 정의하여 다른 곳으로 값이 전달되는 경우에도 타입 정보가 유지된다.
◦
두 번째, keyof 연산자로 더욱 세밀하게 객체의 속성 체크가 가능해진다.
▪
어떤 배열에서 필드의 값만 추출하는 함수를 작성한다고 생각해 보자. 해당 함수의 시그니처를 다음과 같이 작성할 수 있을 것이다.
function pluck(records: any[], key: string): any[] {
return records.map(r => r[key]);
TypeScript
복사
•
타입 체크가 되긴 하지만 any 타입으로 인해서 정밀하지 못하다. 특히 반환 값에 any가 사용되었다.
▪
첫 번째 개선 시도로 제네릭 타입을 도입해볼 수 있다.
function pluck<T>(records: T[], key: string): any[] {
return records.map(r => r[key]);
// ~~~~~~ Element implicitly has an 'any' type
// because type '{}' has no index signature
}
TypeScript
복사
•
이제 타입스크립트는 key 매개변수의 타입이 string이기 때문에 범위가 너무 넓다는 오류를 발생시킨다.
•
만약 Album 배열을 매개변수로 전달하면 기존의 넓은 string 타입의 key 매개변수를 가지게 되는 것에 반해서 유효한 값은 4개의 값뿐이다.
•
따라서 keyof 연산자를 사용해서 key 매개변수의 범위를 좁혀줄 수 있다.
function pluck<T>(records: T[], key: keyof T) {
return records.map(r => r[key]);
}
// function pluck<T>(record: T[], key: keyof T): T[keyof T][];
TypeScript
복사
▪
그러나 위 코드는 다음과 같이 반환값의 타입이 여러개로 혼동될 가능성이 존재한다.
const releaseDates = pluck(albums, 'releaseDate');
// ^? const releaseDates: (string | Date)[]
TypeScript
복사
▪
따라서 두 번째 제네릭 매개변수를 사용해서 반환 타입의 범위를 좁혀줄 수 있다.
function pluck<T, K extends keyof T>(records: T[], key: K): T[K][] {
return records.map(r => r[key]);
}
TypeScript
복사
•
string은 any와 비슷한 문제를 겪을 수 있다. 잘못 사용하게 되면 무효한 값을 허용하고 타입 간의 관계도 감춰버린다.
◦
이러한 문제점은 타입 체커를 방해하고 실제 버그를 찾지 못하게 만든다.
•
타입스크립트에서 string의 부분 집합을 정의할 수 있는 기능은 자바스크립트 코드의 타입 안전성을 크게 높여준다. 보다 정확한 타입을 사용하여 오류를 방지하고 코드의 가독성도 향상시킬 수 있다.
•
요약
◦
모든 문자열을 할당할 수 있는 string 타입보다는 더 구체적인 타입을 사용하는 것이 좋다.
◦
변수의 범위를 보다 정확하게 표현하고 싶다면 string 타입보다는 문자열 리터럴 타입의 유니온을 사용하는 것이 좋다. 타입 체크를 더 엄격히 할 수 있고 생산성을 향상 시킬 수 있다.
◦
객체의 속성 이름을 함수 매개변수로 받을 때는 string보다 keyof T를 사용하는 것이 좋다.
item 34. 부정확한 타입보다는 미완성 타입을 사용하기
•
타입을 선언하다보면 객체의 동작을 덜 구체적으로 모델링하게 되는 상황이 있다.
◦
일반적으로 타입이 구체적일수록 버그를 잡기 쉽고 타입스크립트가 제공하는 도구를 잘 활용할 수 있다.
◦
타입이 부정확하게 구체적인 경우에는 많은 문제가 발생할 수 있다.
•
GeoJSON 형식의 타입 선언을 작성한다고 가정해보자. GeoJSON은 각각 다른 형태의 좌표 배열을 가지는 몇 가지 타입 중 하나가 될 수 있다.
interface Point {
type: 'Point';
coordinates: number[];
}
interface LineString {
type: 'LineString';
coordinates: number[][];
}
interface Polygon {
type: 'Polygon';
coordinates: number[][][];
}
type Geometry = Point | LineString | Polygon; // Also several others
TypeScript
복사
◦
전반적으로 괜찮으나 coordinates의 타입이 너무 넓다. 보통 [x, y] 타입만 사용되지만 외에도 허용하는 타입이 너무 많다.
◦
따라서 다음과 같이 타입을 선언해주고 변경하였다.
type GeoPosition = [number, number];
interface Point {
type: 'Point';
coordinates: GeoPosition;
}
// Etc.
TypeScript
복사
◦
코드에는 위도와 경도만을 명시했지만 세 번째 요소인 고도가 있을 수도 있고 또 다른 정보가 있을 수 있다.
◦
결과적으로 타입 선언을 세밀하게 만들고자 했지만 시도가 너무 과했고 오히려 타입이 부정확해졌다.
▪
현재의 타입 선언을 사용하려면 사용자들은 타입 단언문을 도입하거나 as any를 추가해서 타입 체커를 무시해야 한다.
•
타입을 구체화할 때, 불쾌한 골짜기 은유를 생각해보면 도움이 될 수 있다. 일반적으로 any같은 매우 추상적인 타입은 구체화하는 것이 좋다.
•
타입이 구체적으로 정제가 된다고해서 정확도가 무조건 향상되는 것은 아니다. 타입에 의존하기 시작하면 부정확함으로 인한 문제가 발생하기 쉬워진다.
•
요약
◦
타입 안전성에서 불쾌한 골짜기는 피해야 한다. 타입이 없는 것보다 잘못된 것이 더 나쁘다.
◦
정확하게 타입을 모델링할 수 없다면 부정확하게 모델링하지 말아야 한다. 또한 any와 unknown을 구분해서 사용해야 한다.
◦
타입 정보를 구체적으로 만들수록 오류 메시지와 자동 완성 기능에 주의를 기울여야 한다.
item 35. 데이터가 아닌 API와 명세를 보고 타입 만들기
•
외부 시스템에 의존성을 가지는 데이터의 경우, 타입을 직접 작성하지 않고 자동으로 생성할 수 있다.
◦
예시 데이터가 아닌 명세를 참고해 타입을 생성한다. 명세를 참고해 타입을 생성하면 타입스크립트는 사용자가 실수를 줄일 수 있게 도와준다.
◦
반면 예시 데이터를 참고해 타입을 생성하면 예시 데이터가 과의존하게 되고 미연의 오류들을 방지하기 힘들어진다.
•
요약
◦
코드의 타입 안전성을 확보하기 위해 예시 데이터가 아닌 API 또는 데이터 형식에 대한 타입 생성을 고려해야 한다.
◦
데이터에 드러나지 않은 예외적인 경우들이 문제가 될 수 있기 때문에 데이터보다는 명세로부터 코드를 생성하는 것이 좋다.
item 36. 해당 분야의 용어로 타입 짓기
•
잘못된 타입 명은 코드의 의도를 왜곡하고 오해를 불러일으킨다.
•
코드로 표현하고자 하는 모든 분야에는 해당 주제를 설명하기 위한 전문 용어들이 존재한다. 자체적으로 용어를 만들어내려 하지 말고 해당 분야에 이미 존재하는 용어를 사용하라.
◦
이런 용어들을 사용하면 사용자와 소통에 더 유리하며 타입의 명확성을 올릴 수 있다.
•
전문 분야의 용어는 정확하게 사용해야 한다. 특정 용어를 다른 의미로 잘못 쓰게 되면 직접 만들어 낸 용어보다 더 혼란을 주게 된다.
•
타입, 속성, 변수에 이름을 붙일 대 명심해야할 세 가지 규칙이 있다.
◦
동일한 의미를 나타낼 때는 같은 용어를 사용해야 한다.
◦
모호한 이름은 피해야 한다.
◦
이름을 지을 때는 포함된 내용이나 계산 방식까지 고려하면 복잡해진다. 데이터 자체가 문제를 해결할 때 의미하는 바가 무엇인지 고려해야 한다.
•
요약
◦
가독성을 높이고 추상화 수준을 올리기 위해서 해당 분야의 용어를 사용해야 한다.
◦
같은 의미에 다른 이름을 붙여서는 안 된다. 특별한 의미가 있을 대만 용어를 구분해야 한다.
item 37. 공식 명칭에는 상표를 붙이기
•
구조적 타이핑 때문에 가끔 코드가 이상한 결과를 내뱉을 수 있다. 다음 코드를 살펴보자.
interface Vector2D {
x: number;
y: number;
}
function calculateNorm(p: Vector2D) {
return Math.sqrt(p.x * p.x + p.y * p.y);
}
calculateNorm({x: 3, y: 4}); // OK, result is 5
const vec3D = {x: 3, y: 4, z: 1};
calculateNorm(vec3D); // OK! result is also 5
TypeScript
복사
◦
이는 구조적 타이핑 관점에선 전혀 문제가 없지만 수학적으로 따지면 2차원 벡터를 사용하는 것이 이치에 맞다.
•
calculateNorm 함수가 3차원 벡터를 허용하지 않게 하려면 공식 명칭을 사용하면 된다. 공식 명칭을 사용하는 것은 타입이 아니라 값의 관점에서 Vector2D임을 명시하는 것이다.
◦
공식 명칭 개념을 타입스크립트에서 사용하려면 상표를 붙이면 된다.
interface Vector2D {
_brand: '2d';
x: number;
y: number;
}
function vec2D(x: number, y: number): Vector2D {
return {x, y, _brand: '2d'};
}
function calculateNorm(p: Vector2D) {
return Math.sqrt(p.x * p.x + p.y * p.y); // Same as before
}
calculateNorm(vec2D(3, 4)); // OK, returns 5
const vec3D = {x: 3, y: 4, z: 1};
calculateNorm(vec3D);
// ~~~~~ Property '_brand' is missing in type...
TypeScript
복사
◦
상표를 사용해서 calculateNorm 함수가 Vector2D 타입만 받는 것을 보장한다. 그러나 vec3D에 _brand: ‘2d’를 사용하는 것을 막을 수는 없다. 하지만 단순한 실수를 방지하기는 충분하다.
•
상표 기법은 타입 체커 상에서만 동작하지만 런타임에 상표를 검사하는 것과 동일한 효과를 얻을 수 있다.
•
상표 기법은 타입 시스템 내에서 표현할 수 없는 수많은 속성들을 모델링하는 데 사용되기도 한다.
◦
배열과 함께 객체로 정의되어 정렬되었음을 표시하는 상표 기법도 있다.
type SortedList<T> = T[] & {_brand: 'sorted'};
function isSorted<T>(xs: T[]): xs is SortedList<T> {
for (let i = 1; i < xs.length; i++) {
if (xs[i] < xs[i - 1]) {
return false;
}
}
return true;
}
function binarySearch<T>(xs: SortedList<T>, x: T): boolean {
// ...
}
TypeScript
복사
•
요약
◦
타입스크립트는 구조적 타이핑을 사용하기 때문에 값을 세밀하게 구분하지 못하는 경우가 있다. 값을 구분하기 위해 공식 명칭이 필요하다면 상표 기법을 사용해보자.
◦
상표 기법은 타입 시스템에서만 동작하지만 런타임에 상표를 검사하는 것과 같은 효과를 얻을 수 있다.