
효과적으로 타입을 설계하려면, 유효한 상태만 표현할 수 있는 타입을 만들어 내는 것이 가장 중요하다.
interface State {
pageText: string;
isLoading: boolean;
error?: string;
}
위 예제 코드에서는 가능하지 않은 시나리오까지 표현이 가능하다. 예를 들어 요청이 실패했으면서 로딩 중일 수는 없는데 일단 타입에서는 그렇게 표현이 가능하다. 이러한 타입 설계는 코드를 뒤죽박죽으로 만드는 원인이 된다.
interface RequestPending {
state: 'pending';
}
interface RequestError {
state: 'error';
error: string;
}
interface RequestSuccess {
state: 'success';
pageText: string;
}
type RequestState = RequestPending | RequestError | RequestSuccess;
interface State {
currentPage: string;
request: { [page: string]: RequestState }
}
두 번째 예제 코드에서는 유효한 상태만을 표현하고 있다. 코드의 길이는 조금 더 길어졌지만 발생하는 모든 요청의 상태로서, 명시적으로 모델링 되었기 때문에 첫번째와 같은 오류가 발생하지 않는다.
유효한 상태와 무효한 상태를 둘 다 표현하는 타입은 오류를 유발하므로 유효한 상태만 표현하는 타입을 지향하자!!
함수의 매개변수는 타입의 범위가 넓어도 되지만, 결과를 반환할 때는 일반적으로 타입의 범위가 더 구체적이어야 한다.
declare function setCamera(camera: CameraOptions): void;
declare function viewportForBounds(bounds: LngLatBounds): CameraOptions;
type LngLat =
{ lng: number; lat: number; } |
{ lon: number; lat: number; } |
[ number, number ]
type LngLatBounds =
{ northeast: LngLat; southwest: LngLat; } |
[ LngLat, LngLat ] |
[ number, number, number, number ]
interface CameraOptions {
center?: LngLat;
zoom?: number;
bearing?: number;
pitch?: number;
}
위 예제에서 반환값인 cameraOptions의 형태가 너무 자유롭기 때문에, 반환받은 값을 사용하다보면 아래와 같은 오류가 발생한다.
number | undefined 로 추론되기 때문const cameraOptions = viewportForBounds(bounds);
const { center: {lat, lan}, zoom} = cameraOptions;
// ~~~ ...형식에 'lat' 속성이 없습니다.
// ~~~ ...형식에 'lan' 속성이 없습니다.
cameraOptions을 안전한 타입으로 사용하려면 유니온 타입의 각 요소별로 코드를 분기하는 것이다. 분기 처리를 위한 방법 중에 하나로 LngLat, LngLatLike 로 구분할 수 있다.
declare function setCamera(camera: CameraOptions): void; // 느슨한 매개변수 타입
declare function viewportForBounds(bounds: LngLatBounds): Camera; // 엄격한 반환값 타입
interface LngLat { lng: number; lat: number; };
type LngLatLike =
LngLat |
{ lon: number; lat: number; } |
[number, number]
interface Camera { // 엄격한 반환값 타입
center: LngLat;
zoom: number;
bearing: number;
pitch: number;
}
interface CameraOptions { // 느슨한 매개변수 타입
center?: LngLatLike;
zoom?: number;
bearing?: number;
pitch?: number;
}
아래 코드를 편집기에서 보면 lat, lng가 number 타입으로 제대로 추론된다.
const cameraOptions = viewportForBounds(bounds);
const { center: {lat, lng}, zoom} = cameraOptions;
매개변수와 반환 타입의 재사용을 위해 기본 형태(반환 타입)와 느슨한 형태(매개변수 타입)을 도입하는 것이 좋다.
타입스크립트의 타입 구문 시스템은 간결하고, 구체적이며, 쉽게 읽을 수 있도록 설계되었다. 또 타입 구문은 타입스크립트 컴파일러가 체크해 주기 때문에, 절대로 구현체와의 정합성이 어긋나지 않는다.
누군가 강제하지 않는 이상 주석은 코드와 동기화되지 않는다. 하지만 타입 구문은 타입스크립트 타입 체커가 타입 정보를 동기화하도록 강제한다.
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); // max: number | undefined
//'number | undefined' 형식의 인수는 'number' 형식의 매개 변수에 할당될 수 없습니다.
//'undefined' 형식은 'number' 형식에 할당할 수 없습니다.
}
}
return [min, max];
}
이 오류는 undefined를 min에서만 제외하고 max에서는 제외하지 않았기 때문에 발생했다. 두 개의 변수는 동시에 초기화되지만, 이러한 정보는 타입 시스템에서 표현할 수 없다.
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;
}
// number 타입
const [min, max] = extent([0, 1, 2])! //null이 아님으로 단언
const span = max - min // ok
const range = extent([0, 1, 2]);
if(range) {
const [min, max] = range;
const span = max - min; // ok
}
API 작성 시 반환 타입을 큰 객체로 만들고 반환 타입 전체가 null이거나 null이 아니게 만들자.strictNullChecks 사용유니온 타입의 속성을 여러 개 가지는 인터페이스에서는 속성 간의 관계가 분명하지 않기 때문에 주의해야 한다.
유니온의 인터페이스보다 인터페이스의 유니온이 더 정확하고 이해하기 쉽다.
interface Layer {
type: "fill" | "line" | "point";
layout: FillLayout | LineLayout | PointLayout;
paint: FillPaint | LinePaint | PointPaint;
}
타입 fill과 함께 LineLayout과 PointPaint 등이 쓰이는 것은 말이 되지 않는다.
Layer을 인터페이스의 유니온으로 변환해보자.
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;
type 속성은 태그이다. 인터페이스의 유니온으로 정의한 경우 Layer 타입의 범위를 좁히기도 용이하다.
여러 개의 선택적 필드가 동시에 값이 있거나 동시에 undefined인 경우도 태그된 유니온 패턴이 잘 맞다.
interface Person {
name: string;
placeOfBirth?: string;
dateOfBirth?: Date;
}
placeOfBirth와 dateOfBirth는 관련되어 있지만 타입 정보에는 어떠한 관계도 표현되지 않았다.
두 속성을 하나의 객체로 모으자.
interface Person {
name: string;
birth?: {
place: string;
date: Date;
};
}
이렇게 되면 place만 있고 date가 없는 경우에는 오류가 발생하고 Person 객체를 매개변수로 받는 함수는 birth 하나만 체크하면 된다.
API 결과 처럼 타입의 구조를 손 댈 수 없는 상황이면, 인터페이스의 유니온을 사용해서 속성 사이의 관계를 모델링 할 수 있다.
interface Name {
name: string;
}
interface PersonWithBirth extends Name {
placeOfBirth: string;
dateOfBirth: Date;
}
type Person = Name | PersonWithBirth;
- 유니온의 인터페이스보다 인터페이스의 유니온이 더 정확하고 타입스크립트가 이해하기도 좋다.
- 타입스크립트가 제어 흐름을 분석할 수 있도록 타입에 태그를 넣는 것도 고려해보자.
string 타입의 범위는 매우 넓다. ' ', " " 에 감싸져 있는 모든 것을 의미하기 때문이다. 그래서 string 타입으로 변수를 선언할 때 더 좁은 타입이 적절하지는 않을지 검토해야한다.
// bad
interface Album {
artist: string;
title: string;
releaseDate: string;
recordingType: string;
}
// good
type RecordingType = "studio" | "live";
interface Album {
artist: string;
title: string;
releaseDate: Date;
recordingType: RecordingType;
}
장점 1. 타입을 명시적으로 정의함으로써 다른 곳으로 값이 전달되어도 타입 정보가 유지된다.
장점 2. 타입을 명시적으로 정의하고 해당 타입의 의미를 설명하는 주석을 붙여 넣을 수 있다.
// 이 녹음이 어떤 환경에서 이루어졌는지
type RecordingType = "studio" | "live";
장점 3. keyof 연산자로 더욱 세밀하게 객체의 속성 체크가 가능해진다.
// any 타입이 있어서 매우 좋지 않은 설계이다.
function pluck(records: any[], key: string): any[] {
return records.map(r => r[key]);
}
// 제너릭 타입 도입. key타입이 string. 범위가 너무 넓어 오류 발생
function pluck<T>(records: T[], key: string): any[] {
return records.map(r => r[key]);
// ~~~~~~ '{}' 형식에 인덱스 시그니처가 없으므로
// 요소에 암시적으로 'any' 형식이 있습니다.
}
// key 는 "artist" | "title" | "releaseDate" | "recordingType" 만 유효
type K = keyof Album;
// key의 타입을 string -> keyof T로 변경 -> T 객체 내의 가능한 모든 값
function pluck<T>(records: T[], key: keyof T) {
return record.map(r => r[key]);
}
declare let albums: Album[];
const releaseDates = pluck(albums, 'releaseDate'); // 타입(string | Date)[]
// 2. keyof T의 부분 집합으로 두번 째 제너릭 도입
function pluck<T, K extends keyof T>(records: T[], key: K): T[K][] {
return record.map(r => r[key]);
}
// K에 포함되야만 유효하도록 만듬
string 타입보다 더 구체적인 타입을 사용하자!keyof T를 사용하자!타입 선언의 정밀도를 높이는 일에는 주의를 기울여야 한다. 실수가 발생하기 쉽고 잘못된 타입은 차라리 타입이 없는 것 보다 못할 수 있다.
- 타입이 구체적으로 정제된다 해서 정확도가 무조건 올라가지는 않는다. 타입에 의존함으로써 부정확으로 인해 발생하는 문제가 더 커질 수도 있다.
- 타입이 없는 것보다 잘못된 게 더 나쁘다.
파일 형식, API, 명세(specification) 등 우리가 다루는 타입 중 최소한 몇 개는 프로젝트 외부에서 비롯된 것이다. 이러한 경우는 타입을 직접 작성하지 않고 자동으로 생성할 수 있다.
예시 데이터가 아니라 명세를 참고해 타입을 생성한다.
코드로 표현하고자 하는 모든 분야에는 주제를 설명하기 위한 전문 용어들이 있다. 자체적으로 용어를 만들어 내려 하지 말고, 존재하는 전문 용어를 사용하자.
동일한 의미를 나타낼 때는 같은 용어를 사용해야 한다. 의미적으로 확실하게 구분이 되는 경우에만 다른 용어를 사용하자.
data, info, thing, item, object 같은 모호하고 의미 없는 이름은 피하자.
이름을 지을 때 포함된 내용이나 계산 방식이 아니라 데이터 자체가 무엇인지를 고려하자.
구조적 타이핑을 따르는 타입스크립트에서 각 타입을 정교하게 구분하기 위해서 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);
}
const myVec2D = vec2D(3,4); // '2d' 브랜드 있음
const myVec3D = { x: 3, y: 4, z: 1 }; // '2d' 브랜드 없음
calculateNorm(myVec2D); // 정상
calculateNorm(myVec3D); // 에러, '_brand' 속성이 ... 형식에 없습니다.
상표 기법은 타입 시스템에서 동작하지만 런타임에 상표를 검사하는 것과 동일한 효과를 얻을 수 있다.
🎈 타입 시스템이기 때문에 런타임 오버헤드를 없앨 수 있고 string, number와 같은 내장 타입도 상표화할 수 있다.
상표를 붙이는 것을 고려하자.