Published on

유틸리티 타입을 직접 만들어보자

Authors
  • avatar
    Name
    윤영서
    Twitter

유틸리티 타입이란

유틸리티 타입이란 타입을 변환하거나 조작하는데 사용할 수 있는 타입입니다. 자바스크립트에서 내장 메소드를 제공하는 것 과 같이 타입스크립트에서도 타입 변환을 용이하게 하기 위해서 유틸리티 타입을 제공합니다. 타입스크립트 유틸리티 타입에 대해 이해하기 위해 직접 유틸리티 타입을 만들어 보겠습니다.

Partial<T>

Partial은 제네릭 타입 T를 받아 T의 모든 속성을 선택적으로 만드는 유틸리티 타입입니다.

type MyPartial<T> = {
  [P in keyof T]?: T[P];
};

T의 각 키를 순회하는 P를 사용하여, T의 각 속성(T[P])을 선택적으로 만들면 됩니다.

Required<T>

Required는 제네릭 타입 T를 받아 T의 모든 속성을 필수로 만드는 유틸리티 타입입니다.

type MyRequired<T> = {
  [P in keyof T]-?: T[P];
};

-?는 선택적 속성을 필수로 만들어주는 문법입니다.

Readonly<T>

Readonly는 제네릭 타입 T를 받아 T의 모든 속성을 읽기 전용으로 만드는 유틸리티 타입입니다.

type MyReadonly<T> = {
  readonly [P in keyof T]: T[P];
};

readonly는 속성을 읽기 전용으로 만들어주는 문법입니다. 이를 타입으로 가지는 객체 속성의 값을 바꾸려 한다면 아래와 같은 에러가 발생합니다.

Record<K, T>

Record는 제네릭 타입 KT를 받아 K의 모든 속성을 T 타입으로 만드는 유틸리티 타입입니다. 보통 객체의 키-값 쌍의 타입을 정의할 때 사용합니다.

type MyRecord<K extends keyof any, T> = {
  [P in K]: T;
};

여기서 keyof any는 가능한 모든 타입의 키를 의미합니다. 타입스크립트 4.8이후 버전부터는 이를 PropertyKey로 대체할 수 있습니다. PropertyKeykeyof any와 동일하게 string | number | symbol을 의미하지만 조금 더 명확한 의미를 가지고 있습니다.

그렇지만 실제로 내부적으로는 keyof any를 사용하고 있는데, 왜 PropertyKey로 변경하지 않을까요?

이에 대한 논의가 있었는데, 이미 유틸리티 타입을 제공하기도 하고 PropertyKey를 사용하기엔 하위 호환성 보장이 어려우므로 개발자들이 커스텀하여 사용하길 권장하고 있습니다.

많은 논의 끝에, 우리는 라이브러리가 자체적으로 'Omit'(그리고 'Exclude'와 다른 관련 타입들)의 '더 엄격한' 버전을 제공하는 것이 더 바람직하다고 생각합니다. 이러한 타입들의 Strict 버전은 상위 제약 조건을 강제하는 면에서 매우 전염성이 강하며, 사람들이 이들 사이에서 현명하게 선택할 것인지는 불분명합니다. 이는 신중하게 관리되지 않으면 실제로 마찰을 일으킬 수 있습니다. 이는 'Omit'이 라이브러리에 포함된 지금 더욱 그렇습니다 - 만약 우리가 'OmitStrict'를 선택한다면 (이에 대해서도 여러 정의 중에서 선택해야 할 것입니다), 사용자 공간에 좋은 이름이 많이 남아있지 않을 것입니다. 이러한 모든 헬퍼 타입을 작성하는 방법은 여러 가지가 있으며, 개발자들이 자신의 '엄격함'의 정의에 맞는 것을 선택하도록 하는 것이 개인의 자유를 최대화하는 선택이 될 것입니다. 여기와 트위터에서 우리의 'Omit' 정의 선택(제 의견으로는 완전히 방어 가능한)에 대한 반응을 보면, 왜 우리가 'OmitStrict'를 선택하는 것에 대해 그다지 흥분하지 않는지 알 수 있습니다. 그것은 개발자의 30-70%를 화나게 할 것입니다. 왜냐하면 우리가 그들이 선호하는 정의를 선택하지 않기 때문입니다. 라이브러리에 무언가를 추가하는 것은 그 정의가 완전히 모호하지 않을 때만 좋은 아이디어인 것 같습니다. 그리고 'OmitStrict'는 그 기준에 맞지 않습니다. 돌이켜 보면, 'Omit'도 그렇지 않았고, 아마도 우리는 그것을 그냥 빼놓았어야 했을 것입니다. 개발자 여러분, TypeScript가 그 이름을 밟아버리지 않을 것이라는 것을 알고 여러분이 선택한 'OmitStrict'의 정의와 함께 행복하고 안전하게 나아가세요. 😬

Pick<T, K>

Pick은 제네릭 타입 TK를 받아 T의 속성 중 K에 해당하는 속성만을 선택하는 유틸리티 타입입니다.

type MyPick<T, K extends keyof T> = {
  [P in K]: T[P];
};

당연하게도 K는 T의 키를 상속받아야 하므로 K extends keyof T로 제약을 걸어줍니다.

Exclude<T, U>

Exclude는 제네릭 타입 TU를 받아 T에서 U에 할당할 수 있는 타입을 제외한 타입을 구성합니다.

type MyExclude<T, U> = T extends U ? never : T;

여기서 보면 타입을 만들때도 삼항연산자를 사용할 수 있음을 알 수 있습니다. TU에 할당할 수 있는 타입이라면 never를 반환하고, 그렇지 않다면 T를 반환합니다. 위 타입에서 일어나는 과정은 실제로 T가 U를 순회하는 것이 아니지만 조건부 타입과 분배 법칙이 작동하는 방식 때문에 순회하는 것처럼 보입니다.

MyExclude<'a' | 'b' | 'c', 'a' | 'b'>

// 분배 법칙에 따라 다음과 같이 평가됩니다:
('a' extends 'a' | 'b' ? never : 'a') |
('b' extends 'a' | 'b' ? never : 'b') |
('c' extends 'a' | 'b' ? never : 'c')

Omit<T, K>

Omit은 T에서 모든 프로퍼티를 선택한 다음 K를 제거한 타입을 구성합니다. Pick은 프로퍼티를 선택하는 반면, Omit은 프로퍼티를 제외합니다.

type MyOmit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;

Omit은 앞서 언급한 PickExclude를 조합하여 만들 수 있습니다. T에서 K를 제외한 타입을 Pick으로 선택하면 됩니다. OmitExclude는 모두 제외한다는 특성에서 비슷하게 생각될 수 있지만, Omit은 객체에서, Exclude는 유니온 타입에서 사용됩니다.

Extract<T, U>

Extract은 제네릭 타입 TU를 받아 T에서 U에 할당할 수 있는 타입을 추출한 타입을 구성합니다. Exclude와 반대되는 동작을 합니다. Exclude가 조건에 맞지 않는 타입을 제거한다면, Extract는 조건에 맞는 타입만 남깁니다.

type MyExtract<T, U> = T extends U ? T : never;

NonNullable<T>

NonNullable은 제네릭 타입 T를 받아 T에서 nullundefined를 제외한 타입을 구성합니다.

type MyNonNullable<T> = T extends null | undefined ? never : T;

Parameters<T>

Parameters은 제네릭 타입 T를 받아 함수 타입 T의 매개변수 타입을 튜플로 만드는 유틸리티 타입입니다.

type MyParameters<T extends (...args: any) => any> = T extends (...args: infer P) => any
  ? P
  : never;

(...args: any) => any는 함수의 타입을 의미합니다. 그리고 infer 키워드를 사용하여 함수의 매개변수 타입을 추출할 수 있습니다. infer은 조건부 타입에서 사용됩니다. 위 코드는 'T가 어떤 매개변수든 받고 어떤 값이든 반환하는 함수 타입인 경우, 그 매개변수들의 타입을 P로 추론하라'는 의미입니다.

ConstructorParameters<T>

ConstructorParameters은 제네릭 타입 T를 받아 생성자 함수 타입 T의 매개변수 타입을 튜플로 만드는 유틸리티 타입입니다.

type MyConstructorParameters<T extends new (...args: any) => any> = T extends new (
  ...args: infer P
) => any
  ? P
  : never;

앞서 (...args: any) => any는 함수의 타입을 의미한다고 했습니다. 생성자 함수 타입은 new 키워드를 사용하여 표현합니다.

ReturnType<T>

ReturnType은 제네릭 타입 T를 받아 함수 타입 T의 반환 타입을 구성하는 유틸리티 타입입니다.

type MyReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any;

반환타입이 R로 추론되면 R을 반환하고, 그렇지 않다면 any를 반환합니다.

InstanceType<T>

InstanceType은 제네릭 타입 T를 받아 생성자 함수 타입 T의 인스턴스 타입을 구성하는 유틸리티 타입입니다.

type MyInstanceType<T extends abstract new (...args: any) => any> = T extends abstract new (
  ...args: any
) => infer R
  ? R
  : any;

InstanceTypeConstructorParameters과 비슷한데 abstract 키워드가 추가되었습니다. abstract 키워드는 추상 클래스를 의미합니다. 즉, T가 추상 클래스를 포함한 모든 생성자 함수 타입을 받을 수 있음을 의미합니다.

ThisParameterType<T>

ThisParameterType는 제네릭 타입 T를 받아 함수 타입 Tthis 매개변수 타입을 구성하는 유틸리티 타입입니다. 명시적 this 인자가 없으면 unknown을 반환합니다.

type MyThisParameterType<T> = T extends (this: infer U, ...args: any[]) => any ? U : unknown;

OmitThisParameter<T>

OmitThisParameter는 제네릭 타입 T를 받아 함수 타입 Tthis 매개변수를 제외한 함수 타입을 구성하는 유틸리티 타입입니다. ThisParameterType과 다르게 이름에 Type은 없습니다.

type MyOmitThisParameter<T> = T extends (...args: infer A) => infer R ? (...args: A) => R : T;

우선 함수의 매개변수 타입들을 A로 추론합니다. 그리고 반환 타입을 R로 추론합니다. 그리고 추론된 매개변수 타입들을 사용하여 함수 타입을 만듭니다. 함수 타입이면 this 매개변수를 제외한 새 함수 타입을 반환하고, 아니면 원래 타입을 그대로 반환합니다.

ThisType<T>

ThisType은 제네릭 타입 T를 받아 this 타입을 T로 만드는 유틸리티 타입입니다. 특이한 점은 이 타입은 유틸리티는 변형된 타입을 반환하지 않는다는 점입니다. 이 타입은 문맥적 this 타입에 표시하는 역할을 합니다. 이 유틸리티를 사용하기 위해서는 --noImplicitThis 플래그를 사용해야 합니다.

ThisType은 단지 lib.d.ts에 선언된 빈 인터페이스입니다.

interface ThisType<T> {}

선언 파일에서 단순히 위와 같이 정의되어 있습니다.

Awaited<T>

Awaited는 제네릭 타입 T를 받아 Promise 타입 T의 반환 타입을 구성하는 유틸리티 타입입니다.

type Awaited<T> = T extends null | undefined
  ? T
  : T extends object & { then(onfulfilled: infer F): any }
  ? F extends (value: infer V, ...args: any) => any
    ? Awaited<V>
    : never
  : T;

T가 null | undefined인 경우 T를 그대로 반환하고, 만약 T가 객체이고 then 메소드를 가지고 있다면 then 메소드의 첫 번째 매개변수(onfulfilled 콜백)의 타입을 F로 추론합니다. 이를 통해 Promise와 유사한 thenable객체를 모두 처리합니다. F(onfulfilled 콜백)가 함수인 경우, 그 함수의 첫 번째 매개변수 타입을 V로 추론합니다. 그리고 V를 Awaited로 재귀적으로 호출하여 반환합니다.

Intrinsic String Manipulation Types

템플릿 문자열 리터럴에서의 문자열 조작을 돕기 위해 구현된 타입 집합입니다.

// Uppercase<StringType>
type Uppercase<T extends string> = intrinsic;

// Lowercase<StringType>
type Lowercase<T extends string> = intrinsic;

// Capitalize<StringType>
type Capitalize<T extends string> = intrinsic;

// Uncapitalize<StringType>
type Uncapitalize<T extends string> = intrinsic;

내부를 확인하면 intrinsic이라고만 되어 있습니다. 이는 컴파일러에 의해 특별히 처리된다는 것을 의미합니다.

✏️ 출처