이펙티브 타입스크립트 - 2장 (2)

Sohee Park·2023년 2월 16일
0
post-thumbnail

ITEM 13 타입과 인터페이스의 차이점 알기

솔직히 개인적으로 제일 흥미롭게 읽은 아이템 😎

타입스크립트에는 타입을 정의할 수 있는 방법이 두가지 있다.

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 = '';
}

타입과 인터페이스의 차이점

인터페이스는 보강(augment)이 가능하다

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가 변경되는 일이 있다면 사용자가 인터페이스를 통해 새로운 필드를 병합할 수 있어 유용하다.
반대로, 프로젝트 내부적으로 사용하는 타입에 대해서는 이런 병합이 발생하면 안되므로 타입을 사용하는게 좋다.

ITEM 14 타입 연산과 제너릭 사용으로 반복 줄이기

같은 코드를 반복하지 말라는 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])은 순회하며 Optionsk과 해당하는 속성이 있는지 찾는다. ?는 각 속성을 선택적으로 만들고, 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'}
];

ITEM 15 동적데이터에 인덱스 시그니처 사용하기

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;
// }

ITEM 16 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루프에 비해 몇 배나 느리다.

ITEM 17 변경 관련된 오류 방지를 위해 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)동작한다.

ITEM 18 매핑된 타입을 사용하여 값을 동기화하기

매핑된 타입을 사용해서 관련된 값과 타입을 동기화하고, 인터페이스에 새로운 속성을 추가할 때 선택을 강제하도록 매핑된 타입을 고려해야 한다.

실패에 닫힌(fail close) 방법은 오류 발생 시에 적극적으로 대처하는 방향을 말한다. 말 그대로 방어적, 보수적(conservative) 접근법이다. 반대로 실패에 열린 방법은 오류 발생 시에 소극적으로 대처하는 방향이다. 보안과 관련된 곳이라면 실패에 닫힌 방법을, 기능에 무리가 없고 사용성이 중요한 곳이라면 실패에 열린 방법을 써야 할 것이다.

profile
고양이 두마리를 모시고 있는 프론트엔드 코더(?)

0개의 댓글