Effective Typescript (Day 4)

d_fe·2022년 11월 29일
post-thumbnail

item14. 타입 연산과 제너릭 사용하기

소프트웨어 개발자라면 어느 분야에서든 DRY(don't repeat yourself) 원칙을 지키려 한다.
하지만 반복된 코드를 열심히 제거하며 이 원칙을 지켜왔던 사람들도 타입에 대해서는 간과하기 쉽다.

타입 중복은 코드 중복만큼 많은 문제를 발생시킨다. 예를 들어, 아래 예시에서 Person 에 선택적 필드인 middleName을 추가하면 Person과 PersonWithBirthDate는 다른 타입이 된다.

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

interface PersonWithBirthDate {
   firstName: string;
   lastName: string;
   birth: Date;
}
// 여기선 아래의 interface가 Person을 확장하게 만들어 반복을 제거할 수 있다.
interface PersonWithBirthDate extends Person{
	birth: Date;
}

➡️ 이미 존재하는 타입을 확장하는 경우에, 일반적이지는 않으나 인터섹션 연산자(&)를 쓸 수도 있다.

type PersonWithBirthDate = Person & {birth : Date};

! 이런 기법은 유니온 타입(확장할 수 없는)에 속성을 추가하려고 할 때 특히 유용하다.

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

function distacne(a:{x:number,y:number}, b: {x:number, y:number}) {/* ... */}

// 개선
interface Point2D {
    x: number;
    y: number;
}

function distance (a: Point2D, b:Point2D) {/* ... */}
// 상수를 사용해서 반복을 줄이는 기법을 동일하게 타입 시스템에 적용한 것

➡️ 인덱싱과 매핑된 타입

//  애플리케이션의 전체 상태를 표현하는 State
interface State {
    userId: string;
    pageTitle: string;
    recentFiles: string[];
    pageContents: string;
}

// 일부분만 표현하는 TopNaveState
interface TopNavState {
    userId: string;
    pageTitles: string;
    recentFiles: string[];
}

위에선 TopNaveState을 확장하여 State를 구성하기보다, State의 부분 집합으로 TopNaveState를 정의 하는 것이 바람직하다. 이 방법이 전체 앱의 상태를 하나의 인터페이스로 유지할 수 있게 해준다.

  1. State를 인덱싱하여 속성의 타입에서 중복을 제거
type TopNaveState = {
	userId: State['userId'];
  	pageTitle: State['pageTitles'];
  	recentFiles: State['recentFiles'];
}
  1. 여전히 반복되는 코드가 존재한다. 이 때 '매핑된 타입'을 사용하면 좀 더 나아진다.
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' | 'load'

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

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

➡️ 매핑된 타입과 keyof

interface Options {
    width: number;
    height: number;
    color: string;
    label: string;
}

interface OptionsUpdate {
    width ?: number;
    heigth ?: number;
    color ?: string;
    label ?: string;
}

class UIWidget {
    constructor(init: Options) {/* ... */}
    update(options: OptionsUpdate) {/* ... */}
}

매핑된 타입과 keyof를 사용하면 Options로부터 OptionsUpdate를 만들 수 있다.

type OptionsUpdate = {[k in keyof Options]?: Options[k]}
  • 매핑된 타입([k in keyof Options]) 은 순회하며 Options 내 k 값에 해당하는 속성이 있는지 찾는다.
  • (?) 는 각 속성을 선택적으로 만든다.

❗이 패턴 역시 일반적이며 표준 라이브러리에 Partial이라는 이름으로 포함되어 있다.

type OptionsUpdate = Partial<Options>
// 결과는 위와 같다.

➡️ typeof

값의 형태에 해당하는 타입을 정의하고 싶을 땐 typeof을 사용한다.

const INIT_Options = {
    width: 1,
    height: 2,
    color: '#111111',
    label : 'VGA'
}

type Options = typeof INIT_Options;
// {
//     width: number;
//     heigth: number;
//     color: string;
//     label: string;
// }

JS의 런타임 연산자 typeof을 사용한 것처럼 보이지만, 실제로는 TS 단계에서 연산되며 훨씬 더 정확하게 타입을 표현한다. (JS는 값의 관점, TS는 타입의 관점)

❗but, 값으로부터 타입을 만들어 낼 떄는 선언의 순서에 주의해야 한다. 타입 정의를 먼저하고 값이 그 타입에 할당 가능하다고 선언하는 것이 좋다. 그래야 타입이 더 명확해지고, 예상하기 어려운 타입 변동을 방지할 수 있다.


➡️ 함수나 메서드의 반환 값에 명명된 타입을 만들고 싶을 때

// 예시
function getUserInfo(userId: string) {
    // ...
    return {
        userId,
        name,
        age,
        height,
        weight,
        favoriteColor,
    };
    // 추론된 반환 타입은 {userId: string; name: string; age:nubmer, ...}
}

조건부 타입을 사용하면 된다.
앞의 Pick , Partial 과 같이 이러한 일반적 패턴의 제너릭 타입은 정의되어 있다.
이 경우 ReturnType 제너릭을 사용한다.

type UserInfo = ReturnType<typeof getUserInfo>

ReturnType은 함수의 '값'인 getUserInfo가 아니라 함수의 '타입'인 typeof getUserInfo에 적용되었다. typeof과 마찬가지로 이런 기법은 적용 대상이 값인지 타입인지 정확히 알고, 구분해서 처리해야 한다.


➡️ 제너릭 타입은 타입을 위한 함수와 같다.
타입에 대한 DRY 원칙의 핵심이 제너릭이지만 간과한 것이 있다.
함수에서 매개변수로 매핑할 수 있는 값을 제한하기 위해 타입 시스템을 사용하는 것처럼 제너릭 타입에서 매개변수를 제한할 수 있는 방법이 필요하다.

extends를 이용하면 제너릭 매개변수가 특정 타입을 확장한다고 선언할 수 있다.
(위 예시는 {first: string} 이 Name을 확장하지 않기 때문에 오류가 발생.)

++ 타입스크립트에서는 선언부에 항상 제너릭 매개변수를 작성하도록 되어 있다.
위 예시에서 그냥 DancingDuo로 쓰면 동작하지 않는다.
TS가 제너릭 매개변수의 타입을 추론하게 하기 위해, 함수를 작성할 때는 신중하게 타입을 고려하자.

extends를 이용해 Pick의 정의를 완성할 수 있다.

// 앞서 정의한 Pick
type Pick<T,K> = {[k in K]:T[k]}

K는 T타입과 무관하고 범위가 매우 넓기 떄문에 범위를 좀 더 좁힐 필요가 있다.

type Pick<T, K extends keyof T> = {
    [k in K]: T[k];
};
//lib.es5.d.ts 에는 이미 이렇게 정의되어 있음.

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


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

JS의 장점 중 하나는 객체를 생성하는 문법이 간단하다는 것이다.
JS 객체는 문자열 키를 타입의 값에 관계없이 매핑하는데, TS에서는 타입에 '인덱스 시그니처'를 명시하여 유연하게 매핑을 표현할 수 있다.

const rocket = {
    name: 'Falcon',
    variant: 'Block',
    thrust: '1,333'
}

//인덱스 시그니처로 type을 명시
type Rocket = {[property: string]: string};
const rocket :Rocket = {
    name: 'Falcon',
    variant: 'Block',
    thrust: '1,333'
}

// [property: string]: string
// 인덱스 시그니처
  • 키의 이름: 키의 위치만 표시하는 용도. 타입 체커에서는 사용하지 않기 때문에 무시할 수 있는 참고 정보
  • 키의 타입: string이나 number 또는 symbol의 조합이어야 하지만, 보통은 string을 사용
  • 값의 타입: 어떤 것이든 가능

❗이렇게 타입 체크를 하면 단점이 있다.

  1. 잘못된 키를 포함해 모든 키를 허용한다.
    (name 대신 Name으로 작성해도 유효한 타입이 된다.)
  2. 특정 키가 필요하지 않다.
    ({} 도 유효한 Rocket 타입)
  3. 키마다 다른 타입을 가질 수 없다.
    (thrust는 string이 아니라 number여야 할 수도 있다.)
  4. TS 언어 서비스는 다음과 같은 경우에 도움이 되지 못한다.
    (name: 을 입력할 때, 키는 무엇이든 가능하기 때문에 자동 완성 기능이 동작하지 않음)

결론 > 위에서 인덱스 시그니처는 부정확하므로 더 나은 방법을 찾아야 한다.

예를 들어, 위의 Rocket 예시는 interface로 표현해야 한다.

interface Rocket {
    name: string;
    variant: string;
    thrust: number;
}

++ 그럼 인덱스 시그니처는 어디에 사용?
!! 인덱스 시그니처는 동적 데이터를 표현할 때 사용한다.
(런타임 때까지 객체의 속성을 알 수 없을 경우에만)
일반적인 상황에서 해당 데이터 타입이 무엇인지 미리 알 방법이 없을 때를 말한다.
알고 있는 특정 상황이 온다면 미리 선언해둔 타입으로 단언문을 이용한다.
but, 실제로 일치한다는 보장이 없으므로 이 부분이 걱정된다면 값 타입에 undefined을 추가 한다.


어떤 타입에 가능한 필드가 제한되어 있는 경우라면 인덱스 시그니처로 모델링해선 안된다.

어떤 데이터에 A,B,C,D 같은 키가 있지만, 얼마나 많이 있는지 모른다면 선택적 필드 또는 유니온 타입으로 모델링하자.

interface Row {[column: string]: number} // 너무 광범위하다.
interface Row1 {a:number, b?:number, c?: number, d?: number}; // 최선

type Row3 = {a:number} | {a:number; b: number} | {a:number; b:number} //...
// 가장 정확하지만 사용하기 번거롭다.

string 타입이 너무 광범위해서 인덱스 시그니처를 사용하는 데 문제가 있다면, 두 가지 다른 대안을 생각해볼 수 있다.

  1. Record 를 사용하는 방법
    Record 는 키 타입에 유연성을 제공하는 제너릭 타입이다.
    특히, string의 부분 집합을 사용할 수 있다.
type Vec3D = Record<'x'|'y'|'z',number>
// Type Vec3D = {
//     x: number;
//     y: number;
//     z: number;
// }
  1. 매핑된 타입을 사용하는 방법
    매핑된 타입은 키마다 별도의 타입을 사용하게 해준다.
type Vec3D = {[k in 'x' | 'y' | 'z']:number};
// Type Vec3D = {
//     x: number;
//     y: number;
//     z: number;
// }

type ABC = {[k in 'a' | 'b' | 'c']:k extends 'b' ? string : number};
// Type Vec3D = {
//     a: number;
//     b: string;
//     c: number;
// }

☑️런타임 때까지 객체의 속성을 알 수 없을 경우에만 인덱스 시그니처를 사용하자.
☑️안전한 접근을 위해 인덱스 시그니처의 값 타임에 undefined를 추가하는 것을 고혀하자.
☑️가능하다면 interface, Record, 매핑된 타입 같은 인덱스 시그니처보다 정확한 타입을 사용하자.

profile
오늘보다 내일 더 성장하는 프론트엔드 개발자가 되기 위해

0개의 댓글