조건부 타입과 제네릭
제네릭 입력에 따라 결과를 바꾸는 조건부 타입과 분산·infer 규칙을 결합해 중첩 타입을 재사용 가능하게 변환합니다.
5장 2절에서 조건부 타입이 타입스크립트에서 타입에 조건부 로직을 적용하는 강력한 방법임을 배웠습니다.
이 조건부 타입은 제네릭(Generics)과 결합될 때 그 진정한 위력을 발휘합니다.
제네릭 타입 변수를 기반으로 동적으로 타입을 결정하고 변형함으로써, 매우 유연하고 재사용 가능한 타입을 설계할 수 있습니다.
이 절에서는 조건부 타입이 제네릭과 어떻게 상호작용하는지, 그리고 이를 통해 어떤 고급 타입 조작이 가능한지 심도 있게 살펴보겠습니다.
조건부 타입은 입력 타입이 특정 구조를 만족하는지 검사해 다른 결과 타입을 선택합니다. 여기에 제네릭을 결합하면 함수 인자, 응답 객체, 유니온 멤버처럼 호출 지점마다 달라지는 타입을 기준으로 결과를 계산할 수 있습니다.
- 제네릭 타입 변수를 활용한 조건부 타입
조건부 타입은 제네릭 타입 변수의 속성에 따라 다른 타입을 반환할 수 있습니다. 조건부 타입
- 분산 조건부 타입과 제네릭의 시너지
5장 2절에서 다룬 분산 조건부 타입(Distributive Conditional Types)은 유니온의 각 멤버를 따로 평가한 뒤 다시 유니온으로 합칩니다. 제네릭(Generics)
- infer 키워드와 제네릭의 조합
5장 2절에서 다룬 infer 키워드는 조건부 타입 내에서 타입 변수를 추론하여 사용할 수 있게 해주며, 함수 반환값이나 배열 요소처럼 내부 타입을 안전하게 꺼낼 때 쓰입니다. infer
- 조건부 타입과 제네릭 기준
정확성 조건부 타입은 `T extends U ? X : Y` 형태로 입력 타입의 구조를 검사하고 결과 타입을 분기합니다. 비용 분산 조건부 타입은 유니온을 자동으로 펼치므로 의도하지 않은 분산이 생기면 튜플 래핑으로 막아야 합니다. 확장성 조건부 타입은 입력 타입의 형태에 따라 다른 결과 타입을 선택합니다. 예외 infer는 제네릭 타입 내부에서 필요한 타입을 추론해 새 타입 계산에 사용할 수 있게 합니다.
- 정확성 조건부 타입은 `T extends U ? X
Y` 형태로 입력 타입의 구조를 검사하고 결과 타입을 분기합니다.
- 비용 분산 조건부 타입
유니온을 자동으로 펼치므로 의도하지 않은 분산이 생기면 튜플 래핑으로 막아야 합니다.
- 확장성 조건부 타입
입력 타입의 형태에 따라 다른 결과 타입을 선택합니다.
- 예외 infer
제네릭 타입 내부에서 필요한 타입을 추론해 새 타입 계산에 사용할 수 있게 합니다.
제네릭 타입 변수를 활용한 조건부 타입
조건부 타입은 제네릭 타입 변수의 속성에 따라 다른 타입을 반환할 수 있습니다.
이는 특히 입력 타입에 따라 출력 타입이 달라지는 함수를 모델링할 때 유용합니다.
// 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가 number 속성을 포함하는 객체라면, 그 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)은 제네릭과 함께 사용될 때 더욱 강력해집니다.
제네릭 타입 변수가 유니온 타입일 때, 조건부 타입은 유니온의 각 멤버에 개별적으로 적용된 후 다시 유니온으로 합쳐집니다.
이 특성은 특정 유니온 타입에서 원하는 타입만 필터링하거나 변형할 때 매우 유용합니다.
// Nullable<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>의 핵심 원리입니다.
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 키워드와 제네릭의 조합
함수 반환 타입, 배열 원소 타입, Promise 내부 타입처럼 구조 안쪽의 타입을 이름 붙여 재사용한다.
- Pattern T
특정 구조인지 검사
- infer R 구조 안쪽 타입
R로 붙잡음
- True branch 추출한 R
결과로 사용
- False branch 패턴
아니면 fallback
| 점검 | 기준 |
|---|---|
| ReturnType | (...args) => infer R |
| Awaited | Promise<infer V> |
| Element | Array<infer U> |
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를 뽑아내어 새로운 타입으로 사용할 수 있게 해줍니다.
이는 특히 함수 시그니처, 배열, 객체의 중첩된 타입을 다룰 때 매우 유용합니다.
조건부 타입을 읽을 때는 먼저 T extends U ? X : Y의 기본 분기를 확인하고, 그다음 T가 유니언으로 분산되는지 살펴보는 것이 좋습니다.
필요하면 infer로 내부 타입을 추출하고, 분산이 의도와 다르면 [T] extends [U]처럼 래핑해 전체 유니언을 한 번에 비교합니다.
이 평가 흐름이 NonNullable<T>, Exclude<T, U>, Extract<T, U>, ReturnType<T> 같은 유틸리티 타입의 기반이 됩니다.
조건부 타입과 제네릭의 고급 활용 예시
interface HasName {
name: string;
}
interface HasId {
id: number;
}
// IsRequired<T, K>는 T가 K 속성을 필수로 가지는지 여부에 따라 타입을 결정합니다.
type IsRequired<T, K extends keyof T> =
undefined extends T[K] ? (null extends T[K] ? false : false) : true;
// 더 간단한 버전 (속성 자체가 optional인지 여부 판단)
type IsOptionalProperty<T, K extends keyof T> =
undefined extends 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임)// 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: 'boolean' 형식은 'number' 또는 'string' 형식에 할당할 수 없습니다.조건부 타입과 제네릭의 조합은 타입스크립트 타입 시스템을 매우 강력하고 유연하게 만들어 줍니다.
이를 통해 런타임 코드 복잡성을 줄이면서도 타입 안전성을 유지하고, 다양한 시나리오에 대응하는 고도로 추상화된 타입을 정의할 수 있습니다.
복잡한 라이브러리나 프레임워크를 개발할 때, 혹은 코드베이스 전체 타입 안정성을 높이고 싶을 때 이 기능은 사실상 필수입니다.
처음에는 어렵게 느껴질 수 있지만, 타입스크립트 내장 유틸리티 타입 구현을 분석하면서 학습하면 훨씬 빠르게 익힐 수 있습니다.
조건부 타입은 작은 타입 분기처럼 보이지만, 유니언 분산과 infer가 함께 들어오면 결과가 크게 달라집니다.
아래 표는 복잡한 조건부 타입을 읽을 때의 점검 순서를 정리합니다.
복잡한 조건부 타입은 분기, 분산, 추출, 래핑 순서로 나누어 읽으면 결과를 예측하기 쉬워집니다.
- 기본 분기
extends U ? X : Y 에서 참과 거짓 결과를 먼저 분리합니다.
- 유니언 분산
제네릭 T가 유니언이면 각 멤버별로 조건이 적용되는지 확인합니다.
- 내부 추출
infer 는 Promise, 배열, 함수 반환처럼 내부 타입을 꺼낼 때 씁니다.
- 분산 차단
[T] extends [U] 처럼 감싸면 전체 유니언을 한 번에 비교합니다.
- filter유니언에서 제거
Exclude , NonNullable 처럼 조건에 맞는 멤버를 걸러냅니다.
조건부 타입을 직접 작성할 때는 분산이 필요한지, infer로 꺼낸 타입이 어디에서 쓰이는지, never가 의도대로 사라지는지까지 한 번에 확인하는 것이 안전합니다.
조건부 타입은 삼항 연산자처럼 보이지만 유니언 분산, infer, never 처리 때문에 결과가 크게 달라집니다.
- 읽기 단서읽기 단서 T extends U ? X
infer R never
- 분산 여부
T가 naked type parameter이면 유니언의 각 멤버에 조건이 따로 적용됩니다.
- 추론 위치
infer는 함수 반환, 배열 원소, Promise 내부처럼 꺼낼 위치를 정확히 표시합니다.
- 실패 결과
조건을 만족하지 않는 분기는 never나 fallback 타입으로 내려가 호출부 결과를 바꿉니다.
- wrap T
분산을 막아야 하면 [T] extends [U] 형태를 검토합니다.
- extract
infer로 꺼낸 타입이 true 분기 안에서만 쓰이는지 봅니다.
- fallback
false 분기의 타입이 호출부에서 의미 있는지 확인합니다.
- union test
유니언 입력을 넣었을 때 예상 결과가 나오는지 테스트합니다.
다음 다이어그램은 조건부 타입과 제네릭의 결합을 타입 계산 흐름으로 정리한 표입니다.
제네릭 타입 변수에 조건부 타입을 결합하면 배열 원소, 함수 반환값, union 멤버를 호출자가 준 타입에 맞춰 계산할 수 있습니다.
- T를 조건에 넣기
타입 입력 호출자가 넘긴 타입 변수를 extends 조건의 왼쪽에 두고 결과를 나눕니다. T extends Array<any>
- union 처리
분산 조건 T가 union이면 각 멤버별로 조건이 적용되어 결과도 union으로 합쳐집니다. T extends U
- 안쪽 타입 얻기
infer 추출 Promise, Array, Function 같은 감싼 구조에서 필요한 타입을 꺼냅니다. infer Item
- 규칙 캡슐화
고급 유틸리티 반복되는 타입 계산을 별칭으로 묶어 프로젝트 전반에서 재사용합니다. Flatten<T>
이 다이어그램은 조건부 타입과 제네릭 학습을 선언 위치, 타입 좁히기, 재사용 경계 순서로 마무리합니다.
제네릭 타입 변수를 조건부 타입에 넣으면 호출자가 전달한 타입에 따라 더 구체적인 결과 타입을 만들 수 있습니다.
- 타입을 매개변수화
제네릭 입력 T가 어떤 타입으로 들어오는지에 따라 조건부 타입의 분기 결과가 달라집니다. T
- 유니온 멤버 처리
분산 조건 유니온을 받은 제네릭 조건부 타입은 각 멤버를 따로 계산해 조합합니다. T extends
- 내부 타입 추출
infer 추론 배열 원소, Promise 결과, 함수 반환처럼 감싸진 타입을 끄집어냅니다. infer U
- API 타입 변환
실전 조합 응답 래퍼에서 data 타입만 추출하거나 nullable 값을 제거하는 도구를 만들 수 있습니다. Unwrap<T>
아래 다이어그램은 조건부 타입과 제네릭이 함께 쓰일 때 타입 입력이 결과로 변환되는 과정을 보여줍니다.
제네릭 조건부 타입은 입력 타입 T의 형태를 검사해 함수 반환, 배열 요소, nullable 제거 같은 결과 타입을 계산합니다.
- 분산 효과유니온 멤버별 계산
분산 효과 제네릭 조건부 타입은 유니온 입력을 각 멤버로 나누어 처리할 수 있습니다. T extends null ? never : T
- infer 조합안쪽 타입 꺼내기
infer 조합 Promise, 배열, 함수 반환처럼 감싸진 타입을 추론해 이름 붙입니다. T extends Promise<infer R>
- 실전 변환도메인 모델 정리
실전 변환 응답 타입에서 data만 뽑거나 null을 제거하는 규칙을 재사용합니다. Unwrap<T>