Search
Duplicate
🏙️

4장. 타입 설계

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
복사
먼저 CameraOptionsLngLat 타입의 정의를 살펴보자.
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
복사
이 예제의 오류는 latlng 속성이 없고 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를 정의하면 layoutpaint 속성이 올바르지 못한 조합으로 섞이는 경우를 방지할 수 있다.
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
복사
stringany와 비슷한 문제를 겪을 수 있다. 잘못 사용하게 되면 무효한 값을 허용하고 타입 간의 관계도 감춰버린다.
이러한 문제점은 타입 체커를 방해하고 실제 버그를 찾지 못하게 만든다.
타입스크립트에서 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같은 매우 추상적인 타입은 구체화하는 것이 좋다.
타입이 구체적으로 정제가 된다고해서 정확도가 무조건 향상되는 것은 아니다. 타입에 의존하기 시작하면 부정확함으로 인한 문제가 발생하기 쉬워진다.
요약
타입 안전성에서 불쾌한 골짜기는 피해야 한다. 타입이 없는 것보다 잘못된 것이 더 나쁘다.
정확하게 타입을 모델링할 수 없다면 부정확하게 모델링하지 말아야 한다. 또한 anyunknown을 구분해서 사용해야 한다.
타입 정보를 구체적으로 만들수록 오류 메시지와 자동 완성 기능에 주의를 기울여야 한다.

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
복사
요약
타입스크립트는 구조적 타이핑을 사용하기 때문에 값을 세밀하게 구분하지 못하는 경우가 있다. 값을 구분하기 위해 공식 명칭이 필요하다면 상표 기법을 사용해보자.
상표 기법은 타입 시스템에서만 동작하지만 런타임에 상표를 검사하는 것과 같은 효과를 얻을 수 있다.