조건부 타입
타입 수준의 조건 분기와 분산 규칙을 이해하고 infer로 입력 타입에서 일부 구조를 추출하는 유틸리티를 만듭니다.
타입스크립트 타입 시스템은 단순한 타입 지정에 그치지 않습니다.
프로그래밍 로직처럼 타입 자체에도 조건부 로직을 적용할 수 있습니다.
이 기능이 바로 조건부 타입(Conditional Types)입니다.
특정 타입이 다른 타입에 할당 가능한지에 따라
결과 타입을 다르게 선택하는 문법이며,
자바스크립트 삼항 연산자 condition ? trueExpression : falseExpression와 유사하게 동작합니다.
조건부 타입은 주로 기존의 타입을 기반으로 새로운 타입을 동적으로 생성할 때 사용되며, 제네릭과 함께 사용될 때 더욱 강력한 타입 조작이 가능해집니다.
TYPE / RUNTIME / CONTRACT
조건부 타입은 타입 관계에 따라 결과를 고른다
ToArray의 string | number는 문자열 멤버만 배열로 바뀝니다. 조건의 검사 대상이 타입 매개변수 T 자체이면 유니온 멤버별로 계산합니다.
분배
멤버마다 결과를 계산
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 조건은 분배되며 튜플로 감싼 검사 대상은 전체를 검사한다.
조건부 타입의 기본 문법
조건부 타입은 다음과 같은 형태를 가집니다.
type Result = SomeType extends OtherType ? TrueType : FalseType;SomeType: 검사할 대상 타입extends:SomeType이OtherType에 할당 가능한지 검사하는 키워드 (타입 할당 가능성 검사)OtherType: 비교 대상 타입TrueType:SomeType이OtherType에 할당 가능할 경우 선택될 타입FalseType:SomeType이OtherType에 할당 가능하지 않을 경우 선택될 타입
예시를 통해 이해해봅시다.
// IsString 타입은 T가 string에 할당 가능하면 'yes', 아니면 'no'를 반환합니다.
type IsString<T> = T extends string ? "yes" : "no";
type A = IsString<string>; // type A = "yes"
type B = IsString<number>; // type B = "no"
type C = IsString<any>; // type C = "yes" | "no" (any는 두 분기를 모두 허용)
type D = IsString<unknown>; // type D = "no" (unknown은 string에 할당 불가)
type E = IsString<"hello">; // type E = "yes" ("hello" 리터럴 타입은 string에 할당 가능)
const a: A = "yes";
const b: B = "no";
console.log(a, b); // yes no이처럼 T extends string 조건이 true이면 IsString<T>는 "yes" 타입이 되고, false이면 "no" 타입이 됩니다.
분산 조건부 타입
검사 쪽이 T extends U처럼 감싸지 않은 타입 매개변수이고 T에 유니온을 넣으면 각 멤버에 분배됩니다. [T] extends [U]처럼 감싸면 전체 유니온을 검사합니다.
이를 분산 조건부 타입(Distributive Conditional Types)이라고 부릅니다.
이 특성은 특정 유니온 타입에서 원하는 타입만 추출하거나 제외할 때 매우 유용합니다.
// ToArray는 T가 string이면 T[]를, 아니면 T를 반환합니다.
type ToArray<T> = T extends string ? T[] : T;
type StrArray = ToArray<string>; // type StrArray = string[]
type Num = ToArray<number>; // type Num = number
type Bool = ToArray<boolean>; // type Bool = boolean
// T가 유니온 타입일 때 분산됩니다.
type StringOrNumber = string | number;
type MixedType = ToArray<StringOrNumber>; // type MixedType = string[] | number
// 이는 ToArray<string> | ToArray<number> 와 동일하게 동작합니다.
// 즉, string[] | number가 됩니다.
type StringOrBoolean = string | boolean;
type AnotherMixedType = ToArray<StringOrBoolean>; // type AnotherMixedType = string[] | booleanToArray<StringOrNumber>는 string에는 string[]을, number에는 number를 적용하여 최종적으로 string[] | number가 됩니다.
이 분산 조건부 타입의 특성을 활용하여 타입스크립트의 내장 유틸리티 타입들이 구현되어 있습니다.
같은 입력을 분산하거나 한 번에 검사하기
검사식을 튜플로 감싸면 결과가 어떻게 달라질까요? 바로 위의 ToArray와 StringOrNumber 선언이 있는 같은 .ts 파일에 아래의 새 타입만 이어서 추가합니다. string | number는 유니온 타입, T는 제네릭의 타입 변수에서 복습할 수 있습니다. 기존 TypeScript 프로젝트와 설정을 사용합니다.
type ToArrayWhole<T> = [T] extends [string] ? T[] : T;
type DistributedResult = ToArray<StringOrNumber>;
type WholeResult = ToArrayWhole<StringOrNumber>;먼저 DistributedResult와 WholeResult의 타입을 각각 예상해 보세요. 문자열 배열 ['a'], 숫자 123, 문자열 'a' 중 어느 값을 각 타입의 변수에 넣을 수 있을지도 적어봅니다. 이 예제의 ToArray는 문자열만 배열 타입으로 바꾸고 다른 타입은 그대로 둡니다.
예상 결과와 이유 보기
DistributedResult는 string[] | number입니다. 문자열 멤버에서는 참 분기의 string[], 숫자 멤버에서는 거짓 분기의 number를 얻어 합칩니다.
WholeResult는 string | number입니다. [string | number] 전체는 [string]에 할당할 수 없으므로 거짓 분기의 원래 T를 선택합니다. 이 검사에서는 (string | number)[]가 나오지 않습니다.
const distributedStrings: DistributedResult = ['a'];
const distributedNumber: DistributedResult = 123;
// const distributedString: DistributedResult = 'a'; // 예상 오류: 문자열 하나는 허용하지 않음
const wholeString: WholeResult = 'a';
const wholeNumber: WholeResult = 123;
// const wholeArray: WholeResult = ['a']; // 예상 오류: 이 결과에는 배열 타입이 없음에디터에서 두 결과 타입과 대입을 비교해 보세요. 표시가 예상과 다르면 조건식 왼쪽이 T인지 [T]인지, 참·거짓 분기에 어떤 타입을 썼는지부터 확인합니다. 타입 이름 대신 식으로 표시될 수도 있으므로 허용되는 값도 함께 봅니다. 타입 계산 자체가 실행 중 배열을 만드는 것은 아닙니다.
조건부 타입을 활용한 유틸리티 타입
타입스크립트는 조건부 타입을 사용하여 매우 유용한 내장 유틸리티 타입들 (Built-in Utility Types)을 제공합니다.
Exclude<T, U>: T에서 U에 할당 가능한 타입을 제외합니다. (T에서 U에 포함된 타입을 뺀다)
type NonNullableStringOrNumber = Exclude<string | number | null | undefined, null | undefined>;
// type NonNullableStringOrNumber = string | number
// string, number는 null | undefined에 할당 불가하므로 그대로 유지
// null, undefined는 null | undefined에 할당 가능하므로 제외Exclude<T, U>의 내부 구현은 대략 type Exclude<T, U> = T extends U ? never : T;와 유사하게 작동합니다.
Extract<T, U>: T에서 U에 할당 가능한 타입만 추출합니다. (T와 U의 공통된 타입을 추출한다)
type OnlyStrings = Extract<string | number | boolean, string>;
// type OnlyStrings = string
// string은 string에 할당 가능하므로 추출
// number, boolean은 string에 할당 불가하므로 제외Extract<T, U>의 내부 구현은 대략 type Extract<T, U> = T extends U ? T : never;와 유사하게 작동합니다.
NonNullable<T>: T에서 null과 undefined를 제외합니다.
type PureData = NonNullable<string | number | null | undefined>;
// type PureData = string | number이 예시의 결측 제거는 T extends null | undefined ? never : T라는 조건부 타입으로 설명할 수 있습니다. 다만 현재 표준 라이브러리의 NonNullable<T> 선언은 T & {}이므로 이를 실제 내부 구현과 동일하다고 보지는 않습니다.
이러한 유틸리티 타입들은 복잡한 유니온 타입을 정교하게 다루는 데 필수적입니다.
Exclude와 Extract는 조건부 타입의 분배와 결과에서 never가 사라지는 흐름으로 읽을 수 있습니다. NonNullable도 결측을 제거하지만 표준 구현 방식은 구별합니다.
infer 키워드와 타입 추론
조건부 타입 내에서 infer 키워드를 사용하면, extends 절에서 특정 위치의 타입을 추론(Infer)하여 새로운 타입 변수로 사용할 수 있습니다.
이는 복잡한 타입의 일부를 추출하여 재구성할 때 매우 강력합니다.
TYPE / RUNTIME / CONTRACT
infer는 일치한 타입의 한 부분에 이름을 붙인다
본문의 반환 타입, 매개변수 튜플, Promise 내부 타입 예제는 서로 다른 패턴입니다. 위 그림의 Lookup은 이 절 마지막 연습의 (code: number) => string | undefined를 비교합니다. 패턴과 불일치 결과를 한 쌍으로 읽으세요.
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 한 겹의 내부 타입 또는 원래 타입을 얻는다.
// ReturnType<T>는 함수 타입 T의 반환 타입을 추론합니다.
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : any;
type FuncA = () => string;
type FuncB = (num: number, str: string) => boolean;
type FuncC = () => void;
type ReturnOfA = MyReturnType<FuncA>; // type ReturnOfA = string
type ReturnOfB = MyReturnType<FuncB>; // type ReturnOfB = boolean
type ReturnOfC = MyReturnType<FuncC>; // type ReturnOfC = void
type ReturnOfNonFunc = MyReturnType<number>; // type ReturnOfNonFunc = any
// Parameters<T>는 함수 타입 T의 매개변수 타입을 튜플로 추론합니다.
type MyParameters<T> = T extends (...args: infer P) => any ? P : never;
type ParamsOfA = MyParameters<FuncA>; // type ParamsOfA = []
type ParamsOfB = MyParameters<FuncB>; // type ParamsOfB = [num: number, str: string]
type ParamsOfC = MyParameters<FuncC>; // type ParamsOfC = []
type ParamsOfNonFunc = MyParameters<number>; // type ParamsOfNonFunc = neverT extends (...args: any[]) => infer R ? R : any 구문을 분석해봅시다.
T가 함수 타입인지 검사합니다.T extends (...args: any[]) => any.- 만약
T가 함수 타입이라면, 해당 함수의 반환 타입을infer R을 통해R이라는 새로운 타입 변수로 추론합니다. - 그리고 이 추론된
R타입을 최종 결과로 반환합니다. - 함수 타입이 아니라면
any를 반환합니다.
infer 키워드를 활용한 타입 조작은 타입을 입력받아 새로운 타입을 만드는 타입을 위한 함수에 비유할 수 있습니다.
인덱스 접근과 조건부 타입으로 값 타입 추출하기
첫 예제의 GetPropertyValue는 조건 분기가 없는 T[K] 인덱스 접근입니다. 특정 키의 값 타입을 읽는 원리는 인덱스 접근 타입에서 이어집니다. 그다음 UnwrapPromise는 조건부 타입과 infer로 Promise 패턴의 내부 타입을 추출합니다.
interface UserProfile {
id: number;
name: string;
email: string;
age: number;
}
// GetPropertyValue는 T의 키 K에 해당하는 값의 타입을 반환합니다.
type GetPropertyValue<T, K extends keyof T> = T[K];
type UserIdType = GetPropertyValue<UserProfile, 'id'>; // type UserIdType = number
type UserNameType = GetPropertyValue<UserProfile, 'name'>; // type UserNameType = string// UnwrapPromise는 Promise<T>에서 T를 추출합니다.
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
type ValueFromPromise = UnwrapPromise<Promise<string>>; // type ValueFromPromise = string
type DirectValue = UnwrapPromise<number>; // type DirectValue = number조건부 타입은 타입스크립트의 타입 시스템을 유연하고 표현력 있게 만들어 주는 고급 기능입니다.
이를 통해 기존 타입을 기반으로 복잡한 로직을 가진 새로운 타입을 동적으로 생성할 수 있습니다.
특히 제네릭, 분산 조건부 타입, infer 키워드와 함께 사용하면 거의 모든 타입 조작 시나리오를 다룰 수 있을 정도로 강력해집니다.
조건부 타입은 분기 기준, 분산 여부, 추론 위치를 분리해서 보면 훨씬 읽기 쉽습니다.
처음에는 어렵게 느껴질 수 있지만, 타입스크립트 내장 유틸리티 타입 구현을 분석해 보면 유용성을 빠르게 체감할 수 있습니다.
추론 위치와 불일치 결과를 직접 계산하기
기본 문법의 SomeType 등은 자리를 설명하는 이름입니다. 독립 실행 파일에서는 실제 타입을 선언해야 합니다. 아래 연습은 위에서 정의한 MyReturnType, MyParameters, UnwrapPromise를 같은 파일에서 사용합니다. 컴파일러의 strict 설정을 켜고, 타입이 어떤 값의 대입을 허용하는지 확인해 보세요.
type Lookup = (code: number) => string | undefined;
type LookupResult = MyReturnType<Lookup>;
type LookupArguments = MyParameters<Lookup>;
type OneLayer = UnwrapPromise<Promise<Promise<number>>>;LookupResult와 LookupArguments를 먼저 적습니다. 결과 변수에 "찾음", undefined, 4를 넣을 수 있는지, 인자 튜플에 [4], ["4"], []를 넣을 수 있는지 판단해 보세요. OneLayer는 number일까요, Promise<number>일까요?
패턴별 결과 확인
반환 위치에서 R을 읽으므로 LookupResult는 string | undefined입니다. "찾음"과 undefined는 가능하고 4는 거부됩니다. 인자 위치의 P는 [code: number]이므로 [4]는 가능하지만 문자열 원소와 빈 튜플은 거부됩니다. 튜플의 길이와 각 원소의 타입을 함께 검사합니다.
OneLayer는 Promise<number>입니다. UnwrapPromise는 일치한 바깥 Promise에서 U 한 겹만 읽습니다. 다음 유틸리티 타입의 Awaited는 재귀적으로 해석하므로 같은 타입 입력에서 number를 얻습니다. 타입 계산은 실제 Promise를 기다리는 실행 동작이 아닙니다.
본문의 MyReturnType<number>는 불일치 분기의 any입니다. 함수가 아닌 입력을 거부하는 장치는 아니며, 표준 ReturnType<number>는 함수 타입 제약 때문에 진단됩니다. 자체 유틸리티와 표준 유틸리티는 이름이나 일부 결과가 비슷해도 허용 입력과 불일치 규칙까지 같다고 가정하지 않습니다.
공식 조건부 타입 설명의 infer 절에서 추론 위치를, 표준 유틸리티 설명에서 함수 입력 제약을 대조할 수 있습니다. 위 독립 연습의 함수 타입은 이 교재의 연습용 예시입니다.