조건부 타입과 제네릭
제네릭 타입 매개변수를 조건부 타입에 넣어 입력에 따라 결과가 바뀌는 타입을 만들고, 유니온 걸러 내기의 계산 과정, 재귀로 Promise를 끝까지 푸는 타입, 응답 유니온에서 payload를 꺼낼 때 분배와 전체 검사의 차이, 선택적 속성 판정, 입력에 따른 함수 타입 선택을 다룹니다.
5장 2편에서 조건부 타입의 문법과 분배 규칙, infer를 보았습니다. 조건부 타입은 그 자체로는 이미 정해진 타입끼리의 비교이지만, 제네릭의 타입 매개변수를 넣으면 쓰는 곳에서 들어온 타입에 따라 결과가 달라지는 재사용할 수 있는 도구가 됩니다.
이 글은 그 조합을 여러 쓰임새로 나누어 봅니다. 입력의 모양에 따라 결과가 바뀌는 타입과 유니온을 걸러 내는 계산 과정, 재귀로 끝까지 푸는 타입, 응답의 유니온에서 일부를 꺼내는 두 가지 검사, 선택적 속성의 판정, 함수 타입의 선택을 차례로 다룹니다. 예제는 ts-first 프로젝트에 conditional-generics.ts를 만들고 include를 그 파일로 바꾸어 확인하며, 결과는 편집기의 표시와 값을 넣어 보는 검사로 확인합니다.
입력에 따라 바뀌는 결과
타입 매개변수를 검사하는 조건부 타입은 넘겨받은 타입의 종류나 모양에 따라 결과를 고릅니다. 결과에 T 자신이나 infer로 꺼낸 부분을 쓰면, 들어온 타입의 정보를 잃지 않고 그대로 돌려줄 수 있습니다.
type IsNumber<T> = T extends number ? T : string;
type GetNumberProperty<T> = T extends { num: infer N } ? N : T;| 타입 | 결과 | 이유 |
|---|---|---|
IsNumber<10> | 10 | 숫자 리터럴 타입은 number에 할당되어 T 자신이 남는다 |
IsNumber<string> | string | 거짓 분기의 string을 고른다 |
IsNumber<boolean> | string | true | false로 분배되어 두 멤버 모두 string이 된다 |
GetNumberProperty<{ num: 123 }> | 123 | num의 타입을 N으로 꺼낸다 |
GetNumberProperty<{ value: string }> | { value: string } | num이 없어 T를 그대로 돌려준다 |
IsNumber<boolean>이 string인 이유는 boolean이 실제로는 true | false의 유니온이어서, 분배된 두 멤버가 모두 거짓 분기를 고르기 때문입니다. GetNumberProperty는 이름과 달리 num의 타입이 숫자인지 확인하지 않으므로, { num: "x" }를 넣으면 "x"를 돌려줍니다.
유니온 걸러 내기의 계산 과정
분배되는 조건부 타입이 참 분기에서 never를 돌려주면, 그 멤버는 결과 유니온에서 사라집니다. 이 성질로 유니온의 멤버를 걸러 내는 제네릭 유틸리티를 만듭니다.
type MyExclude<T, U> = T extends U ? never : T;
type Statuses = "active" | "inactive" | "pending" | "deleted";
type CurrentStatuses = MyExclude<Statuses, "deleted">;| 멤버 | extends "deleted" | 결과 |
|---|---|---|
"active" | 거짓 | "active" |
"inactive" | 거짓 | "inactive" |
"pending" | 거짓 | "pending" |
"deleted" | 참 | never |
네 결과를 합치면 "active" | "inactive" | "pending" | never이고, 유니온에서 never는 아무것도 더하지 않으므로 CurrentStatuses는 "active" | "inactive" | "pending"입니다. 같은 방식으로 T extends null | undefined ? never : T는 null과 undefined를 걸러 내며, 결과는 표준 NonNullable과 같습니다.
재귀로 끝까지 풀기
조건부 타입의 결과에 자기 자신을 다시 쓰면, 모양이 맞는 동안 계속 안쪽으로 들어가는 재귀 타입을 만들 수 있습니다. 5장의 UnwrapPromise는 한 겹만 풀었지만, 다음 DeepUnwrapPromise는 Promise가 아닐 때까지 풉니다.
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
type DeepUnwrapPromise<T> = T extends Promise<infer U> ? DeepUnwrapPromise<U> : T;
type OneLayer = UnwrapPromise<Promise<Promise<number>>>;
type AllLayers = DeepUnwrapPromise<Promise<Promise<number>>>;
const oneLayer: OneLayer = Promise.resolve(1);
const allLayers: AllLayers = 1;
console.log(allLayers);1OneLayer는 바깥 한 겹만 풀린 Promise<number>여서 Promise.resolve(1)을 넣을 수 있고, AllLayers는 두 겹이 모두 풀린 number여서 숫자 1을 넣습니다. 표준 Awaited도 같은 생각으로 재귀하지만, Promise뿐 아니라 then 메서드를 가진 객체까지 다루도록 더 자세히 정의되어 있어서 DeepUnwrapPromise와 똑같지는 않습니다.
더 깊이: 재귀 타입의 깊이 제한과 꼬리 재귀
재귀하는 조건부 타입은 끝없이 펼쳐질 수 있으므로, 컴파일러는 일정한 깊이를 넘으면 Type instantiation is excessively deep and possibly infinite.라는 오류로 계산을 멈춥니다. 이 장치는 무한히 깊은 타입을 막으려는 것이지만, 쓸모 있는 타입도 걸릴 수 있습니다. TypeScript 4.5 릴리스 노트는 문자열 앞의 공백을 하나씩 지우는 TrimLeft가, 앞에 공백이 50개 있는 문자열에서 이 오류를 냈다는 예를 듭니다.
4.5부터는 조건부 타입의 한 분기가 그냥 다른 조건부 타입을 돌려주는 꼬리 재귀(tail recursion) 형태이면, 중간 결과를 만들지 않고 계산해 훨씬 깊이까지 허용합니다. 앞의 DeepUnwrapPromise처럼 참 분기가 자기 자신을 그대로 돌려주는 타입이 여기에 해당합니다. 반대로 Char | GetChars<Rest>처럼 재귀의 결과를 유니온에 더하는 타입은 이 최적화를 받지 못합니다. 이럴 때는 재귀 함수에서 하듯, 지금까지의 결과를 담는 누적(accumulator) 타입 매개변수를 하나 더 두어 결과를 다음 단계로 넘기면 꼬리 재귀가 됩니다.
응답 유니온에서 payload 꺼내기
성공 응답에는 payload가 있고 실패 응답에는 없을 때, "성공한 경우의 payload 타입"을 묻는 것과 "모든 응답이 payload를 가졌는가"를 묻는 것은 다른 질문입니다. 앞의 질문에는 분배되는 조건부 타입이, 뒤의 질문에는 [T]로 감싼 전체 검사가 답합니다.
interface UserResponse {
id: number;
name: string;
}
type ApiSuccessResponse = { status: "success"; payload: UserResponse };
type ApiErrorResponse = { status: "error"; message: string };
type ApiResponse = ApiSuccessResponse | ApiErrorResponse;
type GetPayload<T> = T extends { payload: infer P } ? P : never;
type WholeGetPayload<T> = [T] extends [{ payload: infer P }] ? P : never;
type ExtractedPayload = GetPayload<ApiResponse>;
type WholePayload = WholeGetPayload<ApiResponse>;
const payloadExample: ExtractedPayload = { id: 1, name: "Alice" };
console.log(payloadExample.name);AliceGetPayload<ApiResponse>는 성공 멤버에서 UserResponse를, 실패 멤버에서 never를 얻어 합치므로 UserResponse입니다. WholeGetPayload<ApiResponse>는 응답 유니온 전체를 한 번에 검사하는데, 실패 멤버에 payload가 없어 전체가 조건에 맞지 않으므로 never입니다. 그래서 const impossible: WholePayload = payloadExample;은 어떤 값도 never에 넣을 수 없어 진단됩니다.
TYPE / RUNTIME / CONTRACT
입력에 맞는 조건과 결과를 한 쌍으로 읽는다
유니온 필터링, 내부 타입 추출, 선택 속성 판정, 함수 타입 선택은 서로 다른 질문입니다. 본문의 구체 결과를 비교하세요.
위와 아래는 독립적인 두 타입 계산입니다. 원래 실패 응답에는 payload가 없어서 분산 검사는 성공 타입만 남기고 전체 검사는 never입니다. 타입 계산은 네트워크 응답의 실행 검증이 아닙니다.
FILTER
멤버별 분배와 never
MixedUnion에서 null과 undefined만 never가 되어 string | number | boolean이 남습니다. MyExclude는 "deleted"를 제외합니다.
INFER
패턴의 내부 타입
GetNumberProperty는 num 속성의 타입을 꺼냅니다. 이름과 달리 number만 받는 제약이 없으며 속성이 없으면 T를 반환합니다. GetPayload는 불일치 시 never입니다.
PROMISE
한 겹과 재귀를 비교
UnwrapPromise<Promise<Promise<number>>>는 Promise<number>입니다. DeepUnwrapPromise는 number까지 반복합니다. Promise만 처리하는 본문 예제는 표준 Awaited와 같은 구현이 아닙니다.
OPTIONAL KEY
속성의 존재 여부를 검사
{} extends Pick<T, K>이면 그 속성은 선택적입니다. User의 id와 name은 false, email은 true입니다. 필수 속성의 값 타입이 undefined를 포함해도 선택 속성인 것은 아닙니다.
FUNCTION TYPES
입력에 따른 시그니처 선택
number 함수는 123 → "123", string 함수는 "hello" → 5입니다. 유니온 입력의 결과는 함수 타입 유니온이며 여러 시그니처를 함께 제공하는 오버로드와 다릅니다.
WHOLE UNION · 보충
전체 비교가 필요한 경우
[T] extends [U]로 감싸면 멤버별 분배를 막고 전체를 검사합니다. 타입 계산은 실행 중 값을 검증하거나 재귀 깊이를 자동으로 제한하지 않습니다.
NonNullAndUndefined는 null과 undefined 멤버를 never로 제거한다. infer 추출과 재귀 Promise, 선택 속성 판정과 함수 타입 유니온을 구분한다.
위 그림의 두 계산은 서로 독립적입니다. 멤버별로 나누어 검사한 쪽은 성공 타입만 남고, 전체를 검사한 쪽은 실패 멤버 때문에 never가 됩니다. 이 계산은 모두 타입에서만 일어나므로, 실행 중에 응답이 성공인지 가르려면 실제 응답의 status를 검사하는 코드가 따로 필요합니다. 그림 아래쪽의 설명에 나오는 MixedUnion과 NonNullAndUndefined는 이 글의 예제에 없는 이름이며, 선택적 속성 판정은 그림의 User 대신 다음 절의 Memo로 확인합니다.
선택적 속성 판정
{} extends Pick<T, K>는 "빈 객체가 T에서 키 K만 고른 타입에 할당되는가"를 묻습니다. 그 키가 선택적 속성이면 빈 객체도 할당되고, 필수 속성이면 할당되지 않으므로, 이 검사로 속성이 선택적인지 판정할 수 있습니다.
type IsOptionalProperty<T, K extends keyof T> = {} extends Pick<T, K> ? true : false;
type Memo = {
text: string | undefined;
hint?: string;
};
type TextOptional = IsOptionalProperty<Memo, "text">;
type HintOptional = IsOptionalProperty<Memo, "hint">;
const memo: Memo = { text: undefined };
console.log(memo.text, memo.hint);undefined undefinedtext는 값으로 undefined를 받을 수 있지만 키 자체는 필수이므로, Pick<Memo, "text">에 빈 객체가 할당되지 않아 TextOptional은 false입니다. hint는 선택적 속성이어서 HintOptional은 true입니다. 실행하면 두 값 모두 undefined로 보이지만, text는 있는데 값이 undefined이고 hint는 키가 아예 없다는 점이 다르며, const missingText: Memo = {};는 text가 빠져 진단됩니다.
입력 타입에 따라 함수 타입 고르기
조건부 타입의 결과로 함수 타입을 고르면, 입력의 타입에 따라 다른 함수의 모양을 정할 수 있습니다. 다음 Converter<T>는 T가 number이면 문자열을 돌려주는 함수 타입을, string이면 숫자를 돌려주는 함수 타입을 고르고, 둘 다 아니면 never가 됩니다. 아래의 두 변수는 같은 Converter에 서로 다른 타입을 넣어 만든 것입니다.
type Converter<T> = T extends number
? (x: T) => string
: T extends string
? (x: T) => number
: never;
const numberToText: Converter<number> = (x) => x.toString();
const textToLength: Converter<string> = (x) => x.length;
console.log(numberToText(123), textToLength("hello"));123 5Converter<number>는 숫자를 받아 문자열을 돌려주는 함수, Converter<string>은 문자열을 받아 숫자를 돌려주는 함수가 되고, 매개변수 x의 타입은 그 함수 타입에서 문맥으로 정해집니다. Converter<boolean>은 두 조건에 모두 맞지 않아 never이므로, 어떤 함수도 넣을 수 없습니다. 분배 규칙은 공식 Handbook의 분배 조건부 타입에 설명되어 있습니다.
정리
- 조건부 타입에 타입 매개변수를 넣으면, 쓰는 곳에서 들어온 타입에 따라 결과가 바뀌는 재사용할 수 있는 타입이 됩니다.
- 분배되는 조건부 타입이
never를 돌려준 멤버는 결과 유니온에서 사라지므로, 이 성질로 유니온을 걸러 냅니다. - 결과에 자기 자신을 다시 쓰면 모양이 맞는 동안 안쪽으로 들어가는 재귀 타입을 만들 수 있습니다.
- 유니온의 멤버마다 꺼낼지, 유니온 전체가 조건에 맞는지 물을지에 따라 분배되는 검사와
[T]로 감싼 검사를 고릅니다. {} extends Pick<T, K>는 키가 선택적인지를 판정하며, 값에undefined를 허용하는 것과 키를 생략할 수 있는 것은 다릅니다.
연습 문제
1. 실패 응답에도 payload가 있다면
type ErrorWithPayload = { status: "error"; payload: { message: string } };와 type ChangedResponse = ApiSuccessResponse | ErrorWithPayload;를 선언했습니다. GetPayload<ChangedResponse>와 WholeGetPayload<ChangedResponse>는 각각 무엇인가요?
해설 보기
두 결과 모두 UserResponse | { message: string }입니다. 분배되는 검사는 두 멤버에서 꺼낸 타입을 합치고, 전체 검사는 이제 모든 멤버가 payload를 가지므로 참 분기로 가서 두 멤버의 payload 타입을 모은 유니온을 P로 추론합니다. [T]로 감싼 검사가 언제나 never를 만드는 것이 아니라, 유니온 전체가 요구한 모양에 맞는지가 결과를 가릅니다.
2. 바뀐 메모 타입
type AlternativeMemo = { text?: string; hint: string | undefined };에서 IsOptionalProperty<AlternativeMemo, "text">와 IsOptionalProperty<AlternativeMemo, "hint">는 각각 무엇인가요? { hint: undefined }와 { text: "안내" } 가운데 이 타입에 넣을 수 있는 객체는 어느 것인가요?
해설 보기
text는 선택적 속성이어서 true, hint는 필수 속성이어서 false입니다. { hint: undefined }는 필수 키 hint가 있고 값도 허용되므로 넣을 수 있지만, { text: "안내" }는 hint 키가 빠져 진단됩니다.
3. 유니온을 넣은 함수 타입
function callEither(fn: Converter<number | string>) { return fn(1); }은 검사를 통과하나요? 이 함수에 numberToText를 넘길 수는 있나요?
해설 보기
Converter<number | string>은 분배되어 ((x: number) => string) | ((x: string) => number)라는 함수 타입의 유니온이 됩니다. 함수의 유니온을 부를 때는 실제로 어느 쪽인지 모르므로 두 매개변수를 모두 만족하는 인수가 필요한데, number & string은 never여서 fn(1)은 Argument of type 'number' is not assignable to parameter of type 'never'.로 진단됩니다. numberToText는 유니온의 한 멤버와 같은 타입이어서 넘길 수 있지만, 이 함수 안에서는 부를 수 없으므로, 여러 호출 방법을 함께 제공하려면 함수 타입의 유니온이 아니라 오버로드가 필요합니다.
다음 글 제네릭 유틸리티 함수 구현에서는 지금까지의 제네릭과 타입 연산을 실제로 동작하는 함수에 적용해, 타입과 실행 결과가 함께 맞는 유틸리티 함수를 만듭니다.