ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 이펙티브 타입스크립트- 2장 타입시스템 (3)
    TypeScript 2022. 4. 1. 01:13

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

    같은 코드를 반복하지 말라는 DRY(don't repeat yourself) 원칙은 개발자라면 어느 분야든 들어본 말이다. 하지만 

    타입에 대해서는 쉽게 간과하기 쉽다.

     

    아래 예시를 보자 

    interface Person {
        firstName: string;
        lastName: string;
    }
    
    // X
    interface PersonWithBirthDate {
        firstName: string;
        lastName: string;
        birth: Date;
    }
    
    // O
    interface PersonWithBirthDate extends Person {
        birth: Date;
    }
    
    // O
    type PersonWithBirthDate = Person & { birth: Date };

     

    위의 두 interface는 birth 필드를 제외하고 똑같기 때문에 코드 중복이 발생한다. 

    이 때, 선택적 필드인 middleName을 Person에 추가한다고 가정한다면 Person과 PersonWithBirthDate은 다른 타입이 되버려 PersonWithBirthDate이라는 이름이 무색해진다. 

     

    이럴 때는 아래의 두 예시처럼 인터페이스 확장 혹은 인터섹션 타입을 사용해 코드 중복을 줄일 수 있다.

     

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

    // 수정 전
    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));
    }
    
    // 수정 후
    interface Point2D {
        x: number;
        y: number;
    }
    function distance(a: Point2D, b: Point2D) { /* ... */ }

    종종 중복된 타입이 문법에 의해 가려지기도 하다. 

    아래의 예를 보자. 몇몇 함수가 같은 타입 시그니처를 공유하고 있다.

    그러면 해당 함수 시그니처를 명명된 타입으로 분리해낼 수 있다. 

    // 분리 전
    function get(url: string, opts: Options): Promise<Response> { /* ... */ }
    function post(url: string, opts: Options): Promise<Response> { /* ... */ }
    
    // 분리 후
    type HTTPFunction = (url: string, opts: Options) => Promise<Response>;
    const get: HTTPFunction = (url, opts) => { /* ... */ };
    const post: HTTPFunction = (url, opts) => { /* ... */ };

     

    전체 애플리케이션의 상태를 표현하는 State타입과 부분만 표현하는 TopNavState가 있는 아래의 예시를 보자.

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

    위 경우 State을 TopNavState을 확장하여 구성하기 보다 State의 부분집합으로 TopNavState을 정의하는 것이 바람직하다.

    그래야 전체 앱의 상태를 하나의 인터페이스로 유지할 수 있기 때문이다. 

    이때 State을 인덱싱하여 속성의 타입에서 중복을 제거할 수도 있다. 

     

    type TopNavState = {
        userId: State['userId'];
        pageTitle: State['pageTitle'];
        recentFiles: State['recentFiles'];
    }

     

    State내의 userId 등 필드의 타입이 바뀌면 TopNavState에도 반영된다. 하지마 여전히 반복되는 코드가 존재한다. 

    이때 '매핑된 타입' 을 사용하면 더 나아진다. 

     

    type TopNavState = {
        [k in 'userId' | 'pageTitle' | 'recentFiles']: State[k]
    };

    이 방식은 배열의 필드를 루프 도는 것과 같은 방식으로 표준 라이브러리에서도 일반적으로 찾을 수 있으며 'Pick' 이라고 한다. 

    type Pick<T, K> = { [k in K]: T[k] }; // Pick의 정의
    type TopNavState = Pick<State, 'userId' | 'pageTitle' | 'recentFiles'>; // Pick의 사용 예시

     

    태그된 유니온에서도 다른 형태의 중복이 발생할 수 있다. 

    단순히 태그를 붙이기 위해 타입을 사용한다면 다음과 같은 중복이 발생한다. 

    interface SaveAction {
        type: 'save';
        // ...
    }
    interface LoadAction {
        type: 'load';
        // ...
    }
    type Action = SaveAction | LoadAction;
    type ActionType = 'save' | 'load'; // 위의 선언과 반복되는 코드

    이는 아래와 같이 Action 유니온을 인덱싱함으로써 해결할 수 있다. 

    type ActionType = Action['type']; // 'save' | 'action

    만약 추후에 Action유니온에 타입을 더 추가하면 ActionType은 자동적으로 그 타입을 포함한다. 

    하지만 ActionType은 Pick을 사용하여 얻게 되는 type 속성을 가지는 인터페이스와는 다르다. 

    type ActionRec = Pick<Action, 'type'>; // { type: 'save' | 'load' }

     

    만약 생성하고 난 다음에 업데이트가 되는 클래스를 정의한다면, update 메서드 매개변수의 타입은 생성자와 동일한 매개변수이면서 

    타입 대부분이 선택적 필드가 된다. 

    이때 매핑된 타입과 keyof를 사용하면 코드를 개선할 수 있다. 

    interface Options {
        width: number;
        height: number;
        color: string;
        label: string;
    }
    
    // X
    interface OptionsUpdate {
        width?: number;
        height?: number;
        color?: string;
        label?: string;
    }
    
    class UIWidget {
        constructor(init: Options) { /* ... */ }
        update(options: OptionsUpdate) { /* ... */ }
    }
    
    // O
    type OptionsUpdate = {[k in keyof Options]?: Options[k]};
    
    class UIWidget {
        constructor(init: Options) { /* ... */ }
        update(options: OptionsUpdate) { /* ... */ }
    }

    keyof는 타입을 받아서 속성 타입의 유니온을 반환한다

     

    type OptionKeys = keyof Options;
    //타입이 "width" | "height" | "color" | "label"
    
    - 매핑된 타입([k in keyof Options])은 순회하면 Options내 k값에 해당하는 속성이 있는지 찾는다.
    - ?는 각 속성을 선택적으로 만든다.

    이 패턴은 표준 라이브러이에 Partial이라는 이름으로 포함되어 있다. 

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

     

    값의 형태에 해당하는 타입을 정의하고 싶을 때 

    이런 경우 typeof를 사용하면 된다. (자바스크립트 typeof와는 다른 타입스크립트 연산자이다)

    const INIT_OPTIONS = {
        width: 640,
        height: 480,
        color: '#00FF00',
        label: 'VGA',
    };
    
    // X
    // 그냥 interface 선언 
    interface Options {
        width: number;
        height: number;
        color: string;
        label: string;
    }
    
    // O
    // typeof 연산자 사용 
    type Options = typeof INIT_OPTIONS;

     

    만약 함수나 메서드의 반환 값에 타입을 만들고 싶을 수도 있다. 

    이때 표준 라이브러리에 정의된 ReturnType 제네릭을 사용해주면 된다. 

    function getUserInfo(userId: string) {
        // ...
        return {
            userId,
            name,
            age,
            height,
            weight,
            favoriteColor,
        };
    }
    // 추론된 return type은 { userId: string, name: string, age: number, ... }
    
    type UserInfo = ReturnType<typeof getUserInfo>;

     

    제네릭 타입은 타입을 위한 함수와 같다.

    제네릭 타입에서도 매개변수로 들어오는 타입을 제한하는 방법이 존재하는데 바로 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' }
    ]; // 정상
    
    const couple2: DancingDuo<{ first: string }> = [
    // 오류.
    // 'Name' 타입에 필요한 'last' 속성이 '{ first: string }' 타입에 없습니다.
        { first: 'Sonny' },
        { first: 'Cher' }
    ];
    // { first: string } 은 Name을 확장하지 않기 때문에 발생하는 오류.

    앞전에 Pick의 정의로 나왔던 코드를 실제로 실행해보면 아마 오류가 발생할 건데,

    이는 K가 T와 아예 무관하고 범위도 너무 넓기 때문이다.

    K는 T의 인덱스로 사용될 수 있는 'string | number | symbol'로 범위가 좁혀져야 한다.

    그래서 K는 실제로는 T의 key의 부분집합, 즉 keyof T가 되어야 한다.

    따라서 extends를 통해 다음과 같이 고쳐줄 수 있다.

    // 수정 전
    type Pick<T, K> = {
        [k in K]: T[k]
        // 오류. 'K' 타입은 'string | number | symbol' 타입에 할당할 수 없습니다.
    }
    
    // 수정 후
    type Pick<T, K extends keyof T> = {
        [k in K]: T[k]
    }; // 정상
    
    // 만약 Pick에 잘못된 키를 넣으면 오류가 발생한다.
    type FirstLast = Pick<Name, 'first' | 'last'>; // 정상
    type FirstMiddle = Pick<Name, 'first' | 'middle'>; // 오류.
    // '"middle"' 형식은 '"first"' | '"last"' 형식에 할당할 수 없습니다.

    요약 

    • DRY(don't repeat yourself) 원칙을 타입에도 최대한 적용해야 한다.
    • 타입에 이름을 붙여서 반복을 피해야 한다. extends를 사용해서 인터페이스 필드의 반복을 피해라
    • keyof, typeof, 인덱싱 등 타입들 간의 매핑을 위해 타입스크립트가 제공하는 도구들을 학습해라
    • 제너릭 타입은 타입을 위한 함수와 같다. 타입을 반복하는 대신 제너릭 타입을 사용하여 타입들 간에 매핑을 하는 것이 좋다.(제너릭 타입을 제한하려면 extends를 사용하면 된다)
    • Pick, Partial, ReturnType 같은 표준 라이브러리에 정의된 제너릭 타입에 익숙해져야 된다. 

     

    댓글

Designed by Tistory.