조건부 타입과 제네릭
제네릭 입력에 따라 결과를 바꾸는 조건부 타입과 분산·infer 규칙을 결합해 중첩 타입을 재사용 가능하게 변환합니다.
5장 2절에서 조건부 타입이 타입스크립트에서 타입에 조건부 로직을 적용하는 강력한 방법임을 배웠습니다.
이 조건부 타입은 제네릭(Generics)과 결합될 때 그 진정한 위력을 발휘합니다.
제네릭 타입 변수를 기반으로 동적으로 타입을 결정하고 변형함으로써, 매우 유연하고 재사용 가능한 타입을 설계할 수 있습니다.
이 절에서는 조건부 타입이 제네릭과 어떻게 상호작용하는지, 그리고 이를 통해 어떤 고급 타입 조작이 가능한지 심도 있게 살펴보겠습니다.
제네릭 타입 변수를 활용한 조건부 타입
조건부 타입은 제네릭 타입 변수의 속성에 따라 다른 타입을 반환할 수 있습니다.
이는 특히 입력 타입에 따라 출력 타입이 달라지는 함수를 모델링할 때 유용합니다.
// IsNumber<T>는 T가 number에 할당 가능하면 T를, 아니면 string을 반환합니다.
type IsNumber<T> = T extends number ? T : string;
type Type1 = IsNumber<10>; // Type1 = 10 (number 리터럴 타입)
type Type2 = IsNumber<number>; // Type2 = number
type Type3 = IsNumber<string>; // Type3 = string
type Type4 = IsNumber<boolean>; // Type4 = string
// T에 num 속성이 있으면 그 속성의 타입을 반환합니다. number 타입으로 제한하지는 않습니다.
// 그렇지 않다면 T 자체를 반환합니다.
type GetNumberProperty<T> = T extends { num: infer N } ? N : T;
type Type5 = GetNumberProperty<{ num: 123 }>; // Type5 = 123
type Type6 = GetNumberProperty<{ num: number }>; // Type6 = number
type Type7 = GetNumberProperty<{ value: string }>; // Type7 = { value: string; }
type Type8 = GetNumberProperty<string[]>; // Type8 = string[]이처럼 제네릭 타입 변수 T의 형태나 속성에 따라 조건적으로 다른 타입을 반환함으로써, 입력 타입에 반응하는 타입을 만들 수 있습니다.
분산 조건부 타입과 제네릭의 시너지
5장 2절에서 다룬 분산 조건부 타입(Distributive Conditional Types)은 제네릭과 함께 사용될 때 더욱 강력해집니다.
조건식 왼쪽이 T처럼 그대로 쓰인 타입 매개변수이고 T가 유니온이면, 조건부 타입은 각 멤버에 개별적으로 적용된 뒤 다시 유니온으로 합쳐집니다.
이 특성은 특정 유니온 타입에서 원하는 타입만 필터링하거나 변형할 때 매우 유용합니다.
// NonNullAndUndefined<T>는 T가 null 또는 undefined이면 never를, 아니면 T를 반환합니다.
// 즉, T에서 null과 undefined를 제거합니다.
type NonNullAndUndefined<T> = T extends null | undefined ? never : T;
type MixedUnion = string | number | null | undefined | boolean;
// NonNullAndUndefined<MixedUnion>은 다음과 같이 분산되어 처리됩니다.
// (NonNullAndUndefined<string> | NonNullAndUndefined<number> | NonNullAndUndefined<null> | NonNullAndUndefined<undefined> | NonNullAndUndefined<boolean>)
// 결과: (string | number | never | never | boolean)
type CleanedUnion = NonNullAndUndefined<MixedUnion>;
// type CleanedUnion = string | number | boolean이 예제는 조건부 타입으로 결측 타입을 제거합니다. 내장 NonNullable<T>도 결측 타입을 제거하지만 TypeScript 4.8부터 구현은 T & {}입니다.
Exclude<T, U>와 Extract<T, U> 또한 이 분산 조건부 타입의 원리를 활용하여 구현됩니다.
// Exclude<T, U>의 모의 구현 (T에서 U에 할당 가능한 타입을 제외)
type MyExclude<T, U> = T extends U ? never : T;
type Statuses = "active" | "inactive" | "pending" | "deleted";
type CurrentStatuses = MyExclude<Statuses, "deleted">;
// type CurrentStatuses = "active" | "inactive" | "pending"
// Statuses의 각 멤버가 "deleted"에 할당 가능한지 검사합니다.
// ("active" extends "deleted" ? never : "active") => "active"
// ("inactive" extends "deleted" ? never : "inactive") => "inactive"
// ("pending" extends "deleted" ? never : "pending") => "pending"
// ("deleted" extends "deleted" ? never : "deleted") => never
// 결과: "active" | "inactive" | "pending" | never => "active" | "inactive" | "pending"infer 키워드와 제네릭의 조합
5장 2절에서 다룬 infer 키워드는 조건부 타입 내에서 타입 변수를 추론하여 사용할 수 있게 해주며, 제네릭과 결합될 때 복잡한 타입의 일부를 동적으로 추출하는 데 매우 강력합니다.
// UnwrapPromise<T>는 Promise<U> 형태의 타입 T에서 U를 추출합니다.
// 만약 T가 Promise<any> 형태가 아니라면 T 자체를 반환합니다.
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
type P1 = UnwrapPromise<Promise<string>>; // P1 = string
type P2 = UnwrapPromise<Promise<Promise<number>>>; // P2 = Promise<number> (한 단계만 벗겨냄)
type P3 = UnwrapPromise<string>; // P3 = string
// DeepUnwrapPromise<T>는 재귀적으로 Promise를 벗겨내어 최종 값을 추출합니다.
type DeepUnwrapPromise<T> = T extends Promise<infer U> ? DeepUnwrapPromise<U> : T;
type DP1 = DeepUnwrapPromise<Promise<string>>; // DP1 = string
type DP2 = DeepUnwrapPromise<Promise<Promise<number>>>; // DP2 = number
type DP3 = DeepUnwrapPromise<string>; // DP3 = string
interface UserResponse {
id: number;
name: string;
}
// GetPayload<T>는 T가 { payload: infer P } 형태를 가지면 P를 추출합니다.
type GetPayload<T> = T extends { payload: infer P } ? P : never;
type ApiSuccessResponse = { status: "success"; payload: UserResponse };
type ApiErrorResponse = { status: "error"; message: string };
type SuccessPayload = GetPayload<ApiSuccessResponse>; // SuccessPayload = UserResponse
type ErrorPayload = GetPayload<ApiErrorResponse>; // ErrorPayload = never (payload 속성이 없으므로)infer 키워드는 제네릭 타입 T의 내부 구조를 들여다보고 그 안에 있는 특정 타입 U를 뽑아내어 새로운 타입으로 사용할 수 있게 해줍니다.
이는 특히 함수 시그니처, 배열, 객체의 중첩된 타입을 다룰 때 매우 유용합니다.
응답 중 일부를 추출할지 전체 구조를 요구할지
성공 응답에는 payload가 있고 실패 응답에는 없습니다. 성공 멤버에서만 payload 타입을 얻는 일과, 응답 전체가 payload 구조를 갖추었는지 확인하는 일은 다른 질문입니다. 5장 2절의 분산·전체 검사를 이 응답 타입에 적용해 보겠습니다.
바로 위의 UserResponse, GetPayload, ApiSuccessResponse, ApiErrorResponse를 선언한 같은 .ts 파일에 다음 타입만 추가합니다. 두 응답 alias는 현재 예제에 정의된 타입을 그대로 사용합니다.
type ApiResponse = ApiSuccessResponse | ApiErrorResponse;
type WholeGetPayload<T> = [T] extends [{ payload: infer P }] ? P : never;
type ExtractedPayload = GetPayload<ApiResponse>;
type WholePayload = WholeGetPayload<ApiResponse>;두 결과를 먼저 예상해 보세요. 성공·실패 멤버를 각각 검사하면 무엇이 남을까요? 응답 유니온 전체를 한 번에 검사할 때는 어떤 멤버가 조건을 만족하지 못하는지도 찾아봅니다.
예상 결과와 이유 보기
ExtractedPayload는 UserResponse입니다. GetPayload가 성공 멤버에서 UserResponse, 실패 멤버에서 never를 반환해 UserResponse | never가 되고, 유니온에서 never는 사라집니다.
WholePayload는 never입니다. 실패 멤버에 payload가 없으므로 전체 ApiResponse는 요구한 구조에 할당할 수 없습니다. 성공 멤버의 타입만 따로 추출하지 않고 거짓 분기를 선택합니다.
const payloadExample: ExtractedPayload = { id: 1, name: 'Alice' };
// const invalidPayload: ExtractedPayload = { message: '조회 실패' }; // 예상 오류: UserResponse의 구조가 아님
// const impossiblePayload: WholePayload = payloadExample; // 예상 오류: never에는 이 값을 대입할 수 없음기존 프로젝트의 에디터에서 결과 타입과 예상 대입 오류를 비교해 보세요. 결과가 다르면 GetPayload의 거짓 분기가 never인지, WholeGetPayload의 검사 대상을 튜플로 감쌌는지, 실패 응답에 payload를 추가하지 않았는지 확인합니다.
이 타입 추출은 실제 네트워크 응답을 읽거나 JSON을 검증하지 않습니다. 실행 중 성공·실패를 구분하려면 실제 응답의 status 등을 검사하는 코드가 따로 필요합니다.
실패 응답에도 payload를 넣으면?
원래 응답 타입은 그대로 두고, 별도 연습에서 type ErrorWithPayload = { status: 'error'; payload: { message: string } };와 type ChangedResponse = ApiSuccessResponse | ErrorWithPayload;를 선언했다고 가정합니다. GetPayload<ChangedResponse>와 WholeGetPayload<ChangedResponse>는 각각 무엇이 될까요? 어떤 멤버가 조건에 맞는지와 infer P가 모으는 타입을 함께 적어보세요.
모든 멤버가 payload를 가진 경우
두 결과 모두 UserResponse | { message: string }입니다. 분산 검사는 각 멤버에서 추출한 두 타입을 합칩니다. 전체 검사는 모든 멤버가 payload 구조에 맞으므로 참 분기로 가며, 공통 구조의 payload 위치에서 두 타입을 추론합니다. [T]가 언제나 never를 만드는 것이 아니라, 전체 입력이 요구한 구조에 맞는지가 핵심입니다.
분산과 튜플로 감싼 전체 검사의 규칙은 공식 조건부 타입 설명에서 대조할 수 있습니다. 앞의 NonNullable 구현 변경은 TypeScript 4.8 설명에서 확인할 수 있습니다.
조건부 타입을 읽을 때는 먼저 T extends U ? X : Y의 기본 분기를 확인하고, 그다음 T가 유니온으로 분산되는지 살펴보는 것이 좋습니다.
필요하면 infer로 내부 타입을 추출하고, 분산이 의도와 다르면 [T] extends [U]처럼 래핑해 전체 유니온을 한 번에 비교합니다.
이 평가 흐름은 Exclude<T, U>, Extract<T, U>, ReturnType<T> 같은 유틸리티 타입을 읽는 데 쓰입니다. 위 DeepUnwrapPromise는 Promise만 처리하는 단순 재귀 예제이며 표준 Awaited와 동일한 구현은 아닙니다.
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, 선택 속성 판정과 함수 타입 유니온을 구분한다.
조건부 타입과 제네릭의 고급 활용 예시
interface HasName {
name: string;
}
interface HasId {
id: number;
}
// IsRequired<T, K>는 T가 K 속성을 필수로 가지는지 여부에 따라 타입을 결정합니다.
type IsRequired<T, K extends keyof T> =
{} extends Pick<T, K> ? false : true;
// 더 간단한 버전 (속성 자체가 optional인지 여부 판단)
type IsOptionalProperty<T, K extends keyof T> =
{} extends Pick<T, K> ? true : false;
type User = {
id: number;
name: string;
email?: string; // 선택적 속성
}
type IdRequired = IsOptionalProperty<User, 'id'>; // IdRequired = false (id는 optional이 아님)
type NameRequired = IsOptionalProperty<User, 'name'>; // NameRequired = false (name은 optional이 아님)
type EmailOptional = IsOptionalProperty<User, 'email'>; // EmailOptional = true (email은 optional임)필수 키에도 undefined를 저장할 수 있다
속성이 없어도 되는지와, 있는 속성의 값에 undefined를 허용하는지는 다릅니다. 위 IsOptionalProperty 선언을 사용해 다음 타입을 계산합니다. strictNullChecks를 포함한 strict 설정을 켜고 확인합니다.
type Memo = {
text: string | undefined;
hint?: string;
};
type TextOptional = IsOptionalProperty<Memo, 'text'>;
type HintOptional = IsOptionalProperty<Memo, 'hint'>;
const memo: Memo = { text: undefined };
// const missingText: Memo = {}; // 예상 오류: text 속성이 빠짐Pick<Memo, 'text'>는 필수 text 속성을 갖습니다. 빈 객체 {}가 여기에 맞지 않으므로 TextOptional은 false입니다. Pick<Memo, 'hint'>는 선택 속성이므로 빈 객체를 받을 수 있고 HintOptional은 true입니다. 값이 undefined라는 이유로 필수 키 자체가 없어도 된다고 해석하지 않습니다.
조건을 바꾸어 계산하기
type AlternativeMemo = { text?: string; hint: string | undefined };로 선언을 바꾸면 두 키의 선택 여부는 각각 무엇일까요? { hint: undefined }와 { text: '안내' } 중 어느 객체를 이 타입에 대입할 수 있는지도 먼저 적어보세요.
키의 존재 조건 확인
text는 선택 키라 true, hint는 필수 키라 false입니다. { hint: undefined }는 필수 키가 있고 값 타입도 맞습니다. { text: '안내' }는 hint 키가 빠져 거부됩니다. Pick으로 고른 속성에 빈 객체를 대입할 수 있는지 한 키씩 검사하면 두 질문이 섞이지 않습니다.
T가 유니온이면 결과도 함수 타입의 유니온입니다. 여러 호출 시그니처를 함께 제공하는 오버로드와는 다릅니다.
// TypeSafeFunctionOverload는 입력 타입에 따라 다른 반환 타입을 가지는 함수 시그니처를 정의합니다.
type TypeSafeFunctionOverload<T> = T extends number
? (x: T) => string
: T extends string
? (x: T) => number
: never;
const func1: TypeSafeFunctionOverload<number> = (x) => x.toString();
console.log(func1(123)); // "123"
const func2: TypeSafeFunctionOverload<string> = (x) => x.length;
console.log(func2("hello")); // 5
// const func3: TypeSafeFunctionOverload<boolean> = (x) => {}; // Error: 결과 타입이 never이므로 함수를 할당할 수 없습니다.조건부 타입과 제네릭의 조합은 타입스크립트 타입 시스템을 매우 강력하고 유연하게 만들어 줍니다.
이를 통해 런타임 코드 복잡성을 줄이면서도 타입 안전성을 유지하고, 다양한 시나리오에 대응하는 고도로 추상화된 타입을 정의할 수 있습니다.
복잡한 라이브러리나 프레임워크를 개발할 때, 혹은 코드베이스 전체 타입 안정성을 높이고 싶을 때 이 기능은 사실상 필수입니다.
처음에는 어렵게 느껴질 수 있지만, 타입스크립트 내장 유틸리티 타입 구현을 분석하면서 학습하면 훨씬 빠르게 익힐 수 있습니다.
조건부 타입은 작은 타입 분기처럼 보이지만, 유니온 분산과 infer가 함께 들어오면 결과가 크게 달라집니다.
조건부 타입을 직접 작성할 때는 분산이 필요한지, infer로 꺼낸 타입이 어디에서 쓰이는지, never가 의도대로 사라지는지까지 한 번에 확인하는 것이 안전합니다.