솔직히 개인적으로 제일 흥미롭게 읽은 아이템 😎
타입스크립트에는 타입을 정의할 수 있는 방법이 두가지 있다.
type TState = {
name: string;
capital: string;
}
interface IState {
name: string;
capital: string;
}
물론 인터페이스 대신 클래스를 사용할 수 있지만, 클래스는 값으로도 쓰일 수 있기 때문에 이 점을 유의해야 한다.
type TState = {
name: string;
capital: string;
}
interface IState {
name: string;
capital: string;
}
type TDict = { [key: string]: string };
interface IDict {
[key: string]: string;
}
type TState = {
name: string;
capital: string;
}
interface IState {
name: string;
capital: string;
}
type TFn = (x: number) => string;
interface IFn {
(x: number): string;
}
const toStrT: TFn = x => '' + x; // OK
const toStrI: IFn = x => '' + x; // OK
type TFnWithProperties = {
(x: number): number;
prop: string;
}
interface IFnWithProperties {
(x: number): number;
prop: string;
}
type TState = {
name: string;
capital: string;
}
interface IState {
name: string;
capital: string;
}
type TPair<T> = {
first: T;
second: T;
}
interface IPair<T> {
first: T;
second: T;
}
type TState = {
name: string;
capital: string;
}
interface IState {
name: string;
capital: string;
}
interface IStateWithPop extends TState {
population: number;
}
type TStateWithPop = IState & { population: number; };
다만, 인터페이스는 유니온 타입 같은 복잡한 타입을 확장하지는 못한다.
복잡한 타입을 확장하고 싶다면 타입과 &
을 사용해야 한다.
튜플도 마찬가지로 타입을 사용하면 간단하게 사용할 수 있는데, 인터페이스로 튜플과 비슷하게 구현하게 되면 튜플에서 사용할 수 있는 concat
같은 메서드를 사용할 수 없다.
type TState = {
name: string;
capital: string;
}
interface IState {
name: string;
capital: string;
}
class StateT implements TState {
name: string = '';
capital: string = '';
}
class StateI implements IState {
name: string = '';
capital: string = '';
}
interface IState {
name: string;
capital: string;
}
interface IState {
population: number;
}
const wyoming: IState = {
name: 'Wyoming',
capital: 'Cheyenne',
population: 500_000
}; // OK
이렇게 속성확장하는 것을 선언 병합(declaration merging)이라고 한다.
그럼 타입과 인터페이스 중, 뭘로 개발해야하냐!
라고 물어본다면, 처음 개발할 때 써왔거나 혹은 기존 코드베이스에 따라 일관되게 사용하는게 좋다.
API의 타입을 선언해야 한다면 인터페이스가 더 좋을 수 있다. API가 변경되는 일이 있다면 사용자가 인터페이스를 통해 새로운 필드를 병합할 수 있어 유용하다.
반대로, 프로젝트 내부적으로 사용하는 타입에 대해서는 이런 병합이 발생하면 안되므로 타입을 사용하는게 좋다.
같은 코드를 반복하지 말라는 DRY(don't repeat yourself)원칙으로 타입 반복을 줄여보았다는 내용이 이 챕터의 대부분 내용이다.
Pick
// AS-IS
interface State {
userId: string;
pageTitle: string;
recentFiles: string[];
pageContents: string;
}
interface TopNavState {
userId: string;
pageTitle: string;
recentFiles: string[];
}
// TO-BE 1
type TopNavState = {
userId: State['userId'];
pageTitle: State['pageTitle'];
recentFiles: State['recentFiles'];
};
// TO-BE 2 - 매핑된 타입을 사용하기
type TopNavState = {
[k in 'userId' | 'pageTitle' | 'recentFiles']: State[k]
};
매핑된 타입은 배열의 필드를 루프 도는 것과 같은 방식으로 타입을 지정하며, Pick
과 같다. 아래 코드를 보면, 부분집합으로 선택한 것과 완전히 동일하진 않은 것을 알 수있다. 이 점 유의해서 구분해서 사용하면 좋을 것 같다.
interface SaveAction {
type: 'save';
// ...
}
interface LoadAction {
type: 'load';
// ...
}
type Action = SaveAction | LoadAction;
type ActionType = Action['type']; // Type is "save" | "load"
type ActionRec = Pick<Action, 'type'>; // {type: "save" | "load"}
keyof
, Partial
interface Options {
width: number;
height: number;
color: string;
label: string;
}
type OptionsUpdate = {[k in keyof Options]?: Options[k]};
type OptionsKeys = keyof Options;
// Type is "width" | "height" | "color" | "label"
매핑된 타입([k in keyof Options]
)은 순회하며 Options
내 k
과 해당하는 속성이 있는지 찾는다. ?
는 각 속성을 선택적으로 만들고, Partial
과 같은 방식을 적용해서 OptionsUpdate
의 타입을 만들었다.
typeof
const INIT_OPTIONS = {
width: 640,
height: 480,
color: '#00FF00',
label: 'VGA',
};
type Options = typeof INIT_OPTIONS;
값을 타입으로 사용함으로써 타입의 반복을 줄였는데, 이 때 선언의 순서에 주의해야 한다. 타입 정의를 먼저 하고 값이 그 타입에 할당 가능하다고 선언하는 것이 좋다. 그렇게 해야 타입이 더 명확해지고, 예상하기 어려운 타입 변동을 방지할 수 있다.
extends
제너릭 타입은 타입을 위한 함수와 같다. 제너릭 타입에서 매개변수를 제한할 수 있는 방법은 extends
를 사용하는 것이다.
interface Name {
first: string;
last: string;
}
type DancingDuo<T extends Name> = [T, T];
const couple1: DancingDuo<Name> = [
{first: 'Fred', last: 'Astaire'},
{first: 'Ginger', last: 'Rogers'}
]; // OK
const couple2: DancingDuo<{first: string}> = [
// ~~~~~~~~~~~~~~~
// Property 'last' is missing in type
// '{ first: string; }' but required in type 'Name'
{first: 'Sonny'},
{first: 'Cher'}
];
type Rocket = {[property: string]: string};
const rocket: Rocket = {
name: 'Falcon 9',
variant: 'v1.0',
thrust: '4,940 kN',
}; // OK
[property: string]: string
이 인덱스 시그니처다.
다만, 이렇게 타입 체크를 하게 되면 단점이 4가지가 된다.
name
대신 Name
을 쓰더라도 허용된다.{}
타입도 허용하게 된다.이런 단점 때문에 타입이 명확하게 필요한 곳 보다, 어떤 데이터가 들어올 지 예상할 수 없는 동적 데이터를 표현할 때 사용한다.
예를 들면, CSV파일처럼 행과 열에 이름이 있고, 데이터 행을 열 이름과 값으로 매핑하는 객체로 나타내고 싶은 경우다. 하지만 이 때 열 이름을 알고 있는 특정한 상황이 있다면, 미리 선언해 둔 타입으로 단언문을 사용한다.
Map
연관 배열(associative array)의 경우, 객체에 인덱스 시그니처를 사용하는 대신 Map
타입을 사용하는 것을 고려할 수있다.
이 타입의 구체적인 예시는 ITEM 58에서 볼 수 있다.
Record
어떤 타입에 가능한 필드가 제한되어 있는 경우라면, 인덱스 시그니처로 모델링하지 말아야 한다.
// AS-IS
interface Row1 { [column: string]: number } // Too broad
interface Row2 { a: number; b?: number; c?: number; d?: number } // Better
type Row3 =
| { a: number; }
| { a: number; b: number; }
| { a: number; b: number; c: number; }
| { a: number; b: number; c: number; d: number };
// TO-BE 1
type Vec3D = Record<'x' | 'y' | 'z', number>;
// Type Vec3D = {
// x: number;
// y: number;
// z: number;
// }
// TO-BE 2
type ABC = {[k in 'a' | 'b' | 'c']: k extends 'b' ? string : number};
// Type ABC = {
// a: number;
// b: string;
// c: number;
// }
number
인덱스 시그니처보다는 Array
, 튜플, ArrayLike
를 사용하기자바스크립트에서 객체란 키/값 쌍의 모음이고, 키는 보통 문자열이며 그 값은 어떤 것이든 될 수 있다.
배열은 객체고, 숫자 인덱스를 사용하는 것이 당연하다.
하지만 인덱스들은 문자열로 변환되어 사용되고 그 문자열 키를 사용해도 역시 배열의 요소에 접근할 수 있다.
const x = [1, 2, 3]
console.log(x[0]) // 1
console.log(x['1']) // 2
console.log(Object.keys(x)) // ['0', '1', '2']
Object.keys(x)
로 키를 나열하면, 키가 문자열로 나온다. 타입 스크립트는 이런 혼란을 바로 잡기 위해 숫자 키를 허용하고, 문자열 키와 다른 것으로 인식한다.
인덱스 시그니처가 number
로 표현되어 있다면 입력한 값이 number
여야 한다는 것을 의미하지만, 실제 런타입에 사용되는 키는 string
타입이다. 게다가 number
로 지정한다면 이 숫자 속성이 어떤 특별한 의미를 지닌다는 오해를 불러 일으킬 수 있다.
따라서 인덱스 시그니처에 number
를 사용하기보다 Array
나 튜플, 또는 ArrayLike
타입을 사용하는 것이 좋다.
배열의 타입이 불확실 하다면, (대부분의 브라우저와 자바스크립트 엔진에서)
for-in
루프는for-of
또는 C 스타일for
루프에 비해 몇 배나 느리다.
readonly
사용하기function arraySum(arr: number[]) {
let sum = 0, num;
while ((num = arr.pop()) !== undefined) {
sum += num;
}
return sum;
}
function arraySum(arr: readonly number[]) {
let sum = 0, num;
while ((num = arr.pop()) !== undefined) {
// ~~~ 'pop' does not exist on type 'readonly number[]'
sum += num;
}
return sum;
}
readonly number[]
와 number[]
의 차이
length
를 읽을 수 있지만, 바꿀수는 없다(배열을 변경함).pop
을 비롯한 다른 메서드를 호출할 수 없다.number[]
는 readonly number[]
보다 기능이 많기 때문에, readonly number[]
의 서브타입이 된다.매개변수를 readonly
로 선언하면?
readonly
배열을 매개변수로 넣을 수 있다.만약 함수가 매개변수를 변경하지 않고도 제어가 가능하다면 readonly
로 선언하면 된다. 그런데 어떤 함수를 readonly
로 만들면, 그 함수를 호출하는 다른 함수도 모두 readonly
로 만들어야 한다.
➡ 인터페이스를 명확히 하고 타입 안정성을 높일 수 있기 때문에 꼭 단점이라고 볼 순 없다.
단, 다른 라이브러리에 있는 함수를 호출하는 경우라면 타입 단언문을 사용하는 것이 좋다.
readonly
는 얕게(shallow)동작한다.
매핑된 타입을 사용해서 관련된 값과 타입을 동기화하고, 인터페이스에 새로운 속성을 추가할 때 선택을 강제하도록 매핑된 타입을 고려해야 한다.
실패에 닫힌(fail close) 방법은 오류 발생 시에 적극적으로 대처하는 방향을 말한다. 말 그대로 방어적, 보수적(conservative) 접근법이다. 반대로 실패에 열린 방법은 오류 발생 시에 소극적으로 대처하는 방향이다. 보안과 관련된 곳이라면 실패에 닫힌 방법을, 기능에 무리가 없고 사용성이 중요한 곳이라면 실패에 열린 방법을 써야 할 것이다.