조건부 타입
조건부 타입 (Conditional Types)
프로그램을 짜다 보면 입력에 따라 분기를 나눠야 하는 일이 많죠. 타입 수준에서도 그런 분기가 필요해요. 조건부 타입은 타입 시스템 안에서 if 문처럼 동작하게 해 주는 기능으로, 입력 타입과 출력 타입 사이의 관계를 표현해 줍니다. 제네릭과 함께 쓰면 그 진가가 드러납니다.
출처: TypeScript 핸드북
유용한 프로그램의 핵심에는 대부분 "입력에 따라 결정을 내리는" 작업이 있습니다. JavaScript 프로그램도 마찬가지지만, 값은 쉽게 들여다볼 수 있기 때문에 그 결정은 입력의 타입에도 기반하게 돼요. 조건부 타입은 바로 입력 타입과 출력 타입의 관계를 설명해 줍니다.
interface Animal {
live(): void;
}
interface Dog extends Animal {
woof(): void;
}
type Example1 = Dog extends Animal ? number : string;
// ^?
type Example2 = RegExp extends Animal ? number : string;
// ^?
조건부 타입은 JavaScript의 조건식(condition ? trueExpression : falseExpression)과 비슷해 보이는 형태를 띱니다.
type SomeType = any;
type OtherType = any;
type TrueType = any;
type FalseType = any;
type Stuff =
// ---cut---
SomeType extends OtherType ? TrueType : FalseType;
extends 왼쪽의 타입이 오른쪽 타입에 할당 가능하면 첫 번째 분기(참 분기)의 타입을 얻고, 그렇지 않으면 뒤의 분기(거짓 분기) 타입을 얻습니다.
위 예시만 보면 조건부 타입이 당장 유용해 보이지 않을 수 있어요. Dog extends Animal인지 스스로 판단해서 number나 string을 고르는 것뿐이니까요. 하지만 조건부 타입의 힘은 제네릭과 함께 쓸 때 드러납니다.
예를 들어 다음 createLabel 함수를 봅시다.
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";
}
이 createLabel 오버로드들은 입력 타입에 따라 선택을 내리는 하나의 JavaScript 함수를 설명합니다. 여기서 몇 가지를 짚어볼게요.
- 라이브러리가 API 곳곳에서 같은 종류의 선택을 반복해서 해야 한다면, 이 방식은 매우 번거로워져요.
- 오버로드를 세 개 만들어야 합니다. 타입이 확실한 경우 각각 하나씩(
string용과number용), 그리고 가장 일반적인 경우(string | number를 받는)를 위한 하나죠.createLabel이 처리할 새 타입이 늘어날 때마다 오버로드 수는 기하급수적으로 늘어납니다.
대신 그 논리를 조건부 타입에 담을 수 있어요.
interface IdLabel {
id: number /* some fields */;
}
interface NameLabel {
name: string /* other fields */;
}
// ---cut---
type NameOrId<T extends number | string> = T extends number
? IdLabel
: NameLabel;
그러면 그 조건부 타입을 사용해 오버로드를 하나의 오버로드 없는 함수로 줄일 수 있습니다.
interface IdLabel {
id: number /* some fields */;
}
interface NameLabel {
name: string /* other fields */;
}
type NameOrId<T extends number | string> = T extends number
? IdLabel
: NameLabel;
// ---cut---
function createLabel<T extends number | string>(idOrName: T): NameOrId<T> {
throw "unimplemented";
}
let a = createLabel("typescript");
// ^?
let b = createLabel(2.8);
// ^?
let c = createLabel(Math.random() ? "hello" : 42);
// ^?
조건부 타입 제약 (Conditional Type Constraints)
조건부 타입 안의 검사는 종종 새로운 정보를 우리에게 주곤 합니다. 타입 가드로 좁히기(narrowing)가 더 구체적인 타입을 주는 것처럼, 조건부 타입의 참 분기는 우리가 검사한 타입으로 제네릭을 더 제약해 줍니다.
예를 들어 다음을 봅시다.
// @errors: 2536
type MessageOf<T> = T["message"];
이 예시에서 T가 message라는 프로퍼티를 가진다고 알려지지 않아 TypeScript가 에러를 냅니다. T를 제약하면 TypeScript는 더 이상 불평하지 않아요.
type MessageOf<T extends { message: unknown }> = T["message"];
interface Email {
message: string;
}
type EmailMessageContents = MessageOf<Email>;
// ^?
그런데 MessageOf가 아무 타입이나 받고, message 프로퍼티가 없으면 never 같은 걸 기본값으로 주고 싶다면 어떨까요? 제약을 밖으로 옮기고 조건부 타입을 도입하면 됩니다.
type MessageOf<T> = T extends { message: unknown } ? T["message"] : never;
interface Email {
message: string;
}
interface Dog {
bark(): void;
}
type EmailMessageContents = MessageOf<Email>;
// ^?
type DogMessageContents = MessageOf<Dog>;
// ^?
참 분기 안에서는 TypeScript가 T에 message 프로퍼티가 반드시 있을 것임을 압니다.
또 다른 예시로, 배열 타입을 그 요소 타입으로 펼치고(flatten) 그 외에는 그대로 두는 Flatten이라는 타입도 써볼 수 있어요.
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이 배열 타입을 받으면 number로 인덱스 접근을 해서 string[]의 요소 타입을 꺼내고, 그 외에는 받은 타입을 그대로 돌려줍니다.
조건부 타입 안에서 추론하기 (Inferring Within Conditional Types)
방금 우리는 조건부 타입으로 제약을 적용하고 타입을 추출해 냈어요. 이는 워낙 흔한 작업이라 조건부 타입이 더 쉽게 만들어 줍니다.
조건부 타입은 infer 키워드를 사용해 참 분기에서 비교 대상 타입으로부터 추론하는 방법을 제공합니다. 예를 들어 Flatten의 요소 타입을 인덱스 접근 타입으로 "수동으로" 꺼내는 대신 추론할 수도 있어요.
type Flatten<Type> = Type extends Array<infer Item> ? Item : Type;
여기서 우리는 Type의 요소 타입을 어떻게 꺼낼지 명시하는 대신, infer 키워드를 사용해 Item이라는 새 제네릭 타입 변수를 선언적으로 도입했어요. 이렇게 하면 관심 있는 타입의 구조를 파고들고 분해하는 고민에서 자유로워집니다.
infer 키워드로 유용한 도우미 타입 별칭을 몇 가지 쓸 수 있어요. 예를 들어 간단한 경우에는 함수 타입에서 반환 타입을 추출할 수 있습니다.
type GetReturnType<Type> = Type extends (...args: never[]) => infer Return
? Return
: never;
type Num = GetReturnType<() => number>;
// ^?
type Str = GetReturnType<(x: string) => string>;
// ^?
type Bools = GetReturnType<(a: boolean, b: boolean) => boolean[]>;
// ^?
호출 시그니처가 여러 개인 타입(예: 오버로드된 함수의 타입)에서 추론할 때는 마지막 시그니처(아마 가장 관대한 catch-all 케이스)로부터 추론이 이루어집니다. 인자 타입 목록을 기반으로 오버로드 해석을 수행하는 것은 불가능해요.
declare function stringOrNum(x: string): number;
declare function stringOrNum(x: number): string;
declare function stringOrNum(x: string | number): string | number;
type T1 = ReturnType<typeof stringOrNum>;
// ^?
분배적 조건부 타입 (Distributive Conditional Types)
조건부 타입이 제네릭 타입에 작용할 때, 유니온 타입이 주어지면 _분배적(distributive)_이 됩니다. 예를 들어 다음을 봅시다.
type ToArray<Type> = Type extends any ? Type[] : never;
ToArray에 유니온 타입을 넣으면, 조건부 타입은 그 유니온의 각 멤버에 각각 적용됩니다.
type ToArray<Type> = Type extends any ? Type[] : never;
type StrArrOrNumArr = ToArray<string | number>;
// ^?
여기서 일어나는 일은 ToArray가 다음에 대해 분배되고,
type StrArrOrNumArr =
// ---cut---
string | number;
유니온의 각 멤버 타입을 사실상 다음처럼 매핑하는 것입니다.
type ToArray<Type> = Type extends any ? Type[] : never;
type StrArrOrNumArr =
// ---cut---
ToArray<string> | ToArray<number>;
결국 우리는 다음을 얻게 됩니다.
type StrArrOrNumArr =
// ---cut---
string[] | number[];
일반적으로 분배성이 원하는 동작입니다. 그 동작을 피하려면 extends 키워드 양쪽을 대괄호로 감싸면 돼요.
type ToArrayNonDist<Type> = [Type] extends [any] ? Type[] : never;
// 'ArrOfStrOrNum' is no longer a union.
type ArrOfStrOrNum = ToArrayNonDist<string | number>;
// ^?