Blog

이펙티브 타입스크립트(4)

이펙티브 타입스크립트(4)

누가 순서도를 보여줌녀서 테이블을 감추면 나는 여전히 갸우뚱할 것이다. 하지만 테이블을 보여 준다면 순서도는 별로 필요하지 않다. 보지 않더라도 명백할 것이기 때문이다. - 《맨먼스 미신(The Mythical Man Month)》

4-1. 유효한 상태만 표현하는 타입을 지향하기

효과적으로 타입을 설계하려면, 유효한 상태만 표현할 수 있는 타입을 만들어 내는 것이 가장 중요하다
유효한 상태를 표현하는 값만 허용한다면 코드를 작성하기 쉬워지고 타입 체크가 용이해진다
타입을 설계할 때는 어떤 값들을 포함하고 어떤 값들을 제외할지 신중하게 생각해야 한다
예시 코드

4-2. 사용할 때는 너그럽게, 생성할 때는 엄격하게

TCP 구현체는 견고성의 일반적 원칙을 따라야 한다. 당신의 작업은 엄격하게 하고, 다른 사람의 작업은 너그럽게 받아들여야 한다. - 존 포스텔(Jon Postel), 《견고성 원칙(robustness principle)》
함수의 매개변수 타입은 범위가 넓어도 되지만, 결과를 반환할 때는 일반적으로 타입의 범위가 더 구체적이어야 한다.
매개변수 타입의 범위가 넓으면 사용하기 편리하지만, 반환 타입의 범위가 넓으면 불편하다.
사용하기 편리한 API 일수록 반환 타입이 엄격해야 한다.
매개변수와 반환 타입의 재사용을 위해서 기본 형태(반환 타입)와 느슨한 형태(매개변수 타입)을 도입하는 것이 좋다

4-3. 문서에 타입 정보를 쓰지 않기

타입스크립트의 타입 구문 시스템은 간결하고, 구체적이며 쉽게 읽을 수 있도록 설계되어 있다.
함수의 입력과 출력의 타입을 코드로 표현하는 것이 주석보다 더 나은 방법이다.
타입 구문은 타입스크립트 컴파일러가 체크해 주기 때문에 절대로 구현체와 정합성이 어긋나지 않는다.
주석은 코드와 동기화되지 않지만, 타입 구문은 타입스크립트 타입 체커가 타입 정보를 동기화하도록 강제한다.
특정 매개변수를 설명하고 싶다면 JSDoc 의 @param 구문을 사용하면 된다.
값을 변경하지 않는다고 설명하는 주석보다는 매개변수를 readonly 로 선언하여 타입스크립트가 규칙을 강제할 수 있도록 해야 한다.
하지만, 단위가 있는 숫자들은 예외이다
단위가 무엇인지 확실하지 않는다면 변수명 또는 속성 이름에 단위를 포함할 수 있다
timeMstime 보다 명확하고, temperatureCtemperature 보다 명확하다

요약

주석과 변수명에 타입 정보를 적는것은 피해야 한다
타입이 명확하지 않은 경우는 변수명에 단위 정보를 포함하는 것을 고려하는 것이 좋다.

4-4. 타입 주변에 null 값 배치하기

undefined 를 포함하는 객체는 다루기 어렵고 절대 권장하지 않는다.
예시 코드
 Bad (strictNullChecks: false)
function extent(nums: number[]) { let min, max; for (const num of nums) { if (!min) { min = num; max = num; } else { min = Math.min(min, num); max = Math.max(max, num); } } return [min, max]; }
TypeScript
복사
 Good
function extent(nums: number[]) { let result: [number, number] | null = null; for (const num of nums) { if (!result) { result = [num, num]; } else { result = [Math.min(num, result[0]), Math.max(num, result[1])]; } } return result; }
TypeScript
복사

정리

한 값의 null 여부는 다른 값의 null 여부에 암시적으로 관련되도록 설계하면 안된다
API 작성 시에는 반환 타입을 큰 객체로 만들고 반환 타입 전체가 null 이거나 null 이 아니게 만들어야 한다.
클래스를 만들 때는 필요한 모든 값이 준비되었을 때 생성하여 null 이 존재하지 않도록 하는것이 좋다
strictNullChecks 를 설정하면 코드에 많은 오류가 표시되겠지만, null 값과 관련됨 문제점을 찾아낼 수 있기 때문에 반드시 필요하다.

4-5. 유니온의 인터페이스보다는 인터페이스의 유니온을 사용하기

각 타입의 속성들 간의 관계를 제대로 모델링하면, 타입스크립트가 코드의 정확성을 체크하는데 도움이 된다.
예시 코드
 Bad
interface Layer { layout: FillLayout | LineLayout | PointLayout; paint: FillPaint | LinePaint | PointPaint; }
TypeScript
복사
 Good
interface FillLayer { type: 'fill'; layout: FillLayout; paint: FillPaint; } interface LineLayer { type: 'line'; layout: LineLayout; paint: LinePaint; } interface PointLayer { type: 'point'; layout: PointLayout; paint: PointPaint; } type Layer = FillLayer | LineLayer | PointLayer;
TypeScript
복사
태그된 유니온은 타입스크립트 타입 체커와 잘 맞기 때문에 타입스크립트 코드에서 찾아보기 쉽다
여러 개의 선택적 필드가 동시에 값이 있거나, 동시에 undefined 인 경우 태그된 유니온 패턴을 이용하면 좋다
예시 코드
 Bad
interface Person { name: string; // 다음은 둘 다 동시에 있거나 동시에 없습니다. placeOfBirth?: string; dateOfBirth?: Date; }
TypeScript
복사
 Good
interface Name { name: string; } interface PersonWithBirth extends Name { placeOfBirth: string; dateOfBirth: Date; } type Person = Name | PersonWithBirth
TypeScript
복사

요약

유니온 타입의 속성을 여러 개 가지는 인터페이스에서는 속성 간의 관계가 분명하지 않기 때문에 실수가 자주 발생하므로 주의해야 한다
유니온의 인터페이스보다 인터페이스의 유니온이 더 정확하고 타입스크립트가 이해하기도 좋다
타입스크립트가 제어 흐름을 분석할 수 있도록 타입에 태그를 넣는 것을 고려해야 한다
태그된 유니온은 타입스크립트와 매우 잘 맞기 때문에 자주 볼 수 있는 패턴이다.

4-6. string 타입보다는 더 구체적인 타입 사용하기

string 의 타입 범위는 x , y 와 같은 단일 문자부터 모비딕 (소설) 의 전체 길이까지 포함하는 모든 내용이 다 string 타입이다
string 타입으로 변수를 선언하려 한다면, 그보다 더 좁은 타입이 없는지 검토해봐야 한다.
예시 코드
 Bad
interface Album { artist: string; title: string; releaseDate: string; // YYYY-MM-DD recordingType: string; // e.g. live | studio }
TypeScript
복사
 Good
type RecordingType = 'studio' | 'live'; interface Album { artist: string; title: string; releaseDate: Date; recordingType: RecordingType; }
TypeScript
복사
좀 더 좁은 타입으로 선언하면 얻을 수 있는 장점
1.
타입을 명시적으로 정의함으로써 다른 곳으로 값이 전달되어도 타입 정보가 유지된다
function getAlbumsOfType(recordingType: string): Album[] { // ... }
TypeScript
복사
2.
타입을 명시적으로 정의하고 해당 타입의 의미를 설명하는 주석을 붙여 넣을 수 있다
/** 이 녹음은 어떤 환경에서 이루어졌는지? */ type RecordingType = 'live' | 'studio';
TypeScript
복사
3.
keyof 연산자로 더욱 세밀하게 객체의 속성 체크가 가능하다.
function pluck<T, K extends keyof T>(records: T[], key: K): T[K][] { return records.map(r => r[key]); } pluck(albums, 'releaseDate'); // Date[] pluck(albums, 'artist'); // string[] pluck(albums, 'recordingType'); // RecordingType[]
TypeScript
복사

요약

모든 문자열을 할당할 수 있는 string 타입보다는 더 구체적인 타입을 사용하는 것이 좋다
변수의 범위를 보다 정확하게 표현하고 싶다면 string 타입보다는 문자열 리터럴 타입의 유니온을 사용하면 된다
객체의 속성 이름을 함수 매개변수로 받을 때는 string 보다는 keyof T 를 사용하는 것이 좋다

4-7. 부정확한 타입보다는 미완성 타입을 사용하기

불쾌한 골짜기란? 로봇 공학과 인공 지능에서 많이 쓰이는 용어인데, 어설프게 인간과 비슷한 로봇에서 느끼는 불쾌함을 뜻한다. 저자의 의도는 타입 선언에서 어설프게 완벽을 추구하려다가 오히려 역효과가 발생하는 것을 주의하자는 것이다.
타입을 이용해서 부정확함을 바로잡는 방법을 쓰는 대신, 테스트 세트를 추가하여 놓친 부분이 없는지 확인하는게 더 좋다.
일반적으로 복잡한 코드는 더 많은 테스트가 필요하고 타입의 관점에서도 마찬가지다.
타입을 정제(refine) 할 때, 불쾌한 골짜기 은유를 생각해보면 도움이 될 수 있다.
일반적으로 any 와 같은 매우 추상적인 타입은 정제하는 것이 좋다.
타입이 구체적으로 정제된다고 해서 정확도가 무조건 올라가지는 않는다.
타입에 의존하기 시작하면 부정확함으로 인해 발생하는 문제가 더 커질 수 있다

요약

타입의 안정성에서 불쾌한 골짜기는 피해야 한다.
타입이 없는 것보다 잘못된 게 더 나쁘다
정확하게 타입을 모델링할 수 없다면, 부정확하게 모델링하지 말아야 한다.
또한 any 와 unknown 을 구별해서 사용해야 한다
타입 정보를 구체적으로 만들수록 오류 메시지와 자동 완성 기능에 주의를 기울여야 한다
정확도 뿐만 아니라 개발 경험과도 관련된다

4-8. 데이터가 아닌, API 와 명세를 보고 타입 만들기

예시 데이터가 아니라 명세서를 참고해 타입을 생성하면 타입스크립트는 사용자가 실수를 줄일 수 있게 도와준다
만약 예시 데이터를 참고해 타입을 생성하면 보이는 데이터들만 고려하게 되므로 예기치 않은 곳에서 오류가 발생할 수 있다
만약 명세 정보나 공식 스키마가 없다면 데이터로부터 타입을 생성해야 한다
이를 위해 quicktype 같은 도구를 사용할 수 있다
생성된 타입이 실제 데이터와 일치하지 않을 수 있다는 점을 주의해야 한다 ( 예외 케이스가 존재할 수 있다 )

요약

코드의 타입 안정성을 얻기 위해 API 또는 데이터 형식에 대한 타입 생성을 고려해야 한다
데이터에 드러나지 않는 예외적인 경우들이 문제가 될 수 있기 때문에 데이터보다는 명세로부터 코드를 생성하는 것이 좋다

4-9. 해당 분야의 용어로 타입 이름 짓기

컴퓨터 과학에서 어려운 일은 단 두가지 뿐이다. 캐시 무효화와 이름 짓기. - 필 칼튼(Phill Karlton)
엄선된 타입, 속성, 변수의 이름은 의도를 명확히 하고 코드와 타입 추상화 수준을 높여준다
잘못 선택한 타입 이름은 코드의 의도를 왜곡하고 잘못된 개념을 심어주게 된다
코드로 표현하고자 하는 모든 분야에는 주제를 설명하기 위한 전문 용어들이 있다
자체적으로 용어를 만들어 내려고 하지 말고, 해당 분야에 이미 존재하는 용어를 사용해야 한다
전문 분야의 용어는 정확하게 사용해야 한다
특정 용어를 다른 의미로 잘못 쓰게 되면, 직접 만들어 낸 용어보다 더 혼란을 주게 된다
타입, 속성, 변수에 이름을 붙일 때 명심해야 하는 3가지 규칙
1.
동일한 의미를 나타낼 때는 같은 용어를 사용해야 한다
2.
data, info, thing, item, object, entity 같은 모호하고 의미 없는 이름은 피해야 한다
3.
포함된 내용이나 계산 방식이 아니라, 데이터 자체가 무엇인지를 고려해야 한다

요약

가독성을 높이고, 추상화 수준을 올리기 위해서 해당 분야의 용어를 사용해야 한다
같은 의미에 다른 이름을 붙이면 안된다
특별한 의미가 있을 때만 용어를 구분해야 한다

4-10. 공식 명칭에는 상표를 붙이기

구조적 타이핑의 특성 때문에 가끔 코드가 이상한 결과를 낼 수 있다.
예시 코드
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}); // 결과: 5 const vec3D = { x: 3, y: 4, z: 1} calculateNorm(vec3D); // 결과: 5
TypeScript
복사
공식 명칭(nominal typing) 을 사용하는 것은, 타입이 아니라 값의 관점에서 의미하게 된다.
공식 명칭 개념을 타입스크립트에서 흉내 내려면 상표(brand) 를 붙이면 된다.
예시 코드
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); } calculateNorm(vec2D(3, 4)); // 결과: 5 const vec3D = { x: 3, y: 4, z: 1 }; calculateNorm(vec3D); // 에러
TypeScript
복사
상표 기법은 타입 시스템에서 동작하지만, 런타임에 상표를 검사하는 것과 동일한 효과를 얻을 수 있다
상표 기법은 타입 시스템 내에서 표현할 수 없는 수많은 속성들을 모델링 하는 데 사용되기도 한다
상표 기법은 number 타입에도 상표를 붙일 수 있다
number 타입에 상표를 붙어도 산술 연산 후에는 상표가 없어지기 때문에 실제로 사용하기에는 무리가 있다
코드에 여러 단위가 혼합된 많은 수의 숫자가 들어 있는 경우, 숫자의 단위를 문서화 하는 괜찮은 방법일 수 있다
예시 코드
type Meters = number & { _brand: 'meters' }; type Seconds = number & { _brand: 'seconds' }; const meters = (m: number) => m as Meters; const seconds = (s: number) => s as Seconds; const oneKm = meters(1000); const oneMin = seconds(6); const tenKm = oneKm * 10; // number const v = oneKm / oneMin; // number
TypeScript
복사

요약

타입스크립트는 구조적 타이핑을 사용하기 때문에, 값을 세밀하게 구분하지 못하는 경우가 있다
값을 세밀하게 구분하기 위해서는 상표 기법을 사용하는 것을 고려해야 한다
상표 기법은 타입 시스템에서 동작하지만, 런타임에 상쵸를 검사하는 것과 동일한 효과를 얻을 수 있다