33. string 타입보다 더 구체적인 타입 사용하기
문자열을 남발하여 선언된(stringly typed) 코드를 비하자. 모든 문자열을 할당할 수 있는 string보다 더 구체적인 타입을 사용하자.
// 좋지 않은 예
interface Album {
artist: string;
title: string;
releaseDate: string;
recordingType: string;
}
// 개선한 코드
type RecordingType = "studio" | "live";
interface Album {
artist: string;
title: string;
releaseDate: Date;
recordingType: RecordingType;
}
장점 1. 타입을 명시적으로 정의함으로써 다른 곳으로 값이 전달되어도 타입 정보가 유지된다.
장점 2. 타입을 명시적으로 정의하고 해당 타입의 의미를 설명하는 주석을 붙여 넣을 수 있다.
// 이 녹음이 어떤 환경에서 이루어졌는지
type RecordingType = "studio" | "live";
장점 3. keyof 연산자로 더욱 세밀하게 객체의 속성 체크가 가능해진다.
함수의 매개변수에 string을 잘못 사용하는 일은 흔하다.
객체의 속성 이름을 함수 매개현수로 받을 때도 string보다 keyof T를 사용하자.
function pluck(records: any[], key: string): any[] {
return records.map((r) => r[key]);
}
any 때문에 정밀하지 못하다. 특히 반환 값에 any를 사용하는 것은 매우 좋지 않은 설계다.
타입 시그니처를 개선하는 첫 단계로 제너릭 타입을 도입해 보자.
function pluck<T>(records: T[], key: string): any[] {
return records.map((r) => r[key]);
// '{}' 형식에 인덱스 시그니처가 없으므로 요소에 암시적으로 'any' 형식이 있습니다.
}
이제 key의 타입이 string이기 때문에 범위가 너무 넓다는 오류를 발생시킨다.
Album의 배열을 매개변수로 전달하면, key는 artist, title, releaseDate, recordingType만이 유효하다.
따라서 string을 keyof T로 바꾸면 된다.
function pluck<T>(records: T[], key: keyof T) {
return records.map((r) => r[key]);
} // function pluck<T>(records: T[], key: keyof T): T[keyof T][]
T[keyof T]는 T 객체 내의 가능한 모든 값의 타입니다.
그런데 key의 값으로 하나의 문자열을 넣게 되면, 그 범위가 너무 넓어서 적절한 타입으로 보기 어렵다.
const releaseDates = pluck(albums, "releaseDate"); // (string | Date)[]
releaseDates의 타입은 (string | Date)[]가 아니라 Date[]이어야 한다.
따라서 범위를 더 좁히기 위해서, keyof T의 부분 집합으로 두 번째 제너릭 매개변수를 도입해야 한다.
function pluck<T, K extends keyof T>(records: T[], key: K) {
return records.map((r) => r[key]);
}
const releaseDates = pluck(albums, "releaseDate"); // Date
string은 any와 비슷한 문제를 가지고 있다. 잘못 사용하면 무효한 값을 허용하고 타입 간의 관계도 감추어 버린다.
string의 부분 집합을 정의할 수 있는 기능은 안전성을 크게 높인다.
34. 부정확한 타입보다는 미완성 타입을 사용하기
타입을 과하게 구체적으로 정의해서 오히려 타입이 부정확해질 수도 있다.
JSON으로 정의된 Lisp와 비슷한 언어의 타입을 선언한다고 해보자.
type Expression = number | string | any[];
const tests: Expression[] = [
["+", 10, 5],
["case", [">", 20, 10], "red", "blue", "green"], // 오류 체크 해야 함. (값이 너무 많음)
["**", 2, 31], // 오류 체크 해야 함 ('**'는 함수가 아님)
["rgb", 255, 128, 64],
["rgb", 255, 0, 127, 0], // 오류 체크 해야 함. (값이 너무 많음)
];
정밀도를 끌어 올려보자.
type CallExpression = MathCall | CaseCall | RGBCall;
type Expression = number | string | CallExpression;
interface MathCall {
0: "+" | "-" | "*" | "/" | ">" | "<";
1: Expression;
2: Expression;
length: 3;
}
interface CaseCall {
0: "case";
1: Expression;
2: Expression;
length: 4 | 6 | 8 | 10 | 12 | 14 | 16;
}
interface RGBCall {
0: "rgb";
1: Expression;
2: Expression;
length: 4;
}
const tests: Expression[] = [
["+", 10, 5],
["case", [">", 20, 10], "red", "blue", "green"], // 오류
["**", 2, 31], // 오류
["rgb", 255, 128, 64],
["rgb", 255, 0, 127, 0], // 오류
];
타입 정보를 구체적으로 만들수록 오류 메시지와 자동 완성 기능에 주의를 기울여야 한다.
정밀도는 더 높아졌지만, 오류 메시지를 확인해보면 매우 난해하다.
언어 서비스 또한 중요한 부분이다. 타입 선언이 더 구체적이지만 자동 완성을 방해하므로 타입스크립트 개발 경험을 해치게 된다.
타입 안전성에서 불쾌한 골짜기는 피해야 한다. 타입이 없는 것보다 잘못된 게 더 나쁘다.
any 같은 매우 추상적인 타입은 정제하는 것이 좋다.
그러나 타입이 구체적으로 정제된다고 해서 정확도가 무조건 올라가는 것은 아니다.
정확하게 타입을 모델링할 수 없다면, 부정확하게 모델링하지 말아야 한다.
또한 any와 unknown를 구별해서 사용해야 한다.
35. 데이터가 아닌, API와 명세를 보고 타입 만들기
명세를 참고해 타입을 생성하면 타입스크립트는 사용자가 실수를 줄일 수 있게 도와준다.
반면에 예시 데이터를 참고해 타입을 생성하면 눈앞의 데이터들만 고려하게 되므로 오류가 발생할 수 있다.
데이터에 드러나지 않는 예외적인 경우들이 문제가 될 수 있기 때문에 데이터보다는 명세로부터 코드를 생성하는 것이 좋다.
API 명세로부터 타입을 생성할 수 있다면 그렇게 하는 것이 좋다.
특히 GraphQL처럼 자체적으로 타입이 정의된 API에서 잘 동작한다.
GraphQL의 장점은 특정 쿼리에 대해 타입스크립트 타입을 생성할 수 있다는 것이다.
query getLicense($owner: String!, $name: String!) {
repository(owner: $owner, name: $name) {
description
licenseInfo {
spdxId
name
}
}
}
위 GraphQL 쿼리를 타입스크립트 타입으로 변환해 주는 도구인 Apollo로 실행시키면 다음과 같은 결과가 나온다.
export interface getLicense_respository_licenseInfo {
__typename: "License";
spdxId: string | null;
name: string;
}
export interface getLicense_repository {
__typename: "Repository";
description: string | null;
licenseInfo: getLicense_respository_licenseInfo | null;
}
export interface getLicense {
repository: getLicense_repository | null;
}
export interface getLicenseVariables {
owner: string;
name: string;
}
자동으로 생성된 타입 정보는 API를 정확히 사용할 수 있도록 도와준다.
만약 명세 정보나 공식 스키마가 없다면 데이터로 부터 타입을 생성해야 한다. quicktype 같은 도구를 사용할 수 있다.
36. 해당 분야의 용어로 타입 이름 짓기
타입 이름 짓기 역시 타입 설계에서 중요한 부분이다.
엄선된 타입, 속성, 변수의 이름은 의도를 명확히 하고 코드와 타입의 추상화 수준을 높여 준다.
interface Animal {
name: string;
endangered: boolean;
habitat: string;
}
const leopard: Animal = {
name: "Snow Leopard",
endangered: false,
habitat: "tundra",
};
- name은 매우 일반적인 용어다. 동물의 학명인지 일반적인 명칭인지 알 수 없다.
- 멸종 위기를 표현하기 위해 boolean 타입을 사용한 것이 이상하다. 멸종 위기인지 멸종인지 모호하다.
- habitat 속성은 범위가 너무 넓은 string일 뿐만 아니라 서식지라는 뜻 자체도 불분명하다.
- 객체의 이름 leopard와 속성의 name이 다른 의도로 사용된 것인지 불분명하다.
데이터를 훨씬 명확하게 표현해보자.
interface Animal {
commonName: string;
genus: string;
species: string;
status: ConservationStatus;
climates: KoppenClimate[];
}
type ConservationStatus = "EX" | "EW" | "CR" | "EN" | "VU" | "NT" | "LC";
type KoppenClimate = "Af" | "Am" | "As";
const snowLeopard: Animal = {
commonName: "Snow Leopard",
genus: "Panthera",
species: "Uncia",
status: "VU",
climates: ["Af", "Am"],
};
전문 용어들이 사용되었다. 전문 용어를 사용하면 가동성을 높이고 추상화 수준을 올릴 수 있다.
타입, 속성, 변수에 이름을 붙을 때 명심해야 할 세 가지 규칙이 있다.
- 동일한 의미를 나타낼 때는 같은 용어를 사용해야 한다.
정말로 의미적으로 구분이 되어야 하는 경우에만 다른 용어를 사용해야 한다. - data, info, item 같은 모호하고 의미 없는 이름은 피해야 한다.
- 이름을 지을 때는 포함된 내용이나 계산 방식이 아니라 데이터 자체가 무엇인지를 고려해야 한다.
INodeList보다는 Directory가 더 의미있는 이름이다.
좋은 이름은 추상화의 수준을 높이고 의도치 않은 충돌의 위험성을 줄여 준다.
37. 공식 명칭에는 상표를 붙이기
타입스크립트는 구조적 타이핑(덕 타이핑)을 사용하기 때문에 값을 세밀게 구분하지 못하는 경우가 있다.
interface Vector2D {
x: number;
y: number;
}
function calculateNorm(p: Vector2D) {
return Math.sqrt(p.x * p.x + p.y * p.y);
}
const vec3D = { x: 3, y: 4, z: 1 };
calculateNorm(vec3D);
calculateNorm 함수가 3차원 벡터를 허용하지 않게 하려면 공식 명칭(nominal typing)을 사용하면 된다.
공식 명칭을 사용하는 것은 타입이 아닌 값의 관점에서 말하는 것이다.
공식 명칭 개념을 타입스크립트에서 흉내 내려면 상표를 붙이면 된다.
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)); // 정상
const vec3D = { x: 3, y: 4, z: 1 };
calculateNorm(vec3D); // '_brand' 속성이 없습니다.
상표 기법은 타입 시스템에서 동작하지만 런타임에 상표를 검사하는 것과 동일한 효과를 얻을 수 있다.
타입 시스템이기 때문에 런타임 오버헤드를 없앨 수 있고, 추가 속성을 붙일 수 없는 string, number 같은 내장 타입도 상표화할 수 있다.
절대 경로를 사용해 파일 시스템에 접근하는 함수를 가정해 보자.
런타임에는 절대 경로('/')로 시작하는지 체크하기 쉽지만, 타입스크립트에서는 어렵기 때문에 상표 기법을 사용한다.
type AbsolutePath = string & { _brand: "abs" };
function listAbsolutePath(path: AbsolutePath) {}
function isAbsolutePath(path: string): path is AbsolutePath {
return path.startsWith("/");
}
function f(path: string) {
if (isAbsolutePath(path)) {
listAbsolutePath(path);
}
listAbsolutePath(path); // 오류
}
string이면서 _brand 속성을 가지는 객체를 만들 수는 없다. AbsolutePath는 온전히 타입 시스템의 영역이다.
상표 기법은 타입 시스템 내에서 표현할 수 없는 수많은 속성들을 모델링하는 데 사용되기도 한다.
function binarySearch<T>(xs: T[], x: T): boolean {
let low = 0,
high = xs.length - 1;
while (high >= low) {
const mid = low + Math.floor((high - low) / 2);
const v = xs[mid];
if (v === x) return true;
[low, high] = x > v ? [mid + 1, high] : [low, mid - 1];
}
return false;
}
이진 검색은 이미 정렬된 상태를 가정하기 때문에, 목록이 정렬되어 있지 않다면 잘못된 결과가 나온다.
타입스크립트 타입 시스템에서는 목록이 정렬되어 있다는 의도를 표현하기 어렵다. 상표 기법을 사용해 보자.
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[], x: T): boolean {}
binarySearch를 호출하려면,
정렬되었다는 상표가 붙은 SortedList 타입의 값을 사용하거나 isSorted를 호출하여 정렬되어있음을 증명해야 한다.
isSorted를 호출하는 것이 효율적인 방법은 아니지만 적어도 안전성은 확보할 수 있다.
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); // Meters
const oneMin = seconds(50); // Seconds
const tenKim = oneKm * 10; // number
const v = oneKm / oneMin; // number
산술 연산 후에는 상표가 없어지기 때문에 실제로 사용하기에는 무리가 있다.
그러나 코드에 여러 단위가 혼합된 많은 수의 숫자가 들어 있는 경우, 숫자의 단위를 문서화하는 괜찮은 방법일 수 있다.