라이브러리나 동료의 코드를 읽다 보면 type Result = ReturnType<typeof handler> 같은 줄을 만납니다. 결과 타입이 무엇인지는 짐작할 수 있어도, ReturnType이 함수 타입에서 반환 타입만 뽑아내는 내부 동작은 코드만 봐서는 설명하기 어렵습니다.
ReturnType은 조건부 타입과 infer라는 두 문법의 조합으로 동작합니다. 이 중 조건부 타입은 제네릭 글의 마지막 groupBy 예제에도 이미 나왔습니다. 그 예제에서 반환 타입을 T[K] extends PropertyKey ? Record<T[K], T[]> : never라고 적었는데, 이 ? :가 바로 조건부 타입입니다.
이번 글에서는 ReturnType, Parameters, Awaited 같은 내장 유틸리티 타입을 직접 만들어 보며 조건부 타입과 infer가 어떻게 동작하는지, 그리고 이들이 유니온을 만났을 때 벌어지는 분배 조건부 타입까지 정리합니다.
조건부 타입의 문법
조건부 타입은 삼항 연산자와 같은 형태로 씁니다. 먼저 값에 쓰는 삼항 연산자입니다.
// 값 세계: 조건이 참이면 앞, 거짓이면 뒤를 돌려준다.
const label = age >= 20 ? '성인' : '미성년';**조건부 타입(Conditional Type)**은 이 조건 ? 참 : 거짓 구조를 타입에 그대로 가져옵니다. 다른 점은 조건 자리에 extends가 들어간다는 것뿐입니다.
// 타입 세계: T가 string이면 true, 아니면 false라는 '타입'을 돌려준다.
type IsString<T> = T extends string ? true : false;
type A = IsString<'hello'>; // A: true
type B = IsString<42>; // B: falseIsString은 타입을 입력받아 다른 타입을 돌려주는 작은 함수처럼 동작합니다. 'hello'를 넣으면 true 타입이, 42를 넣으면 false 타입이 나옵니다. 읽는 법은 삼항 연산자와 같고, 결과가 타입이라는 점만 다릅니다.
앞서 나온 groupBy의 반환 타입도 같은 방식으로 읽을 수 있습니다. "T[K]가 객체 키가 될 수 있는 타입이면 Record<...>를, 아니면 never를 돌려줘라"는 조건 분기입니다.
extends와 할당 가능성
조건부 타입의 extends는 클래스 상속과 관계가 없습니다. T extends U는 T 값을 U 자리에 넣을 수 있는지, 즉 T가 U의 부분집합인지를 검사합니다.
타입을 값의 집합으로 보는 관점을 가져오면 방향이 분명해집니다. 'a'는 "문자열 a 하나만 담긴 집합"이고, string은 "모든 문자열의 집합"입니다. 작은 집합은 큰 집합에 속하지만 그 반대는 아닙니다.
// '더 좁은' 리터럴은 '더 넓은' string에 할당할 수 있다 → 참.
type C = 'a' extends string ? 'yes' : 'no'; // C: 'yes'
// 반대로 '넓은' string을 '좁은' 'a' 자리에는 넣을 수 없다 → 거짓.
type D = string extends 'a' ? 'yes' : 'no'; // D: 'no'extends를 상속처럼 "string이 더 크니까 참이겠지"라고 읽으면 방향을 정반대로 이해하게 됩니다. 조건부 타입의 extends는 좁은 타입 → 넓은 타입 방향일 때만 참입니다.
여기서의
extends는 제네릭 제약의extends(예:<T extends { length: number }>)와 키워드만 같을 뿐 쓰임이 다릅니다. 제약의extends는 "타입 파라미터가 이 조건을 만족해야 한다"는 입력 검사이고, 조건부 타입의extends는 "이 조건이 참이냐"를 물어 분기를 결정합니다. 문맥으로 구분하면 됩니다.
infer로 타입 꺼내기
조건부 타입은 infer와 함께 쓸 때 쓰임이 넓어집니다. infer는 extends 오른쪽 패턴의 한 위치에 타입 변수를 선언하고, 그 위치에 매칭된 타입을 꺼내는 키워드입니다.
배열에서 요소 타입을 꺼내는 예제로 확인해 보겠습니다.
// T가 "무언가의 배열"이면, 그 무언가(요소 타입)를 E로 뽑아 돌려준다.
type ElementType<T> = T extends (infer E)[] ? E : T;
type E1 = ElementType<string[]>; // E1: string (string[]의 요소 → string)
type E2 = ElementType<number>; // E2: number (배열이 아니면 그대로 T)T extends (infer E)[]는 "T가 어떤 배열 모양에 들어맞는가"를 물으면서, 동시에 그 배열의 요소 타입을 E라는 이름으로 받습니다. string[]을 넣으면 패턴이 매칭되고 E는 string으로 채워집니다. number처럼 배열이 아니면 매칭에 실패해 거짓 분기로 갑니다.
infer에는 두 가지 규칙만 기억하면 됩니다.
- infer는
extends의 오른쪽 패턴 안에서만 쓸 수 있습니다. - infer로 뽑은 변수는 참(true) 분기에서만 사용할 수 있습니다. 매칭에 성공했을 때만 꺼낼 것이 생기기 때문입니다.
infer를 두는 위치를 바꾸면 다른 위치의 타입을 꺼낼 수 있습니다. 튜플의 첫 요소를 꺼내려면 infer를 첫 요소 자리에 둡니다.
// 튜플의 첫 요소 자리에 infer H를 두어 그 타입을 꺼낸다. 나머지 요소는 ...unknown[]로 받는다.
type First<T> = T extends [infer H, ...unknown[]] ? H : never;
type F1 = First<[1, 2, 3]>; // F1: 1
type F2 = First<[]>; // F2: never (빈 튜플엔 첫 요소가 없다)다음 절의 유틸리티 타입도 같은 방식으로 infer의 위치를 정해 만듭니다.
내장 유틸리티 타입을 직접 만들어 보기
이제 조건부 타입과 infer를 조합해 ReturnType을 직접 만들어 보겠습니다. ReturnType과 Parameters는 함수 타입에서 infer를 두는 위치만 다르고, Awaited는 Promise 안의 타입을 재귀로 꺼냅니다.
ReturnType: 반환 위치에서 꺼내기
함수 타입에서 반환 타입만 꺼내려면 반환 위치에 infer를 둡니다.
// T가 "어떤 인자든 받아 무언가를 반환하는 함수"에 들어맞으면,
// 그 반환 위치의 타입을 R로 뽑아 돌려준다.
type MyReturnType<T extends (...args: any[]) => any> =
T extends (...args: any[]) => infer R ? R : never;
function createUser() {
return { id: 1, name: 'claude' };
}
// 반환 위치만 R로 뽑혔다.
type User = MyReturnType<typeof createUser>; // User: { id: number; name: string }인자 자리는 (...args: any[])로 모든 경우를 받고, 반환 자리에만 infer R을 두었습니다. 그래서 createUser의 반환값 타입인 { id: number; name: string }만 정확히 뽑힙니다. typeof createUser는 함수 "값"에서 함수 "타입"을 얻는 문법입니다.
이렇게 만든 MyReturnType은 표준 라이브러리의 ReturnType과 동일하게 동작합니다.
// 직접 만든 것과 내장 유틸리티의 결과가 같다.
type Std = ReturnType<typeof createUser>; // Std: { id: number; name: string }참고로 실제
lib.es5.d.ts의 정의는 거짓 분기가never가 아니라any입니다(... ? R : any). 다만 타입 파라미터가T extends (...args: any) => any로 이미 "함수"임을 강제받기 때문에, 거짓 분기에 닿을 일은 사실상 없습니다.never든any든 유효한 입력에서는 결과가 같습니다.
Parameters: 인자 위치에서 꺼내기
Parameters는 ReturnType과 반대로 인자 위치에 infer를 둡니다. 인자는 여러 개일 수 있으므로, 뽑아내는 결과는 튜플 전체입니다.
// 인자 목록 전체를 P라는 튜플로 뽑는다.
type MyParameters<T extends (...args: any[]) => any> =
T extends (...args: infer P) => any ? P : never;
function greet(name: string, age: number): void {}
// 인자 목록이 튜플로 뽑혔다.
type GreetParams = MyParameters<typeof greet>; // GreetParams: [name: string, age: number]반환 위치에 infer R을 두면 ReturnType, 인자 위치에 infer P를 두면 Parameters. infer의 위치만 다르고 나머지 구조는 같습니다.
같은
infer이름을 여러 위치에 두면 어떻게 될까요? 함수 인자 자리는 반공변(contravariant) 위치라, 같은 이름으로 여러 개를 캡처하면 유니온이 아니라 교집합으로 합쳐집니다. 예를 들어(a: infer U, b: infer U) => void에(a: string, b: number) => void를 넣으면U는string & number, 즉never가 됩니다. 다른 라이브러리의 타입 유틸리티에서 예상하지 못한never가 나올 때 이 규칙이 원인인 경우가 많습니다.
Awaited: 재귀 조건부 타입
마지막은 조금 더 까다로운 Awaited입니다. await는 Promise를 풀어 안의 값을 꺼내는데, 그 타입 버전이 Awaited입니다. 단순하게 구현하면 이렇습니다.
// Promise 안의 값을 한 번 꺼낸다.
type NaiveAwaited<T> = T extends Promise<infer V> ? V : T;
type N1 = NaiveAwaited<Promise<string>>; // N1: string (여기까진 잘 된다)
type N2 = NaiveAwaited<Promise<Promise<number>>>; // N2: Promise<number> (한 겹만 벗겨졌다!)문제는 Promise<Promise<number>>처럼 중첩된 경우입니다. infer V는 Promise를 한 단계만 풀기 때문에 안쪽에 Promise<number>가 그대로 남습니다. 반면 실제 await는 값이 또 Promise면 계속 풀어서 끝까지 평탄화합니다.
중첩된 Promise를 끝까지 풀려면 재귀 조건부 타입을 씁니다. 한 단계 푼 결과를 자기 자신에게 다시 넘깁니다.
// 한 단계 푼 V가 또 Promise일 수 있으니, 자기 자신을 다시 호출해 끝까지 푼다.
type MyAwaited<T> = T extends Promise<infer V> ? MyAwaited<V> : T;
type R1 = MyAwaited<Promise<string>>; // R1: string
type R2 = MyAwaited<Promise<Promise<number>>>; // R2: number (재귀로 두 겹 다 벗겼다)
type R3 = MyAwaited<boolean>; // R3: boolean (Promise가 아니면 그대로)참 분기의 MyAwaited<V>가 재귀를 일으킵니다. Promise를 한 단계 풀어 V를 얻으면, V가 또 Promise인지 검사하려고 자기 자신을 다시 호출합니다. Promise가 아닌 타입이 나오면 거짓 분기로 끝납니다.
실제 표준
Awaited는 더 넓은 경우를 다룹니다.Promise뿐 아니라then메서드를 가진 객체(thenable) 전반을 다루기 위해,Promise<infer V>대신then을 가진 객체인지 확인한 뒤 그then이 넘겨주는 값을 꺼내 다시 재귀적으로 푸는 형태로 되어 있습니다. 패턴이 한 겹 더 복잡할 뿐, 원리(패턴 매칭 + infer + 재귀)는 우리가 만든 것과 같습니다.
컴파일러로 동일성 검증하기
직접 만든 타입이 내장 타입과 같은지는 눈으로 보는 것만으로는 확인하기 어렵습니다. 두 타입이 정확히 같은지는 컴파일러로 확인할 수 있습니다.
// 두 타입이 완전히 동일할 때만 true가 되는 헬퍼.
type Equal<A, B> =
(<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
// T가 true가 아니면 이 줄 자체가 컴파일 에러가 난다.
type Expect<T extends true> = T;
// MyReturnType과 표준 ReturnType이 같은 타입임을 컴파일 타임에 단언한다.
type _t1 = Expect<Equal<MyReturnType<typeof createUser>, ReturnType<typeof createUser>>>;
// MyParameters도 표준과 동일.
type _t2 = Expect<Equal<MyParameters<typeof greet>, Parameters<typeof greet>>>;Expect<Equal<...>>는 두 타입이 다르면 컴파일 자체가 실패합니다. 위 코드가 오류 없이 통과한다는 것은 곧, 우리가 만든 유틸리티가 내장과 동일하다는 것을 컴파일러가 보장해 준다는 뜻입니다. 이 글의 예제들은 모두 이 방식으로 검증했습니다.
분배 조건부 타입과 유니온
지금까지는 단일 타입만 넣었습니다. 조건부 타입에 유니온을 넣으면 값 세계의 삼항 연산자에는 없는 동작이 나타납니다. 다음 결과를 예상해 봅시다.
// T를 배열로 감싸는 단순한 조건부 타입.
type ToArray<T> = T extends unknown ? T[] : never;
// string | number를 넣으면 결과는?
type Arr = ToArray<string | number>;(string | number)[]를 예상하기 쉽지만, 실제 결과는 이렇습니다.
type Arr = ToArray<string | number>; // Arr: string[] | number[] ← 통짜 배열이 아니다!유니온이 멤버별로 쪼개져 각각 조건부 타입을 통과한 뒤, 결과가 다시 유니온으로 합쳐집니다. 즉 ToArray<string> | ToArray<number> = string[] | number[]가 됩니다. 이것이 **분배 조건부 타입(Distributive Conditional Type)**입니다. 배열의 map처럼 유니온의 각 멤버에 조건부 타입을 적용하고, 결과를 다시 유니온으로 모읍니다.
분배는 조건부 타입의 왼쪽에 타입 파라미터 T가 감싸지지 않은 채(naked type parameter) 놓이고, 그 자리에 유니온이 들어올 때 일어납니다. T를 다른 타입으로 감싸면 분배가 일어나지 않습니다. 이 성질은 뒤에서 씁니다.
Exclude를 직접 만들어 보기
분배를 활용하는 대표적인 예가 Exclude입니다. 유니온에서 특정 멤버를 빼는 유틸리티이고, 분배와 never의 성질로 세 줄 만에 만들 수 있습니다.
// 각 멤버가 U에 할당 가능하면 never로 지우고, 아니면 그대로 남긴다.
type MyExclude<T, U> = T extends U ? never : T;
type Colors = 'red' | 'green' | 'blue';
type NotRed = MyExclude<Colors, 'red'>; // NotRed: 'green' | 'blue'동작을 한 멤버씩 풀어 보면 이렇습니다. 유니온의 각 멤버가 분배되어 개별적으로 검사됩니다.
'red' extends 'red'→ 참 →never(지워짐)'green' extends 'red'→ 거짓 →'green'(남음)'blue' extends 'red'→ 거짓 →'blue'(남음)
결과를 다시 유니온으로 합치면 never | 'green' | 'blue'인데, 유니온에서 never는 흡수되어 사라지므로 'green' | 'blue'만 남습니다. 이 MyExclude는 내장 Exclude와 완전히 같습니다. 방향을 뒤집어 "매칭되는 것만 남기면" 그대로 Extract가 됩니다.
// 참 분기와 거짓 분기를 뒤집으면 Extract가 된다.
type MyExtract<T, U> = T extends U ? T : never;
type OnlyRed = MyExtract<Colors, 'red' | 'yellow'>; // OnlyRed: 'red'never와 분배 끄기
never를 naked type parameter 자리에 넣으면 참·거짓 어느 분기도 선택되지 않고 결과가 never가 됩니다. 자주 놓치는 동작입니다.
// never는 '멤버가 0개인 유니온'이다. 분배할 멤버가 없으니 결과도 0개 → never.
type A = ToArray<never>; // A: never (never[]가 아니다!)never는 값의 집합으로 보면 빈 집합입니다. 분배는 유니온 멤버마다 조건을 적용하는데 멤버가 하나도 없으므로 결과도 never입니다. 그래서 never인지 판별하는 조건부 타입을 그대로 쓰면 참 분기로 가지 않습니다.
// never는 분배할 멤버가 없어서 어느 분기도 평가되지 않고, 결과가 never가 된다.
type IsNeverWrong<T> = T extends never ? 'yes' : 'no';
type W = IsNeverWrong<never>; // W: never ('yes'도 'no'도 아니다!)해결책이 앞에서 예고한 분배 끄기입니다. 타입 파라미터를 [T]처럼 튜플로 한 겹 감싸면, T가 더 이상 naked type parameter가 아니게 되어 분배가 꺼집니다. 그러면 유니온(과 never)을 하나의 타입으로 판정합니다.
// [T]로 감싸 분배를 끈다. 이제 never도 하나의 타입으로 비교된다.
type IsNever<T> = [T] extends [never] ? 'yes' : 'no';
type OK = IsNever<never>; // OK: 'yes' (정상 감지된다)같은 입력이라도 분배 여부에 따라 결과가 확연히 달라지는 것을 표로 정리하면 이렇습니다.
| 입력 | 분배 켜짐 T extends U | 분배 꺼짐 [T] extends [U] |
|---|---|---|
ToArray<string | number> | string[] | number[] | (string | number)[] |
never 감지 | never (감지 실패) | 'yes' (감지 성공) |
boolean에서도 비슷한 점을 주의해야 합니다.boolean은 내부적으로true | false유니온이라,T extends true ? 'Y' : 'N'에boolean을 넣으면 한쪽이 아니라'Y' | 'N'양쪽이 모두 나옵니다. 정확히true하나만 걸러내려면 마찬가지로[T] extends [true]로 분배를 꺼야 합니다.
정리
이번 글에서는 조건부 타입과 infer로 내장 유틸리티 타입을 직접 만들어 보며, 타입이 어떻게 "계산"되는지 살펴봤습니다.
| 개념 | 한 줄 요약 |
|---|---|
조건부 타입 T extends U ? X : Y | 삼항 연산자 형태의 타입 분기. extends는 할당 가능한지를 검사한다 |
infer | extends 패턴 안에서 매칭된 위치의 타입을 변수로 꺼낸다 |
ReturnType · Parameters | 함수 타입의 반환/인자 위치에 infer를 심어 뽑아낸다 |
Awaited | 재귀 조건부 타입으로 중첩 Promise를 끝까지 푼다 |
| 분배 조건부 타입 | naked type parameter에 유니온이 오면 멤버별로 적용 후 다시 합친다 |
[T] extends [U] | 튜플로 감싸 분배를 끄고, 유니온을 하나의 타입으로 판정한다 |
- 조건부 타입의
extends는 상속이 아니라 좁은 타입에서 넓은 타입 방향의 할당 가능성 판정입니다. - infer는 두는 위치에 따라 반환·인자·요소·
Promise안의 타입을 꺼냅니다. - 유니온은 naked type parameter를 통해 들어올 때만 분배되며,
never는 빈 유니온이라 분배하면 결과가never입니다. - 분배가 방해가 될 땐
[T] extends [U]로 끄고, 필요할 땐 그대로 활용합니다.
마치며
조건부 타입과 infer를 알면 ReturnType<typeof fn> 같은 코드를 함수 타입의 반환 위치에서 타입을 꺼내는 조건부 타입으로 읽을 수 있습니다. 라이브러리의 타입 정의도 같은 방식으로 읽을 수 있습니다.
타입 좁히기 글에서는 값에 if와 typeof를 써서 타입을 좁혔습니다. 조건부 타입은 같은 분기를 타입 수준에서 합니다. extends로 분기하고 infer로 타입을 꺼냅니다. 긴 글 읽어주셔서 감사합니다.