Effective Typescript 2장 아이템 13 ~ 15

·

9 min read

아이템 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 = {};

인덱스 시그니처 의미

  1. 키의 이름 - 키의 위치만 표시하는 용도, 타입 체커에서 사용하지 않음

  2. 키의 타입 : string, number, symbol 조합이어야 하지만 보통 string 사용

  3. 값의 타입 : 어떤 것이든 될 수 있다.

인덱스 시그니처의 단점

  1. string이라면 잘못된 키도 허용된다. name / Name 모두 가능

  2. 특정 키가 필수로 필요하지 않기 때문에 빈 객체도 유효한 Rocket 타입이다.

  3. 키마다 다른 값 타입을 가질 수 없다. only one type

  4. 키 타입의 범위가 넓기 때문에 자동완성 기능을 사용할 수 없다.

  5. 인덱스 시그니처는 부정확하다.

인터페이스 사용시 장점

  • 타입 체크 허용

  • 타입스크립트에서 제공하는 언어 서비스 모두 사용 : 자동 완성, 정의로 이동, 이름 바꾸기 등

// 키마다 다른 타입을 주고 싶은 경우 인터페이스 사용
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가지

  1. Recode 사용하기
type Vec3D = Record<"x" | "y" | "z", number>;
  1. 매핑된 타입 사용하는 방법 : 키마다 별도의 타입을 사용이 가능하다.
type Vec3D2 = { [k in "x" | "y" | "z"]: number };

type Vec3DWithDifferentValueType = {
  [k in "x" | "y" | "z"]: k extends "x" ? string : number;
};

요약

  • 런타임 때까지 객체의 속성을 알 수 없는 경우에만 인덱스 시그니처 사용하고 가능하다면 인덱스 시그니처보다 정확한 타입 사용하기

  • 안전한 접근을 위해 인덱스 시그니처의 값 타입에 undefined 추가하는 것 고려해보기