TypeScript, Capitalize에 대하여

Capitalize의 사용 예시 코드로 다음이 있습니다.

type Getters<Type> = {
  [Property in keyof Type as `get${Capitalize<string & Property>}`]: () => Type[Property]
};

interface Person {
  name: string;
  age: number;
  location: string;
}

type LazyPerson = Getters<Person>;

위의 코드는 LazyPerson에 대한 타입을 다음처럼 자동으로 구성해 줍니다.

type LazyPerson = {
  getName: () => string;
  getAge: () => number;
  getLocation: () => string;
}

위의 코드에서 설명할 내용이 산더미 같지만 일단 집중하고자 하는 것은 Capitalize 입니다. Capitalize는 TypeScript로 작성되지 않은 Native 코드입니다. Capitalize가 Native로 작성되었지만 TypeScript로도 작성될 수 있는데요. TypeScript로 작성된 Capitalize은 다음과 같습니다.

type UpperMap = {
  a: "A";
  b: "B";
  c: "C";
  d: "D";
  e: "E";
  f: "F";
  g: "G";
  h: "H";
  i: "I";
  j: "J";
  k: "K";
  l: "L";
  m: "M";
  n: "N";
  o: "O";
  p: "P";
  q: "Q";
  r: "R";
  s: "S";
  t: "T";
  u: "U";
  v: "V";
  w: "W";
  x: "X";
  y: "Y";
  z: "Z";
};

type MyCapitalize<S extends string> =
  S extends `${infer First}${infer Rest}`
  ? `${First extends keyof UpperMap ? UpperMap[First] : First}${Rest}`
  : S;

type A = MyCapitalize<"hello">;      // "Hello"
type B = MyCapitalize<"typescript">; // "Typescript"
type C = MyCapitalize<"안녕?">;     // "안녕"
type D = MyCapitalize<"Hello">;      // "Hello"

TypeScript, 조건부 타입에 대한 분산 처리

아래의 코드는 주어진 타입에 대한 배열 타입을 만들어주는 코드입니다. 주어진 타입이 유니언일 경우 각 타입이 하나씩 분산 처리되어 유니온화 됩니다.

type ToArray<Type> = Type extends any ? Type[] : never;
type StrArrOrNumArr = ToArray<string | number>;
// type StrArrOrNumArr = string[] | number[]

만약 주어진 타입을 분산 처리하지 않고 한번에 처리하기를 원한다는 다음처럼 작성하면 됩니다.

type ToArray<Type> = [Type] extends [any] ? Type[] : never;
type StrArrOrNumArr = ToArray<string | number>;
// type StrArrOrNumArr = (string | number)[]

TypeScript, 배열 타입에서 조건부 타입과 추론

배열에서 조건부 타입과 추론에 대해 의미있는 코드가 있어 정리해 봅니다. 배열을 구성하는 항목의 타입을 얻기 위해 다음과 같은 코드가 가능합니다.

type Flatten<T> = T extends any[] ? T[number] : T;

type Str = Flatten;
type Num = Flatten;

T[number]가 배열을 구성하는 항목의 타입을 나타낸다는 점이 포인트입니다. 위의 코드는 조건부 타입에서 추론을 사용하면 다음처럼 동일하게 작성될 수 있습니다.

type Flatten<Type> = Type extends Array<infer Item> ? Item : Type;

TypeScript, 오버로드 시그니쳐 vs 조건부 타입

다음과 같은 타입이 있다고 할때 …

interface IdLabel {
  id: number;
}

interface NameLabel {
  name: string;
}

숫자형 id 인자나 문자열 name 인자중 어떤 인자를 받느냐에 따라 구현이 달라지는 함수를 정의하기 위해 오버로드 시그니쳐 방식으로 다음처럼 함수를 정의할 수 있습니다.

function createLabel(id: number): IdLabel;
function createLabel(name: string): NameLabel;
function createLabel(nameOrId: string | number): IdLabel | NameLabel;
function createLabel(nameOrId: string | number): IdLabel | NameLabel {
  throw "unimplemented";
}

코드가 중복적이고 장황합니다. 이를 조건부 타입 방식으로 완전히 동일하게 작동하는 함수를 정의하면 다음과 같습니다.

type NameOrId<T extends number | string> = T extends number
  ? IdLabel
  : NameLabel;

function createLabel2<T extends number | string>(idOrName: T): NameOrId<T> {
  throw "unimplemented";
}

TypeScript Utility Type의 기반, 조건부 타입(Conditional Type)

Conditional Type에 대한 구구절절한 문법을 설명하는 대신 Utility Type 중 ReturnType의 구현 코드를 살펴보고자 합니다.

ReturnType은 입력된 함수 타입의 반환 타입을 얻는 TypeScript에 이미 정의된 API입니다. 그런데 이 ReturnType은 네이티브가 아닌 TypeScript 언어로 작성된 녀석입니다. 이때 조건부 타입(Conditional Type) 문법이 사용되는데요. ReturnType을 비롯한 대다수의 Utility Type 들은 조건부 타입 문법을 통해 구현되어 있습니다.

ReturnType의 구현 코드는 다음과 같습니다. 실제 구현된 코드와 다를 수 있으나 결과는 동일합니다.

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

위의 코드를 사용한 코드를 예시로 보면..

type f = (a: number) => void;
type rt = MyReturnType<f>

f는 void 타입을 반환하는 함수 타입이므로 rt는 void 타입이 됩니다. 기대했던 기능입니다.

이제 MyReturnType에 대한 구현 코드를 살펴보면 …

MyReturnType의 T 인자는 <T extends (...args: any) => any>에 의해 모든 함수의 형태를 포함할 수 있는 함수 타입으로 제약 받고 있습니다. 그리고 T extends (...args: any) => infer R에 의해 다시 T가 정해진 제약 조건, 즉 조건부 타입인지를 검사하고 있고, 조건부 타입 검사를 수행할때 infer R에 의해 R에 대한 타입을 추론(infer)하게 되고 결과적으로 조건에 맞을 경우 추론된 타입인 R이 반환되고 조건에 맞지 않을 경우 any가 반환되도록 하는 것입니다.