Effective Typescript 2장 아이템 13 ~ 15
아이템 13. 타입과 인터페이스의 차이점 알기
인터페이스와 타입의 공통점
1. 인덱스 시그니처 사용 가능
type TDict = { [key: string]: string };
interface IDict {
[key: string]: string;
}
2. 함수 타입 정의 가능
// 타입 별칭
type TFn = (x: number) => string;
// 인터페이스
interface IFn {
(x: number): string;
}
const toStrT: TFn = (x) => " " + x; // correct
const toStrI: IFn = (x) => " " + x; // correct
3. 프로퍼티를 가진 함수 타입 사용 가능
- 자바스크립트에서 함수는 호출 가능한 객체라는 점 기억하자!
type TFnWithProperties = {
(x: number): number;
prop: string;
};
interface IFnWithProperties {
(x: number): number;
prop: string;
}
4. 제네릭 사용 가능
type TPair<T> = {
first: T;
second: T;
};
interface IPair<T> {
first: T;
second: T;
}
const pairExample: TPair<number> = { first: 1, second: 2 };
5. 타입 확장 가능
인터페이스는 타입을 확장할 수 있다(
extends
)유니온 처럼 복잡한 타입을 확장하고 싶다면
타입과 &
를 사용해야 한다.
interface IStateWithPop extends TState {
poplulation: number;
}
type TStateWithPop = IState & { population: number };
6. 클래스 구현 가능
- 클래스 구현할 때는 타입(
TState
)과 인터페이스(IState
) 모두 사용 가능하다.
class StateT implements TState {
name: string = "";
capital: string = "";
}
class StateI implements IState {
name: string = "";
capital: string = "";
}
인터페이스와 타입의 차이점
1. 타입에만 유니온 타입 존재
type AorB = "a" | "b";
2. 인터페이스는 타입 확장할 수 있지만 유니온을 할 수 없다.
유니온 타입을 확장 하고 싶다면 하나의 변수명으로 매핑하는 인터페이스를 사용한다.
type Input = {};
type Output = {};
interface VariableMap {
[name: string]: Input | Output;
}
3. 유니온 타입에는 속성을 붙인 타입을 만들 수 있다.
type 키워드
는 유니온이 될 수도 있고, 매핑된 타입 or 조건부 타입에 활용되기 때문에 더 많이 사용된다.interface에서 tuple 구현 가능하지만 튜플에서 사용할 수 있는 concat 같은 메서드 사용 불가하다.
type NameVariable = (Input | Output) & { name: string };
// 타입으로 튜플과 배열 타입 표현하는 방법
type Pair = [number, number];
type StringList = string[];
type NmaedNums = [string, ...number[]];
// 인터페이스로 튜플 표현하는 방법
interface Tuple {
0: number;
1: number;
length: 2;
}
const t: Tuple = [10, 20];
4. 인터페이스에는 보강(argument)이 가능하다. = 선언 병합
보강이 가능하기 때문에 각 인터페이스가 병합되어서
ES2015
에 추가된Array의 find
메서드도 하나의Array 타입
을 통해 사용할 수 있다.기존 타입에 추가적인 보강이 없는 경우에만 병합 사용한다.
interface IState {
name: string;
capital: string;
}
interface IState {
age: number;
}
const mallang: IState = {
name: "Mallang",
capital: "Seoul",
age: 5,
};
요약
복잡한 타입이라면 타입 사용하기.
타입과 인터페이스가 모두 가능한 간단한 객체 타입이라면 일관성과 보강의 관점에서 타입을 고려해보기.
현재 코드베이스를 고려해서 일관된 스타일 확립하기.
API의 경우는 인터페이스가 좋다. API 변경시 새로운 필드를 병합할 수 있어 유용하다.
프로젝트 내부적으로 사용되는 타입에 선언 병합이 발생하는 것은 잘못된 설계이다
.
아이템 14. 타입 연산과 제네릭 사용으로 반복 줄이기
타입 반복(중복)을 줄이는 방법
타입에 이름을 붙이고 재사용하라.
코드 타입을 공유하게 되면,중간에 속성이 추가되어도 타입 관계가 유지될 수 있다.
1. 타입을 확장하라
interface Person {
firstName: string;
lastName: string;
}
// bad case
interface PersonWithBirthDate {
firstName: string;
lastName: string;
birth: Date;
}
// good case
interface BetterPersonWithBirthDate extends Person {
birth: Date;
}
2. 타입을 분리해서 이름을 붙여라.
// bad case
function distance(a: { x: number; y: number }, b: { x: number; y: number }) {
return Math.sqrt(Math.pow(a.x - b.x, 2) + Math.pow(a.y - b.y, 2));
}
// good case
interface Point2D {
x: number;
y: number;
}
function distance(a: Point2D, b: Point2D) {
return Math.sqrt(Math.pow(a.x - b.x, 2) + Math.pow(a.y - b.y, 2));
}
3. 중복 사용되는 함수 타입 정의는 함수 시그니처로 분리하라.
// bad case
function get(url: string, opt: Options): Promise<Response> {}
function post(url: string, opt: Options): Promise<Response> {}
// good case
type HTTPFunction = (url: string, opt: Options) => Promise<Response>;
const get: HTTPFunction = (url, opt) => {};
const post: HTTPFunction = (url, opt) => {};
타입 중복 개선 관련 예시
개선할 때 고려할 부분
앱 전체 상태 타입과 부분을 표현하는 타입이 있는 경우는 인덱싱 사용해서 전체 앱의 상태를 하나의 인터페이스로 유지하도록 한다.
목적에 따라 함께 그룹핑되어야 하는 값이라면, 하나의 타입 형태를 유지하는 게 좋다.
interface State {
userId: string;
pageTitle: string;
recentFiles: string[];
pageContents: string;
}
// bad case : 타입 중복 발생
interface TopNavState {
userId: string;
pageTitle: string;
recentFiles: string[];
}
// a litte better case : `매핑된 타입` 사용해서 State[key] 반복 코드 제거하기
type TopNavState = {
userId: State["userId"];
pageTitle: State["pageTitle"];
recentFiles: State["recentFiles"];
};
// good case
type GoodTopNavState = {
[k in "userId" | "pageTitle" | "recentFiles"]: State[k];
};
매핑된 타입 활용 예시
매핑된 타입은 배열의 필드를 루프 도는 것과 같다.
Pick
은 제네릭 타입으로, 함수를 호출하는 것과 마찬가지이다.T
,K
두 가지 타입을 받아서 결과 타입 반환한다.
// Pick 제네릭 함수 정의를 보면 키의 범위를 좁히기 위해서 부분 집합의 개념으로 extends를 사용한다.
type Pick<T, K extends keyof T> = { [k in K]: T[k] };
유니온 인덱싱 사용해서 반복없이 타입 정의하기
interface SaveAction {
type: "save";
}
interface LoadAction {
type: "load";
}
type Action = SaveAction | LoadAction;
// 타입 반복 발생
type ActionType = "save" | "load";
// 유니온을 인덱싱하면 반복 없이 정의 가능 - 유니온 타입 리턴
type ActionType2 = Action["type"];
// Pick 메서드 사용해서 인터페이스 리턴
type ActionType3 = Pick<Action, "type">;
생성하고 난 다음에 업데이트되는 클래스 정의
interface Options {
width: number;
height: number;
color: string;
label: string;
}
// 타입 중복 발생
interface OptionsUpdate {
width: number;
height: number;
color: string;
label: string;
}
class UIWidget {
constructor(init: Options) {}
update(options: OptionsUpdate) {}
}
// keyof Options는 Options 속성타입의 유니온 반환과 동일하고, 이는 Partial 제네릭 함수와 동일하다
type OptionsUpdate2 = { [k in keyof Options]?: Options[k] };
type OptionsUpdate3 = Partial<Options>;
값에 형태에 해당하는 타입을 정의하고 싶을 때
typeof
,ReturnType
활용하기
// 타입스크립트 typeof 사용
// 값으로부터 타입 만들어낼 때 주의사항 - 값과 타입의 선언 순서가 중요하다.
const INIT_OPTIONS = {
width: 200,
height: 200,
color: "#000000",
label: "Color",
};
type InitOptionsType = typeof INIT_OPTIONS;
// 함수나 메서드의 반환 값에 명명된 타입 만들고 싶은 경우
function getUserInfo(userId: string) {
return {
userId,
name,
age,
height,
};
}
// 타입스크립트 ReturnType 사용
// 함수의 타입의 리턴 타입 가져오기
type UserInfo = ReturnType<typeof getUserInfo>;
제네릭 타입에서 매개변수를 좁히는 방법
- 제네릭 매개변수가 특정 타입을 확장한다고 선언하라.
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" },
];
제네릭 매게변수 관련 오류 개선
interface Name {
first: string;
last: string;
}
// 선언부에 작성된 제네릭 매개변수에는 last 프로퍼티가 존재하는데,
// couple2의 제네릭 매개변수에는 last 프로퍼티가 존재하지 않아서 타입 에러 발생
const couple2: DancingDuo<{ first: string }> = [
{ first: "Fred" },
{ first: "Ginger" },
];
// 개선하려면 Partial을 사용해서 프로퍼티를 optional로 변경해줌
type CustomDancingDuo<T extends Partial<Name>> = [T, T];
const couple4: CustomDancingDuo<Pick<Name, "first">> = [
{ first: "Fred" },
{ first: "Ginger" },
];
요약
타입 공간에서도 반복적인 코드는 좋지 않다.
DRY 원칙
을 타입에도 최대한 적용하자.인터페이스 필드의 반복은
extends
를 사용해서 피하자.타입들 간의 매핑을 위해 TS가 제공하는 도구인
keyof
,typeof
,인덱스
,매핑된 타입
을 활용하자.Pick
,Partial
,ReturnType
같은 제네릭 타입을 사용해서 타입을 매핑하자. 제네릭 타입을 제한하려면 부분 집합의 개념으로extends
사용하기.타입스크립트 목적은 유효한 프로그램은 통과시키고 무효한 프로그램에는 오류를 발생시키는 것이기 떄문에
Pick
에 잘못된 키를 넣으면 오류가 발생해야 한다.
아이템 15. 동적 데이터에 인덱스 시그니처 사용하기
인덱스 시그니처 예시
type Rocket = { [property: string]: string };
const rocket: Rocket = {
name: "Falcon 9",
variant: "Block 5",
thrust: "8,403 kN",
};
const text: Rocket = {};
인덱스 시그니처 의미
키의 이름 - 키의 위치만 표시하는 용도, 타입 체커에서 사용하지 않음
키의 타입 :
string
,number
,symbol
조합이어야 하지만 보통string
사용값의 타입 : 어떤 것이든 될 수 있다.
인덱스 시그니처의 단점
string
이라면 잘못된 키도 허용된다. name / Name 모두 가능특정 키가 필수로 필요하지 않기 때문에 빈 객체도 유효한 Rocket 타입이다.
키마다 다른 값 타입을 가질 수 없다. only one type
키 타입의 범위가 넓기 때문에 자동완성 기능을 사용할 수 없다.
인덱스 시그니처는 부정확하다.
인터페이스 사용시 장점
타입 체크 허용
타입스크립트에서 제공하는 언어 서비스 모두 사용 : 자동 완성, 정의로 이동, 이름 바꾸기 등
// 키마다 다른 타입을 주고 싶은 경우 인터페이스 사용
interface DifferentRocket {
name: string;
variant: string;
thrust_kN: number;
}
const customRocket: DifferentRocket = {
name: "Falcon 9",
variant: "Block 5",
thrust_kN: 8403,
};
인덱스 시그니처를 사용해야 하는 경우
동적 데이터를 표현할 때
실무에서 사용헤 본 경험 : 데이터의 키가 랜덤 스트링으로 들어오는 경우
const parseCSV = (input: string): { [column: string]: string }[] => {
const lines = input.split("\n");
const [header, ...rows] = lines;
const headerColumns = header.split(",");
return rows.map((rowStr) => {
// 열 이름 미리 알 수 없을 때는 인덱스 시그니처 사용
const row: { [column: string]: string } = {};
rowStr.split(",").forEach((cell, i) => {
row[headerColumns[i]] = cell;
});
return row;
});
};
interface ProductionRow {
productId: string;
name: string;
price: string;
}
declare let csvData: string;
// 열 이름을 알고 있는 상황이라면 미리 선언해둔 타입으로 단언문 사용
// 단언문 사용해서 `[column: string]: string` 타입을 `ProductionRow` 타입으로 좁힘
const products = parseCSV(csvData) as unknown as ProductionRow;
그런데 선언해둔 열이 런타임에 실제로 일치한다는 보장이 없다
- 걱정된다면 기존 함수를 한번 더 감싸는 함수(
safeParseCSV
) 만들고 값 타입에undefined
추가 하기
// undefined 타입이 추가된 케이스
const safeParseCSV = (
input: string
): { [columnName: string]: string | undefined }[] => {
return parseCSV(input);
};
undefined
를 리턴하기 떄문에 모든 열에서undefined
여부를 체크해야 한다. 값 체크 여부가 추가되기 때문에undefined
를 타입에 추가할지는 상황에 맞게 판단하는 게 좋다.연관 배열의 경우, 객체에 인덱스 시그니처 사용하는 대신
Map 타입
사용할 수 있다. (아이템 58에서 다룰 예정)
어떤 타입에 가능한 필드가 제한되어 있는 경우라면, 인덱스 시그니처로 모델링하지 말기
- 데이터 A,B,C,D 같은 키가 얼마나 많이 있는지 모른다면 유니온 타입이나, 선택적 필드로 모델링하기
// 너무 넓은 타입
interface Row1 {
[column: string]: number;
}
// good case - 선택적 필드
interface Row2 {
a: number;
b?: number;
c?: number;
d?: number;
}
// 유니온 타입을 모델링, 가장 정확하지만 번거로움
type Row3 =
| { a: number }
| { a: number; b?: number }
| { a: number; b?: number; c?: number }
| { a: number; b?: number; c?: number; d?: number };
인덱스 시그니처를 좀 더 narrow 하게 사용하는 방법 2가지
- Recode 사용하기
type Vec3D = Record<"x" | "y" | "z", number>;
- 매핑된 타입 사용하는 방법 : 키마다 별도의 타입을 사용이 가능하다.
type Vec3D2 = { [k in "x" | "y" | "z"]: number };
type Vec3DWithDifferentValueType = {
[k in "x" | "y" | "z"]: k extends "x" ? string : number;
};
요약
런타임 때까지 객체의 속성을 알 수 없는 경우에만
인덱스 시그니처
사용하고 가능하다면 인덱스 시그니처보다 정확한 타입 사용하기안전한 접근을 위해 인덱스 시그니처의 값 타입에
undefined
추가하는 것 고려해보기