조건문과 비슷한 컨디셔널 타입이 있다
TypeScript에는 타입을 조건에 따라 유연하게 지정할 수 있는 컨디셔널 타입(Conditional Type) 문법이 존재한다. 마치 자바스크립트의 삼항 연산자처럼 동작하지만, 타입 레벨에서 조건 분기를 처리할 수 있어 타입 설계에 유용하다.
기본 문법
type A1 = string;
type B1 = A1 extends string ? number : boolean; // number
type A2 = number;
type B2 = A2 extends string ? number : boolean; // boolean
여기서 extends는 A2가 string 타입에 할당될 수 있는가? 를 검사한다. 할당 가능하다면 참(true), 아니면 거짓(false)이 되어 삼항 연산자에 따라 타입이 결정 된다.
명시적인 extends가 없어도 OK
interface X { x: number }
interface XY { x: number; y: number }
type A = XY extends X ? string : number; // string
구조적 타이핑 덕분에 XY는 X를 명시적으로 extends하지 않았지만, 구조적으로 X에 할당 가능하므로 조건이 true가 되어 A는 string 타입이 된다.
타입 검사로 활용
type Result1 = 'hi' extends string ? true : false; // true
type Result2 = [1] extends [string] ? true : false; // false
리터럴 값이 특정 타입에 해당하는지를 검사하는 데도 쓸 수 있다.
never와 함께 쓰기
type Start = string | number;
type New = Start extends string | number ? Start[] : never; // string[] | number[]
이건 그냥 Start[]로 해도 되지만, 실제로는 제네릭 타입과 함께 쓸 때 never가 의미를 가진. 단순한 extends 조건문에서 never을 쓰는 것보다, 제네릭과 결합했을 때 never가 의도한 동작을 정확히 만들어낼 수 있는 도구가 된다는 뜻이다.
type ChooseArray<A> = A extends string ? string[] : never;
type StringArray = ChooseArray<string>; // string[]
type Never = ChooseArray<number>; // never
매핑된 타입과 함께 쓰기
type OmitByType<O, T> = {
[K in keyof O as O[K] extends T ? never : K]: O[K];
};
type Result = OmitByType<{
name: string;
age: number;
married: boolean;
rich: boolean;
}, boolean>;
// { name: string; age: number }
OmitByType은 객체에서 특정 타입의 속성을 제거해 준다. 조건이 true일 때 키를 never로 만들어 제거하는 원리이다.
중첩된 컨디셔널 타입
type ChooseArray<A> =
A extends string ? string[] :
A extends boolean ? boolean[] :
never;
type Result1 = ChooseArray<string>; // string[]
type Result2 = ChooseArray<boolean>; // boolean[]
type Result3 = ChooseArray<number>; // never
삼항 연산자처럼 중첩해서 사용도 가능하다.
인덱스 타입 접근 방식으로도 가능
type A1 = string;
type B2 = {
t: number;
f: boolean;
}[A1 extends string ? 't' : 'f']; // number
조건 분기의 결과가 문자열일 경우, 이를 객체의 키로 활용하여 타입을 지정할 수도 있다.
분배 법칙(Distributive Conditional Types)
type Result<T> = T extends string ? T[] : never;
type R = Result<string | number>; // string[]
유니언 타입과 컨디셔널 타입이 만나면 분배법칙이 적용되어 Result<string> | Result<number>로 평가 된다. 즉, 각각 분기한 결과를 다시 합치는 것이다.
type R<T> = T extends string ? true : false;
type RR = R<'hi' | 3>; // true | false → boolean
예상과 달리 boolean이 된 이유는 분배법칙 때문이다. 이를 방지하려면 배열로 감싸 분배법칙을 차단할 수 있다.
type IsString<T> = [T] extends [string] ? true : false;
type R = IsString<'hi' | 3>; // false
never는 특이 케이스
type R<T> = T extends string ? true : false;
type RR = R<never>; // never
never는 분배법칙의 결과가 아무 것도 없기 때문에 결과도 never가 된다.
type IsNever<T> = [T] extends [never] ? true : false;
type T1 = IsNever<never>; // true
type T2 = IsNever<'never'>; // false
실전 주의점
function test<T>(a: T) {
type R<T> = T extends string ? T : T;
const b: R<T> = a; // ❌ Type 'T' is not assignable to type 'R<T>'
}
TypeScript는 제네릭이 들어간 컨디셔널 타입을 미루어 판단한다. 이로 인해 a의 타입이 R<T>에 맞지 않는다는 오류가 생길 수 있다. 이럴 때도 배열로 감싸면 해결할 수 있다.
function test<T extends ([T] extends [string] ? string : never)>(a: T) {
type R<T> = [T] extends [string] ? T : T;
const b: R<T> = a; // ✅ OK
}
TypeScript의 컨디셔널 타입은 복잡한 타입 로직을 간결하고 안전하게 표현할 수 있다.특히 extends, never, 유니언 타입, 제네릭과 조합했을 때 다양한 활용이 가능하기 때문에 타입 설계에서 고려하자.