
소프트웨어 개발자라면 어느 분야에서든 DRY(don't repeat yourself) 원칙을 지키려 한다.
하지만 반복된 코드를 열심히 제거하며 이 원칙을 지켜왔던 사람들도 타입에 대해서는 간과하기 쉽다.
타입 중복은 코드 중복만큼 많은 문제를 발생시킨다. 예를 들어, 아래 예시에서 Person 에 선택적 필드인 middleName을 추가하면 Person과 PersonWithBirthDate는 다른 타입이 된다.
interface Person {
firstName: string;
lastName: string;
}
interface PersonWithBirthDate {
firstName: string;
lastName: string;
birth: Date;
}
// 여기선 아래의 interface가 Person을 확장하게 만들어 반복을 제거할 수 있다.
interface PersonWithBirthDate extends Person{
birth: Date;
}
➡️ 이미 존재하는 타입을 확장하는 경우에, 일반적이지는 않으나 인터섹션 연산자(&)를 쓸 수도 있다.
type PersonWithBirthDate = Person & {birth : Date};
! 이런 기법은 유니온 타입(확장할 수 없는)에 속성을 추가하려고 할 때 특히 유용하다.
➡️ 반복을 줄이는 가장 간단한 방법은 타입에 이름을 붙이는 것이다.
function distacne(a:{x:number,y:number}, b: {x:number, y:number}) {/* ... */}
// 개선
interface Point2D {
x: number;
y: number;
}
function distance (a: Point2D, b:Point2D) {/* ... */}
// 상수를 사용해서 반복을 줄이는 기법을 동일하게 타입 시스템에 적용한 것
➡️ 인덱싱과 매핑된 타입
// 애플리케이션의 전체 상태를 표현하는 State
interface State {
userId: string;
pageTitle: string;
recentFiles: string[];
pageContents: string;
}
// 일부분만 표현하는 TopNaveState
interface TopNavState {
userId: string;
pageTitles: string;
recentFiles: string[];
}
위에선 TopNaveState을 확장하여 State를 구성하기보다, State의 부분 집합으로 TopNaveState를 정의 하는 것이 바람직하다. 이 방법이 전체 앱의 상태를 하나의 인터페이스로 유지할 수 있게 해준다.
type TopNaveState = {
userId: State['userId'];
pageTitle: State['pageTitles'];
recentFiles: State['recentFiles'];
}
type TopNavState = {
[k in 'userId' | 'pageTitle' | 'recentFiles']:State[k]
}
매핑된 타입은 배열의 필드를 루프 도는 것과 같은 방식이다. 이 패턴은 표준 라이브러이에서도 일반적으로 찾을 수 있고, Pick 이라고 한다.
type Pick<T,k> = {[k in K]:T[k]};
//Pick을 이용하여 다음과 같이 사용할 수 있다.
type TopNavState = Pick<State, 'userId' | 'pageTitle' | 'recentFiles'>;
여기서 Pick 은 제너릭 타입이다.
➡️ 태그된 유니온에서도 다른 형태의 중복이 발생할 수 있다.
interface SaveAction {
type: 'save';
}
interface LoadAction {
type: 'load';
}
type Action = SaveAction | LoadAction
type ActionType = 'save' | 'load'; // ! Action 타입과 반복
//인덱싱 사용
type ActionType = Action['type']; // 'save' | 'load'
❗이렇게 얻은 ActionType은 Pick을 사용하여 얻게 되는, type 속성을 가지는 인터페이스와는 다르다.
type ActionType = Pick<Action,'type'> // {type: 'save' | 'load'}
➡️ 매핑된 타입과 keyof
interface Options {
width: number;
height: number;
color: string;
label: string;
}
interface OptionsUpdate {
width ?: number;
heigth ?: number;
color ?: string;
label ?: string;
}
class UIWidget {
constructor(init: Options) {/* ... */}
update(options: OptionsUpdate) {/* ... */}
}
매핑된 타입과 keyof를 사용하면 Options로부터 OptionsUpdate를 만들 수 있다.
type OptionsUpdate = {[k in keyof Options]?: Options[k]}
❗이 패턴 역시 일반적이며 표준 라이브러리에 Partial이라는 이름으로 포함되어 있다.
type OptionsUpdate = Partial<Options>
// 결과는 위와 같다.
➡️ typeof
값의 형태에 해당하는 타입을 정의하고 싶을 땐 typeof을 사용한다.
const INIT_Options = {
width: 1,
height: 2,
color: '#111111',
label : 'VGA'
}
type Options = typeof INIT_Options;
// {
// width: number;
// heigth: number;
// color: string;
// label: string;
// }
JS의 런타임 연산자 typeof을 사용한 것처럼 보이지만, 실제로는 TS 단계에서 연산되며 훨씬 더 정확하게 타입을 표현한다. (JS는 값의 관점, TS는 타입의 관점)
❗but, 값으로부터 타입을 만들어 낼 떄는 선언의 순서에 주의해야 한다. 타입 정의를 먼저하고 값이 그 타입에 할당 가능하다고 선언하는 것이 좋다. 그래야 타입이 더 명확해지고, 예상하기 어려운 타입 변동을 방지할 수 있다.
➡️ 함수나 메서드의 반환 값에 명명된 타입을 만들고 싶을 때
// 예시
function getUserInfo(userId: string) {
// ...
return {
userId,
name,
age,
height,
weight,
favoriteColor,
};
// 추론된 반환 타입은 {userId: string; name: string; age:nubmer, ...}
}
조건부 타입을 사용하면 된다.
앞의 Pick , Partial 과 같이 이러한 일반적 패턴의 제너릭 타입은 정의되어 있다.
이 경우 ReturnType 제너릭을 사용한다.
type UserInfo = ReturnType<typeof getUserInfo>
ReturnType은 함수의 '값'인 getUserInfo가 아니라 함수의 '타입'인 typeof getUserInfo에 적용되었다. typeof과 마찬가지로 이런 기법은 적용 대상이 값인지 타입인지 정확히 알고, 구분해서 처리해야 한다.
➡️ 제너릭 타입은 타입을 위한 함수와 같다.
타입에 대한 DRY 원칙의 핵심이 제너릭이지만 간과한 것이 있다.
함수에서 매개변수로 매핑할 수 있는 값을 제한하기 위해 타입 시스템을 사용하는 것처럼 제너릭 타입에서 매개변수를 제한할 수 있는 방법이 필요하다.

extends를 이용하면 제너릭 매개변수가 특정 타입을 확장한다고 선언할 수 있다.
(위 예시는 {first: string} 이 Name을 확장하지 않기 때문에 오류가 발생.)
++ 타입스크립트에서는 선언부에 항상 제너릭 매개변수를 작성하도록 되어 있다.
위 예시에서 그냥 DancingDuo로 쓰면 동작하지 않는다.
TS가 제너릭 매개변수의 타입을 추론하게 하기 위해, 함수를 작성할 때는 신중하게 타입을 고려하자.
extends를 이용해 Pick의 정의를 완성할 수 있다.
// 앞서 정의한 Pick
type Pick<T,K> = {[k in K]:T[k]}
K는 T타입과 무관하고 범위가 매우 넓기 떄문에 범위를 좀 더 좁힐 필요가 있다.
type Pick<T, K extends keyof T> = {
[k in K]: T[k];
};
//lib.es5.d.ts 에는 이미 이렇게 정의되어 있음.
K는 실제로 T의 키의 부분 집합, 즉 keyof T가 되어야 한다.
JS의 장점 중 하나는 객체를 생성하는 문법이 간단하다는 것이다.
JS 객체는 문자열 키를 타입의 값에 관계없이 매핑하는데, TS에서는 타입에 '인덱스 시그니처'를 명시하여 유연하게 매핑을 표현할 수 있다.
const rocket = {
name: 'Falcon',
variant: 'Block',
thrust: '1,333'
}
//인덱스 시그니처로 type을 명시
type Rocket = {[property: string]: string};
const rocket :Rocket = {
name: 'Falcon',
variant: 'Block',
thrust: '1,333'
}
// [property: string]: string
// 인덱스 시그니처
❗이렇게 타입 체크를 하면 단점이 있다.
결론 > 위에서 인덱스 시그니처는 부정확하므로 더 나은 방법을 찾아야 한다.
예를 들어, 위의 Rocket 예시는 interface로 표현해야 한다.
interface Rocket {
name: string;
variant: string;
thrust: number;
}
++ 그럼 인덱스 시그니처는 어디에 사용?
!! 인덱스 시그니처는 동적 데이터를 표현할 때 사용한다.
(런타임 때까지 객체의 속성을 알 수 없을 경우에만)
일반적인 상황에서 해당 데이터 타입이 무엇인지 미리 알 방법이 없을 때를 말한다.
알고 있는 특정 상황이 온다면 미리 선언해둔 타입으로 단언문을 이용한다.
but, 실제로 일치한다는 보장이 없으므로 이 부분이 걱정된다면 값 타입에 undefined을 추가 한다.
❗어떤 타입에 가능한 필드가 제한되어 있는 경우라면 인덱스 시그니처로 모델링해선 안된다.
어떤 데이터에 A,B,C,D 같은 키가 있지만, 얼마나 많이 있는지 모른다면 선택적 필드 또는 유니온 타입으로 모델링하자.
interface Row {[column: string]: number} // 너무 광범위하다.
interface Row1 {a:number, b?:number, c?: number, d?: number}; // 최선
type Row3 = {a:number} | {a:number; b: number} | {a:number; b:number} //...
// 가장 정확하지만 사용하기 번거롭다.
string 타입이 너무 광범위해서 인덱스 시그니처를 사용하는 데 문제가 있다면, 두 가지 다른 대안을 생각해볼 수 있다.
Record 를 사용하는 방법Record 는 키 타입에 유연성을 제공하는 제너릭 타입이다.type Vec3D = Record<'x'|'y'|'z',number>
// Type Vec3D = {
// x: number;
// y: number;
// z: number;
// }
type Vec3D = {[k in 'x' | 'y' | 'z']:number};
// Type Vec3D = {
// x: number;
// y: number;
// z: number;
// }
type ABC = {[k in 'a' | 'b' | 'c']:k extends 'b' ? string : number};
// Type Vec3D = {
// a: number;
// b: string;
// c: number;
// }
☑️런타임 때까지 객체의 속성을 알 수 없을 경우에만 인덱스 시그니처를 사용하자.
☑️안전한 접근을 위해 인덱스 시그니처의 값 타임에 undefined를 추가하는 것을 고혀하자.
☑️가능하다면 interface, Record, 매핑된 타입 같은 인덱스 시그니처보다 정확한 타입을 사용하자.