본문 바로가기

조건부 타입

타입이 다른 타입에 할당되는지에 따라 결과 타입을 고르는 조건부 타입의 문법과, 유니온을 멤버별로 나누어 계산하는 분배 규칙, 표준 유틸리티 Exclude와 Extract의 원리, infer로 함수의 반환 타입과 매개변수, Promise의 내부 타입을 꺼내는 방법을 다룹니다.

앞 글의 타입 가드는 실행 중의 값을 검사해 분기했습니다. 조건부 타입(conditional type)은 같은 일을 타입에 대해 합니다. 어떤 타입이 다른 타입에 할당될 수 있으면 이 타입을, 아니면 저 타입을 결과로 고르는 것이며, 모양은 자바스크립트의 삼항 연산자와 같습니다.

이 글은 조건부 타입의 문법에서 시작해, 유니온을 넣었을 때 멤버마다 따로 계산되는 분배 규칙과 그 규칙으로 만든 표준 유틸리티를 봅니다. 마지막으로 infer로 함수나 Promise 같은 타입의 일부를 꺼내는 방법을 다룹니다. 조건부 타입은 실행 중에 아무 일도 하지 않으므로, 결과는 편집기에서 타입 이름에 마우스를 올려 확인하거나 그 타입의 변수에 값을 넣어 보며 확인합니다. 예제는 ts-first 프로젝트에 conditional.ts를 만들고 include를 그 파일로 바꾸어 확인합니다.

기본 문법

조건부 타입은 검사할 타입 extends 비교할 타입 ? 참일 때의 타입 : 거짓일 때의 타입으로 적습니다. 여기서 extends는 상속이 아니라 "왼쪽 타입이 오른쪽 타입에 할당될 수 있는가"를 묻는 검사입니다.

type IsString<T> = T extends string ? "yes" : "no";

type A = IsString<string>;
type B = IsString<number>;
type C = IsString<"hello">;
type D = IsString<unknown>;
type E = IsString<any>;

const a: A = "yes";
const b: B = "no";
console.log(a, b);
yes no
타입결과이유
IsString<string>"yes"string은 string에 할당된다
IsString<number>"no"number는 string에 할당되지 않는다
IsString<"hello">"yes"문자열 리터럴 타입은 string에 할당된다
IsString<unknown>"no"unknown은 string에 할당되지 않는다
IsString<any>"yes" | "no"any는 두 분기를 모두 고른다

A의 값으로는 "yes"만, B의 값으로는 "no"만 넣을 수 있으므로 실행하면 두 문자열이 출력됩니다. const wrong: A = "no";처럼 반대 값을 넣으면 Type '"no"' is not assignable to type '"yes"'.로 진단되며, 이렇게 값을 넣어 보는 것이 조건부 타입의 결과를 확인하는 가장 간단한 방법입니다.

ToArray<string | number>는 string[] | number이다. T extends 조건은 분배되며 튜플로 감싼 검사 대상은 전체를 검사한다.

TYPE / RUNTIME / CONTRACT

조건부 타입은 타입 관계에 따라 결과를 고른다

ToArray의 string | number는 문자열 멤버만 배열로 바뀝니다. 조건의 검사 대상이 타입 매개변수 T 자체이면 유니온 멤버별로 계산합니다.

유니온의 각 멤버를 계산하고 결과를 합친다ToArray<T>의입력string|number에서string과number로나누고각각참분기string[]와거짓분기number를얻어결과string[]|number로합친다. 6노드6간선타입계산이며런타임배열생성이나동시실행이아니다. 튜플전체검사는아래별도비교이며이분배간선에속하지않는다.입력 유니온string | numberT = stringextends string · 참T = numberextends string · 거짓참 분기 T[]string[]거짓 분기 Tnumber결과 유니온string[] | number
유니온의 각 멤버를 계산하고 결과를 합친다 · 모바일ToArray<T>의입력string|number에서string과number로나누고각각참분기string[]와거짓분기number를얻어결과string[]|number로합친다. 6노드6간선타입계산이며런타임배열생성이나동시실행이아니다. 튜플전체검사는아래별도비교이며이분배간선에속하지않는다. 모바일에서도 동일 노드와 간선 방향을 유지한다.입력 유니온string | numberT = stringextends string · 참참 분기 T[]string[]T = numberextends string · 거짓거짓 분기 Tnumber결과 유니온string[] | number

분배

멤버마다 결과를 계산

string은 string[], number는 그대로 number가 됩니다. 두 결과를 유니온으로 합칩니다. 실행 중 배열을 만드는 코드가 아닙니다.

전체 검사 · 보충

튜플로 분배를 막기

[T] extends [string]은 string | number 전체를 검사하므로 false입니다. 타입 매개변수가 검사 대상에 직접 나타나는지 구분하세요.

경계 값

any와 never

IsString<any>는 "yes" | "no"가 됩니다. 분배되는 IsString<never>는 처리할 멤버가 없어 never입니다.

다른 연산

조건부 타입과 인덱스 접근

Exclude와 Extract는 조건에 맞는 멤버를 골라냅니다. 본문의 GetPropertyValue는 T[K] 인덱스 접근입니다. NonNullable의 현행 표준 정의는 T & {}입니다.

ToArray<string | number>는 string[] | number이다. T extends 조건은 분배되며 튜플로 감싼 검사 대상은 전체를 검사한다.

유니온의 분배

검사하는 쪽이 T extends ...처럼 아무것도 감싸지 않은 타입 매개변수이고, 그 T에 유니온을 넣으면 조건부 타입은 유니온의 멤버마다 따로 계산한 뒤 결과를 다시 유니온으로 합칩니다. 이것을 분배 조건부 타입(distributive conditional type)이라고 합니다.

type ToArray<T> = T extends string ? T[] : T;

type StrArray = ToArray<string>;
type Num = ToArray<number>;
type Mixed = ToArray<string | number>;

StrArray는 string[], Num은 number입니다. Mixed는 ToArray<string> | ToArray<number>처럼 계산되어 string[] | number가 됩니다. 위 그림처럼 문자열 멤버는 참 분기에서 string[]이 되고, 숫자 멤버는 거짓 분기에서 그대로 number가 되어 둘이 합쳐지는 것입니다.

분배를 원하지 않는다면 검사하는 쪽을 [T] extends [string]처럼 튜플로 감쌉니다. 그러면 유니온 전체를 한 번에 검사합니다.

type ToArrayWhole<T> = [T] extends [string] ? T[] : T;

type DistributedResult = ToArray<string | number>;
type WholeResult = ToArrayWhole<string | number>;

const distributedStrings: DistributedResult = ["a"];
const distributedNumber: DistributedResult = 123;
const wholeString: WholeResult = "a";
const wholeNumber: WholeResult = 123;

[string | number]는 [string]에 할당되지 않으므로 WholeResult는 거짓 분기의 string | number입니다. 그래서 문자열 배열은 DistributedResult에만, 문자열 하나는 WholeResult에만 넣을 수 있습니다. 반대로 const wrongWhole: WholeResult = ["a"];처럼 넣으면, 이 결과에는 배열 타입이 없으므로 진단됩니다.

유니온이 하나도 없는 never를 넣으면, 분배할 멤버가 없으므로 결과도 never입니다. IsString<never>가 "yes"도 "no"도 아닌 never가 되는 것은 이 때문이며, [T]로 감싼 형태에서는 never가 [string]에 할당되므로 참 분기를 고릅니다.

조건부 타입으로 만든 유틸리티

표준 라이브러리의 몇몇 유틸리티 타입은 이 분배 규칙으로 만들어져 있습니다. Exclude<T, U>는 T의 멤버 가운데 U에 할당되는 것을 빼고, Extract<T, U>는 할당되는 것만 남깁니다.

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

type NoMissing = MyExclude<string | number | null | undefined, null | undefined>;
type OnlyStrings = MyExtract<string | number | boolean, string>;

NoMissing을 계산하면 string과 number는 거짓 분기에서 그대로 남고, null과 undefined는 참 분기에서 never가 됩니다. 유니온에서 never는 아무것도 더하지 않으므로 결과는 string | number입니다. 같은 방식으로 OnlyStrings는 string이며, 표준 라이브러리의 Exclude와 Extract도 이와 같은 선언으로 정의되어 있습니다.

null과 undefined를 빼는 표준 유틸리티 NonNullable<T>도 결과는 MyExclude<T, null | undefined>와 같습니다. 다만 표준 라이브러리는 이것을 조건부 타입이 아니라 T & {}로 선언합니다. {}는 null과 undefined를 뺀 모든 값의 타입이므로, 인터섹션이 그 둘을 걸러 내는 것입니다.

infer로 타입의 일부 꺼내기

조건부 타입의 extends 오른쪽에 infer 이름을 쓰면, 비교하는 타입의 그 위치에 있는 타입을 그 이름으로 받아 참 분기에서 쓸 수 있습니다. 함수 타입에서 반환 타입만, 또는 매개변수 목록만 꺼낼 때 씁니다.

type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : any;
type MyParameters<T> = T extends (...args: infer P) => any ? P : never;
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;

type FuncB = (num: number, str: string) => boolean;

type ReturnOfB = MyReturnType<FuncB>;
type ParamsOfB = MyParameters<FuncB>;
type NotFunction = MyReturnType<number>;
type FromPromise = UnwrapPromise<Promise<string>>;
type PlainValue = UnwrapPromise<number>;
타입결과이유
MyReturnType<FuncB>boolean반환 위치의 R이 boolean이다
MyParameters<FuncB>[num: number, str: string]매개변수 목록의 P가 이름 붙은 튜플이다
MyReturnType<number>any함수가 아니어서 거짓 분기를 고른다
UnwrapPromise<Promise<string>>stringPromise의 안쪽 U가 string이다
UnwrapPromise<number>numberPromise가 아니어서 원래 타입을 돌려준다

MyReturnType<FuncB>에서 FuncB는 (...args: any[]) => infer R의 모양과 맞으므로, 반환 위치의 boolean이 R이 되어 결과로 나옵니다. MyParameters는 매개변수 목록 전체를 P로 받아, 이름이 붙은 튜플 [num: number, str: string]을 돌려줍니다. 비교하는 모양과 맞지 않으면 infer로 받은 이름은 쓰이지 않고 거짓 분기의 타입이 결과가 됩니다.

MyReturnType은 함수 반환 타입 또는 any, MyParameters는 매개변수 튜플 또는 never, UnwrapPromise는 Promise 한 겹의 내부 타입 또는 원래 타입을 얻는다.

TYPE / RUNTIME / CONTRACT

infer는 일치한 타입의 한 부분에 이름을 붙인다

본문의 반환 타입, 매개변수 튜플, Promise 내부 타입 예제는 서로 다른 패턴입니다. 위 그림의 Lookup은 이 절 마지막 연습의 (code: number) => string | undefined를 비교합니다. 패턴과 불일치 결과를 한 쌍으로 읽으세요.

같은 함수 구조의 서로 다른 위치를 읽는다Lookup의같은함수타입을출발점으로두독립3노드2간선경로를비교한다. 첫경로함수→반환위치inferR→string|undefined이고둘째함수→인자위치inferP→[code:number]이다. 서로의결과사이에간선이없다. 불일치및Promise패턴은아래설명에서별도비교한다.반환 위치Lookup 함수 타입(code: number) → 결과infer R반환 위치만 읽기LookupResultstring | undefined인자 위치같은 Lookup(code: number) → 결과infer P인자 튜플 읽기LookupArguments[code: number]
같은 함수 구조의 서로 다른 위치를 읽는다 · 모바일Lookup의같은함수타입을출발점으로두독립3노드2간선경로를비교한다. 첫경로함수→반환위치inferR→string|undefined이고둘째함수→인자위치inferP→[code:number]이다. 서로의결과사이에간선이없다. 불일치및Promise패턴은아래설명에서별도비교한다. 모바일에서도 노드와 연결 방향·독립 경로가 같다.반환 위치Lookup 함수 타입(code: number) → 결과infer R반환 위치만 읽기LookupResultstring | undefined인자 위치같은 Lookup(code: number) → 결과infer P인자 튜플 읽기LookupArguments[code: number]

MYRETURNTYPE

반환 위치의 R

(...args: any[]) => infer R에 맞으면 R입니다. 본문 함수들의 결과는 string, boolean, void이며 함수가 아니면 any입니다.

MYPARAMETERS

인자 위치의 P

(...args: infer P) => any에 맞으면 P입니다. 인자 없는 함수는 [], 두 인자 함수는 [number, string]입니다. 불일치 결과는 never입니다.

UNWRAPPROMISE

Promise 한 겹의 U

Promise<infer U>에 맞으면 U이고 아니면 T입니다. 본문은 Promise 패턴을 사용하며 재귀적으로 벗기지 않습니다.

AWAITED · 비교

표준 유틸리티의 의미

Awaited는 중첩된 비동기 결과를 재귀적으로 해석합니다. 한 겹만 추출하는 UnwrapPromise와 구분하세요. infer는 런타임 검사 함수를 생성하지 않습니다.

MyReturnType은 함수 반환 타입 또는 any, MyParameters는 매개변수 튜플 또는 never, UnwrapPromise는 Promise 한 겹의 내부 타입 또는 원래 타입을 얻는다.

위 그림은 이 글의 연습 문제에 나오는 함수 타입 하나에서, 반환 위치와 매개변수 위치를 각각 infer로 읽는 두 경로를 비교합니다. 같은 함수 타입이라도 infer를 어느 위치에 두느냐에 따라 전혀 다른 부분을 꺼내며, 패턴과 맞지 않을 때의 결과도 유틸리티마다 다르게 정해 두었다는 점을 함께 봅니다. 그림 아래쪽의 카드는 결과가 string, boolean, void인 여러 함수와 인자 없는 함수를 예로 들지만, 본문에서 계산한 함수 타입은 FuncB 하나이고 그 결과는 위 표의 boolean과 [num: number, str: string]입니다.

표준 라이브러리에도 같은 일을 하는 ReturnType<T>와 Parameters<T>가 있지만, 허용하는 입력이 다릅니다. 표준 ReturnType은 T에 함수 타입이라는 제약이 붙어 있어서, ReturnType<number>는 any를 돌려주는 대신 Type 'number' does not satisfy the constraint '(...args: any) => any'.로 진단됩니다. 직접 만든 유틸리티와 표준 유틸리티는 이름과 일부 결과가 같아도 입력의 규칙까지 같다고 가정하지 않습니다.

더 깊이: 오버로드한 함수에서 infer가 읽는 시그니처

3장의 오버로드처럼 호출 시그니처가 여러 개인 함수 타입에 infer를 쓰면, 컴파일러는 그 가운데 마지막 시그니처에서만 타입을 꺼냅니다. 공식 Handbook의 조건부 타입 글은 마지막 시그니처가 아마 모든 경우를 받는 가장 넓은 시그니처일 것이라는 이유를 들고, 인수 타입의 목록으로 오버로드를 골라 주는 방법은 없다고 적습니다. 그래서 문자열을 받으면 숫자를, 숫자를 받으면 문자열을 돌려주는 두 오버로드 뒤에 세 번째 오버로드 시그니처로 (x: string | number): string | number를 둔 함수라면, 그 함수 타입에 ReturnType을 씌운 결과는 string | number가 됩니다. 같은 모양을 구현 시그니처로만 적으면 3장에서 본 것처럼 바깥에서 보이지 않으므로, ReturnType은 마지막 오버로드 (x: number): string을 읽어 string이 됩니다.

이 규칙은 오버로드를 적는 순서와도 이어집니다. 호출을 검사할 때는 앞에서부터 맞는 시그니처를 고르므로 구체적인 시그니처를 앞에 두고, infer는 마지막 것을 읽으므로 가장 넓은 시그니처를 맨 뒤에 두면 두 쪽 모두 자연스러운 결과를 얻습니다. 반대로 마지막 오버로드가 특수한 경우 하나만 다룬다면, ReturnType과 Parameters가 그 특수한 경우의 타입만 돌려주어 예상과 다른 결과가 나옵니다.

정리

  • 조건부 타입 T extends U ? X : Y는 T가 U에 할당되면 X를, 아니면 Y를 결과로 고르며, 실행 중에는 아무 일도 하지 않습니다.
  • 감싸지 않은 타입 매개변수에 유니온을 넣으면 멤버마다 따로 계산해 합치고, [T] extends [U]로 감싸면 유니온 전체를 한 번에 검사합니다.
  • 분배되는 조건부 타입에 never를 넣으면 결과도 never이고, any를 넣으면 두 분기를 모두 고릅니다.
  • Exclude와 Extract는 분배 규칙과, 유니온에서 never가 사라지는 성질로 만든 유틸리티입니다.
  • infer는 비교하는 타입의 한 위치에 이름을 붙여 꺼내며, 패턴과 맞지 않으면 거짓 분기의 타입이 결과가 됩니다.

연습 문제

1. 같은 함수에서 두 부분 꺼내기

이 글의 유틸리티를 같은 파일에 둔 채 다음 타입을 더했습니다. LookupResult와 LookupArguments는 각각 무엇인가요? LookupResult 타입의 변수에 "찾음", undefined, 4를 넣을 수 있는지, LookupArguments 타입의 변수에 [4], ["4"], []를 넣을 수 있는지 판단해 보세요.

type Lookup = (code: number) => string | undefined;
type LookupResult = MyReturnType<Lookup>;
type LookupArguments = MyParameters<Lookup>;
해설 보기

LookupResult는 반환 위치를 읽으므로 string | undefined이고, "찾음"과 undefined는 넣을 수 있지만 4는 진단됩니다. LookupArguments는 매개변수 목록을 읽으므로 [code: number]이고, [4]만 넣을 수 있습니다. ["4"]는 원소의 타입이, []는 길이가 맞지 않아 진단되며, 튜플은 원소의 개수와 각 위치의 타입을 함께 검사합니다.

2. 두 겹의 Promise

type OneLayer = UnwrapPromise<Promise<Promise<number>>>;의 결과는 number인가요, Promise<number>인가요? 표준 유틸리티 Awaited에 같은 타입을 넣으면 어떻게 되나요?

해설 보기

UnwrapPromise는 바깥 Promise의 안쪽 U를 한 번만 꺼내므로 OneLayer는 Promise<number>입니다. 표준 Awaited는 꺼낸 결과가 다시 Promise이면 계속 풀어 내도록 재귀적으로 선언되어 있어서, Awaited<Promise<Promise<number>>>는 number가 됩니다. 두 유틸리티 모두 실행 중에 Promise를 기다리지는 않고, 타입만 계산합니다.

3. 분배되지 않는 버전 만들기

type IsStringWhole<T> = [T] extends [string] ? "yes" : "no";일 때 IsStringWhole<string | number>와 IsString<string | number>의 결과는 각각 무엇인가요?

해설 보기

IsString<string | number>는 분배되어 IsString<string> | IsString<number>, 곧 "yes" | "no"가 됩니다. IsStringWhole<string | number>는 유니온 전체를 검사하는데, [string | number]는 [string]에 할당되지 않으므로 "no"입니다. "유니온의 멤버 가운데 하나라도"를 물을지, "유니온 전체가"를 물을지에 따라 둘 가운데 하나를 고릅니다.

다음 글 매핑된 타입에서는 객체 타입의 속성을 하나씩 돌며 같은 규칙으로 바꾸어 새 타입을 만드는 매핑된 타입을 다룹니다.