dimohy
디모이 블로그

디모이 블로그

TypeScript 배우기 - 11. 조건부 타입

dimohy's photo
dimohy
·Aug 10, 2022·

4 min read

조건부 타입은 입력 타입과 출력 타입간의 관계를 설명하는데 도움이 됩니다. 다음은 핸드북에 나온 예제 입니다.

interface Animal {
  live(): void;
}
interface Dog extends Animal {
  woof(): void;
}

// Dog는 Animal 타입으로 할당할 수 있으므로 참. 그러므로 Example1는 number가 됨
type Example1 = Dog extends Animal ? number : string;

// RegExp는 Animal 타입으로 할당할 수 없으므로 거짓. 그러므로 Example2는 string이 됨
type Example2 = RegExp extends Animal ? number : string;

조건부 타입은 다음과 같이 표현됩니다.

SomeType extends OtherType ? TrueType : FalseType;

SomeTypeOtherType으로 할당할 수 있으면 TrueType이 되고 그렇지 않으면 FalseType이 됩니다.

위의 코드는 너무나 간단해서 조건부 타입이 정말로 유용하게 쓰일 수 있는지 알기가 어려운데요, 제네릭과 함께 사용될 때 그 힘이 발휘됩니다. 다음의 코드를 보시죠.

interface IdLabel {
  id: number /* some fields */;
}
interface NameLabel {
  name: string /* other fields */;
}

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;

오, 제네릭 인자의 타입에 따라 NameOrId<T>의 타입이 IdLabel 또는 NameLabel로 결정됩니다.

이제 위의 장황한 오버로드 함수를 다음의 간결한 제네릭 함수로 표현할 수 있습니다.

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

// 인자 타입이 string이므로 반환 타입은 `NameLabel`이 됨
let a = createLabel("typescript");

// 인자 타입이 number이므로 반환 타입은 `IdLabel`이 됨
let b = createLabel(2.8);

// `random()`에 의해 인자 타입은 `string | number`가 되므로 반환 타입 역시 `NameLabel | IdLabel`이 됨
let c = createLabel(Math.random() ? "hello" : 42);

조건부 타입의 제약 조건

다음의 코드를 확인해 봅시다.

type MessageOf<T> = T["message"];
> Type '"message"' cannot be used to index type 'T'.

type MessageOf<T>Tmessage 속성 타입을 취하고 있습니다. 하지만 제네릭 타입 T는 어떠한 extends도 가지지 않으므로 message를 알 수 없습니다. 그러므로 오류입니다.

이것을 다음처럼 표현하면 TypeScript는 더이상 오류를 표시하지 않습니다.

// T는 message 속성이 있고 그 타입은 알 수 없다는 것을 표현
type MessageOf<T extends { message: unknown }> = T["message"];

interface Email {
  message: string;
}

// EmailMessageContents는 Email에 `message` 속성이 있으므로 그 타입인 `string`이 됨
type EmailMessageContents = MessageOf<Email>;

하지만 해당 속성이 없을 경우 기본 값으로 naver 타입이 되게 할 수는 없을까요? 이때 조건부 타입을 사용할 수 있습니다.

// message 속성이 있을경우 참이므로 `T["message"]` 타입이 됨. 없을 경우 `never` 타입이 됨
type MessageOf<T> = T extends { message: unknown } ? T["message"] : never;

interface Email {
  message: string;
}

interface Dog {
  bark(): void;
}

// string
type EmailMessageContents = MessageOf<Email>;

// never
type DogMessageContents = MessageOf<Dog>;

다름은 다른 예시 입니다.

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

// Extracts out the element type.
type Str = Flatten<string[]>;

// Leaves the type alone.
type Num = Flatten<number>;

Flatten<string[]>any[]로 할당할 수 있으므로 참이 되어 T[number] 타입인 string이 됩니다. Flatten<number>의 경우 any[]로 할당할 수 없으므로 거짓이 되어 T 타입인 number가 됩니다.

조건부 유형 내 추론

Flatten<string[]> 경우 처럼 타입을 추출하는 방식을 보았습니다. 이런 방법처럼 infer 키워드로 관심 있는 타입을 얻을 수 있습니다. 다음의 예시는 ReturnType<T>infer를 이용해서 구현한 것입니다.

// `infer Return`는 타입이 되고 그 타입은 `Return`으로 쓸 수 있음
// 즉, 함수의 반환형을 `Return`으로 취할 수 있게 됨
type GetReturnType<Type> = Type extends (...args: never[]) => infer Return
  ? Return
  : never;

// number
type Num = GetReturnType<() => number>;

// string
type Str = GetReturnType<(x: string) => string>;

// boolean[]
type Bools = GetReturnType<(a: boolean, b: boolean) => boolean[]>

오버로드 함수의 다중 호출 서명의 경우 가장 포괄적인 서명을 선택합니다.

declare function stringOrNum(x: string): number;
declare function stringOrNum(x: number): string;
declare function stringOrNum(x: string | number): string | number;

// string | number
type T1 = ReturnType<typeof stringOrNum>;

분배 조건부 타입

다음의 경우를 살펴봅시다. 타입을 타입[]으로 확장하는 조건부 타입입니다.

type ToArray<Type> = Type extends any ? Type[] : never;

유니온 타입을 사용하면 어떻게 될까요?

type ToArray<Type> = Type extends any ? Type[] : never;

// string[] | number[]
type StrArrOrNumArr = ToArray<string | number>;

string | numberToArray<string> | ToArray<number>가 되어 결국에 string[] | number[]이 됩니다.

이 동작은 기본 동작입니다. 만약에 이 동작 대신 (string | number)[]이 되고자 한다면 다음처럼 가능 합니다.

type ToArrayNonDist<Type> = [Type] extends [any] ? Type[] : never;

// (string | number)[]
type StrArrOrNumArr = ToArrayNonDist<string | number>;