본문 바로가기

HTML ⁄ CSS ⁄ JS

[이펙티브 타입스크립트] 2장 타입 시스템(2)

이펙티브 타입스크립트 정주행 스터디 2주차 후반부
(분량이 많아 전반/후반으로 나누어 작성함)

 

 

13 인터페이스와 타입별칭의 차이점 알기

타입스크립트에서 타입의 이름을 주는 방법은 인터페이스(interface) 정의와 타입 별칭(type alias) 2가지가 있다. 대부분의 경우 둘 중 어느 것을 사용해도 되지만, 둘의 차이를 분명하게 알고 같은 상황에서는 일관적인 방법으로 name type을 작성해야 한다.

우선 공통적으로 가능한 것은 일반적인 속성 정의, 인덱스 시그니처 사용, 함수 타입 정의, 제너릭 사용 등이 있다.

타입을 확장하는 경우 인터페이스와 타입의 차이가 있다. 인터페이스는 extends 키워드로 확장하는 반면, 타입별칭은 인터섹션 연산자(&)로 확장한다. 단, 인터페이스는 유니온 같은 복잡한 타입을 확장할 수는 없다. 유니온 같은 복잡한 타입을 확장하고 싶다면 타입과 인터섹션 연산자(&)를 사용해야 한다.

튜플을 표현하는 경우, 인터페이스로도 표현이 가능하지만, 더 간결하게 표현할 수 있는 타입별칭으로 작성하는 것이 낫다. 

인터페이스에만 있는 기능도 있다. 속성을 확장할 수 있는 선언 병합(declaration merging)이다.  만약 타입 선언 파일에서 선언 병합을 지원하고 싶다면 반드시 인터페이스를 사용해야한다. 추가적인 보강(augment)가 없는 경우에는 타입을 사용해도 좋다.

interface State {
  name: string;
  capital: string;
} 
interface State {
  population: number;
} 

const songpa: State {
  name: 'Songpa';
  capital: 'Seoul'
  population: 670_000
}

 

 

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

타입 정의에서도 DRY원칙 (Don't Repeat Yourself) 을 적용할 수 있다.

반복을 줄이는 가장 간단한 방법은 타입에 이름을 붙이는 것이다.

interface Person {
  firstName: string;
  lastName: string;
}

// 개선 전
interface PersonWithBirthDate1 {
  firstName: string;
  lastName: string;
  birth: Date;
}

// 개선 후
interface PersonWithBirthDate2 extends Person {
  birth: Date;
}

type PersonWithBirthDate3 = Person & { birth: Date };

 

// 개선 전
function get(url: string, opts: Optoins): Promise<Response> { /* ... */ }
function post(url: string, opts: Optoins): Promise<Response> { /* ... */ }

// 개선 후
type HTTPFunc = (url: string, opts: Options) => Promise<Response>
function get: HTTPFunc = (url, opts) => { /* ... */ }
function post: HTTPFunc = (url, opts) => { /* ... */ }

 

객체의 각 프로퍼티에 할당해둔 값에 따라 그 구조에 맞게 타입을 만들고 싶으면 typeof 를 사용하면 된다. 아래의 typeof는 자바스크립트의 typeof가 아니라 타입스크립트의 typeof 연산자이다.

const INITIAL_OPTIONS = {
  width: 640,
  height: 480,
  color: '#00FF00',
}

type Options = typeof INITIAL_OPTIONS;

 

태그된 유니온(tagged union)에서도 ActionType1 처럼 중복이 발생할 수 있다. 이럴 때는 ActionType2와 같이 앞서 정의한 Action 유니온을 인덱싱하면 반복을 없앨 수 있다.

interface SaveAction {
  type: 'save';
  // ...
}
interface LoadAction {
  type: 'save';
  // ...
}

type Action = SaveAction | LoadAction;

type ActionType1 = 'save' | 'load'; // 타입은 'save' | 'load'
type ActionType2 = Action['type']; // 타입은 'save' | 'load'

 

 

만약 기존에 존재하던 타입에서 부분집합을 만들고 싶은 경우에는 다음과 같이 작성할 수 있다.

interface NavState {
  userId: string;
  pageTitle: string;
  recentFiles: string[];
  pageContents: string;
}

interface TopNavState {
  [k in 'userId' | 'pageTitle' | 'recentFiles']: NavState[k];
}

같은 의도를 구현하기 위해 interface가 아닌 type을 사용할 수도 있다. 이를 Pick 패턴이라고 한다.

// Pick 패턴
type Pick<T, K> = { [k in K]: T[k] }

// 적용
type TopNavState = Pick<NavState, 'userId' | 'pageTitle' | 'recentFiles'>;
// { userId: string; pageTitle: string; recentFiles: string[]; }

 

이렇게 꺽새 < > 로 타입을 받는 것을 제너릭(Generic)이라고 한다. 제너릭은 '일반적인'이라는 뜻으로, 이것 하나로 여러 다른 데이터 타입을 가질 수 있도록 하는 방식이다. 그런 측면에서 '타입을 위한 함수'와 같다고 할 수 있다. 즉, 함수에서 매개변수를 받아 리턴값을 반환하듯, 제너릭에서 타입을 받아 결과 타입을 반환할 수 있다.

제너릭으로 타입들 간의 매핑을 하면 타입 반복, 타입 중복을 줄일 수 있게된다. 제너릭 타입에서는 extends 키워드를 사용해서 매개변수를 제한할 수 있다.

 

 

keyof 를 사용하면 프로퍼티들의 유니온을 쉽게 만들 수 있다. 원래있던 인터페이스에서 각 프로퍼티를 optional 한 새로운 타입을 만들고 싶다면 이를 활용하면 된다. 이는 Pick 패턴과 같이 표준 라이브러리에 Partial 패턴으로 정의되어 있다.

interface Options {
  width: number;
  height: number;
  color: string;
}
type OptionsKeys = keyof Options; // "width" | "height" | "color"
type OptionsOptional = { [k in keyof Options]?: Options[k] };

// Partial 패턴
class UIWidget {
  constructor(options: Options>) {
    /* ... */
  }
  update(options: Partial<Options>) {
    /* ... */
  }
}

 

함수의 반환 값을 이용해서 타입을 만들고 싶은 경우, 역시 제너릭으로 정의되어있는 ReturnType 패턴을 사용할 수 있다. 이때 제너릭 안에 들어가는 값이 함수 자체 getUserInfo가 아니라 typeof getUserInfo 인 점을 기억해두자.

function getUserInfo(userId: string) {
  return { userId, name, age }
}

type UserInfo = ReturnType<typeof getUserInfo>;
// 타입이 { userId: string; name: string; age: number; }

 

15 인덱스 시그니처 지양하기

아래에서 [property: string]: string 부분을 '인덱스 시그니처(index signature)'라고 한다.

type Rocket = { [property: string]: string };

 

위 타입의 문제는 잘못된 키가 들어가도 타입 체커가 잡아낼 수 없고, 아무키가 들어가지 않아도, 즉 빈 객체( { } ) 여도 유효하다고 판단한다. 또 각 프로퍼티마다 다른 타입을 가지게 할 수도 없고, 자동완성 어시스트를 사용할수도 없다.

이런 이유로 인덱스 시그니처는 굳이 사용하지 않는 것이 좋고, 런타임 전에는 객체의 프로퍼티를 알 수 없을 경우에만 사용하는 것이 좋다.

인덱스 시그니처 대신 제너릭 타입인 Record 패턴을 사용할 수 있다.

type Vec3D Record<'x' | 'y' | 'z', number>;

Type Vec3D_eqaul = {
  x: number;
  y: number;
  z: number;
}

 

또는 매핑된 타입(mapped types)을 사용하는 방법이 있다.

type Vec3D = { [k in 'a' | 'b' | 'c]: k entends 'b' ? string : number };
type Vec3D_eqaul = {
  a: number;
  b: string;
  c: nubmer;
}

 

 

16 Array, Tuple, ArrayLike 사용하기

자바스크립트에서 객체 프로퍼티의 키값은 무조건 문자열이다. 자바스크립트의 배열 또한 객체이기 때문에 배열의 인덱스도 문자열이다. 그러나 타입스크립트에서는 숫자 키를 허용하고 문자열 키와 다른 것으로 간주한다.

인덱스 시그니처에 number를 사용하기 보다는 Array, Tuple, ArrayLike 타입을 사용하는 것이 좋다.

 

17 변경 오류 방지에 readonly 사용하기

읽기전용 접근제어자 readonly 를 붙이면 의도치않게 변경되어 발생하는 오류를 방지할 수 있고, 인터페이스를 더욱 명확하게 해준다. 

예를들어 readonly number[] 타입은 number[] 타입과 달리, 배열을 변경할 수 없다. 이 때 배열의 변경이란 배열의 요소의 값을 업데이트하거나, 배열을 변경하는 pop 등의 메서드를 호출하는 것 등을 모두 포함한다. 

그러나 배열 자체를 가공할 수 없을 뿐 const number = []; 로 선언한 것과 달리 새로 할당하는 것은 가능하다. 원본 배열을 변경하지 않는 slice, concat 등의 메서드도 사용가능하다.

원래 타입에 readonly가 붙으면 그 타입의 서브타입이 된다. 이는 원래 타입이 더 많은 것들을 가지고 있기 때문에 자연스럽게 이해된다. 따라서 readonly가 붙은 타입에 그냥 타입을 할당할 수는 있어도, 그냥 타입에 readonly가 붙은 타입를 할당할 수는 없다.

자바스크립트를 사용하면서도 암묵적으로 함수의 매개변수는 변경하지 않도록 함수를 작성하지만, 타입스크립트를 통해 이를 더 명시적으로 표현해주는 것이 더 좋다.

객체에는 Readonly 제너릭을 사용할 수 있다.

interface Outer {
  inner: { x: number; }
}

type T = Readonly<Outer>;
o.inner = { x: 1 }; // 에러, 읽기전용 속성이기 때문에 inner에 할당할 수 없습니다.
o.inner.x = 1; // 정상

type TD = DeepReadonly<Outer>;
o.inner = { x: 1 }; // 에러
o.inner.x = 1; // 에러

 

인덱스 시그니처에도 readonly를 사용하면, 프로퍼티가 변경하는 것을 방지할 수 있다. 단 아래 예제에서 obj 자체를 덮어씌워버리는 것은 가능하다.

let obj: { readonly [k: string]: number } = {};
// Readonly<{[k: string]: number}>

obj.hi = 45;
// 인덱스 시그니처는 읽기만 허용됩니다.

 

 

 

18 매핑된 타입으로 값을 동기화하기

보안이 중요할 경우 보수적이고 실패에 닫힌(fail close) 접근법을, 사용성이 중요한 곳이라면 실패에 열린(fail open) 접근법을 사용해야 한다. 상황에 따라 어떤 접근법을 사용할지 선택해야 한다.

매핑된 타입(Mapped Types)를 사용해서 값의 모양 또는 구조를 그대로 타입으로 옮길 수 있다. 따라서 한 객체가 다른 객체와 완전히 동일한 프로퍼티들을 가지게 할 때 이상적인 방법이다. 

interface Props {
  xs: number[];
  ys: number[];
  color: string;
  onClick: (x: number, y: number, index: number) => void;
}

const IS_UPDATE_REQUIRED: { [k in keyof Props]: boolean } = {
  xs: true,
  ys: true,
  color: true,
  onClick: false,
};

function shouldUpdate(oldProps: Props, newProps: Props) {
  let k: keyof Props;

  for (k in oldProps) {
    if (IS_UPDATE_REQUIRED[k] && oldProps[k] !== newProps[k]) {
      return true;
    }
  }
  return false;
}

위와 같이 IS_UPDATE_REQUIRED 와 같이 객체의 프로퍼티가 boolean 값을 갖게 할 수도 있고, 아래와 같이 배열을 사용할 수도 있다.

const PROPS_TO_UPDATE: (keyof Props)[] = [ 'xs', 'ys', 'color' ];