TypeScript infer 키워드로 타입 안에서 타입 추출하기
TypeScript로 타입을 다루다 보면, 어떤 타입 안에 들어 있는 다른 타입만 쏙 꺼내고 싶을 때가 있습니다. “이 배열의 요소 타입이 뭐지?”, “이 Promise가 결국 무슨 타입으로 resolve되지?”, “이 함수가 반환하는 타입만 가져오고 싶은데” 같은 상황이죠. 🤔
이럴 때 쓰는 게 바로 infer 키워드입니다.
타입 안에 숨어 있는 타입에 이름을 붙여 끄집어내는, TypeScript 타입 프로그래밍의 핵심 도구인데요.
이번 글에서는 infer가 어떻게 동작하는지, 그리고 실무에서 어떤 식으로 쓰이는지 예제로 알아보겠습니다.
infer는 조건부 타입 안에서 산다
infer는 단독으로는 못 쓰고, 반드시 조건부 타입(conditional type) 안에서만 등장합니다.
조건부 타입은 T extends U ? X : Y 꼴로, “T가 U에 할당 가능하면 X, 아니면 Y”라는 삼항 연산자의 타입 버전인데요.
infer는 이 extends 오른쪽에서 “여기에 들어올 타입을 변수로 잡아 둬”라고 선언하는 역할을 합니다.
말보다 예제가 빠르겠죠. 배열에서 요소 타입을 꺼내 보겠습니다.
type ElementType<T> = T extends (infer U)[] ? U : never;
type A = ElementType<number[]>; // number
type B = ElementType<string[]>; // string
T extends (infer U)[]는 “T가 어떤 타입 U의 배열이라면”이라는 뜻입니다.
이때 infer U가 그 요소 타입을 U라는 이름으로 붙잡아 두고, 조건이 참이면 그 U를 결과로 내보내는 거죠.
number[]를 넣으면 U가 number로 잡혀서 number가 나옵니다.
Promise 안의 타입 꺼내기
infer가 진가를 발휘하는 대표적인 곳이 Promise입니다.
비동기 함수가 결국 무슨 값으로 resolve되는지, 그 타입만 꺼내고 싶을 때가 많거든요.
type Unwrap<T> = T extends Promise<infer U> ? U : T;
type A = Unwrap<Promise<string>>; // string
type B = Unwrap<number>; // number (Promise가 아니면 그대로)
Promise<infer U>는 “T가 어떤 타입 U를 감싼 Promise라면, 그 U를 꺼내라”는 의미입니다.
Promise가 아닌 타입을 넣으면 조건이 거짓이 되어 입력을 그대로 돌려주고요.
사실 TypeScript에는 이 동작을 하는 Awaited라는 내장 유틸리티 타입이 이미 있습니다.
중첩된 Promise까지 풀어 주는 더 똑똑한 버전인데, 내부 구현이 바로 이 infer로 되어 있죠.
함수의 반환 타입과 매개변수 타입
함수에서 반환 타입이나 매개변수 타입을 꺼내는 것도 infer의 단골 활용처입니다.
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
type A = MyReturnType<() => boolean>; // boolean
(...args: any[]) => infer R는 “T가 어떤 타입 R을 반환하는 함수라면, 그 R을 꺼내라”는 뜻입니다.
매개변수는 any[]로 느슨하게 받아 두고, 반환 자리만 infer R로 잡는 거죠.
매개변수 타입도 같은 방식으로 꺼낼 수 있습니다.
type FirstArg<T> = T extends (first: infer P, ...rest: any[]) => any
? P
: never;
type A = FirstArg<(x: string, y: number) => void>; // string
이 두 패턴은 워낙 자주 쓰여서 TypeScript가 ReturnType과 Parameters라는 내장 유틸리티 타입으로 이미 제공합니다.
직접 만들 일은 많지 않지만, 이들이 어떻게 동작하는지 알아 두면 타입이 꼬였을 때 디버깅이 훨씬 수월해집니다.
infer에 제약 걸기
기본적으로 infer로 잡은 타입은 아무 타입이나 될 수 있는데요.
TypeScript 4.7부터는 infer에 extends로 제약을 걸 수 있게 됐습니다.
type FirstIfString<T> = T extends [infer H extends string, ...any[]]
? H
: "not-string";
type A = FirstIfString<["hello", 1, 2]>; // "hello"
type B = FirstIfString<[1, 2]>; // "not-string" (첫 요소가 string이 아님)
infer H extends string은 “첫 요소를 H로 잡되, 그게 string일 때만”이라는 조건을 더합니다.
제약을 만족하지 않으면 조건이 거짓이 되어 else 가지로 넘어가죠.
예전에는 infer H로 잡은 뒤 H extends string ? ...로 한 번 더 중첩해 검사해야 했던 걸 깔끔하게 줄여 줍니다.
객체 깊숙한 곳에서 꺼내기
infer는 중첩된 구조 안에서도 동작합니다.
객체 안의 특정 속성 타입을 콕 집어 꺼낼 수도 있죠.
type RouterPath<T> = T extends { router: { path: infer P } } ? P : never;
type A = RouterPath<{ router: { path: "/" | "/about" } }>; // "/" | "/about"
어디서 본 패턴 같지 않나요?
라이브러리 저자를 위한 확장 가능한 타입 설계에서 본 Register 레지스트리가 바로 이 방식입니다.
라이브러리가 Register extends { router: infer R } ? R : 기본값으로 사용자가 등록한 타입을 꺼내는 것이었죠.
infer를 이해하고 나면 그 패턴이 어떻게 동작하는지 훨씬 또렷하게 보입니다.
같은 이름으로 여러 번 추론하면
infer로 같은 이름의 변수를 여러 자리에서 잡으면 어떻게 될까요?
재미있게도 그 자리가 어디냐에 따라 결과가 달라집니다.
객체 속성이나 반환 타입처럼 값이 나오는 자리에서 같은 이름으로 여러 번 추론하면, 그 타입들의 유니온(union)이 됩니다.
type Merge<T> = T extends { a: infer U; b: infer U } ? U : never;
type A = Merge<{ a: string; b: number }>; // string | number
반대로 함수 매개변수처럼 값이 들어가는 자리에서 같은 이름으로 추론하면 교차 타입(intersection)이 됩니다.
type ParamType<T> = T extends {
f: (x: infer U) => void;
g: (x: infer U) => void;
}
? U
: never;
type B = ParamType<{ f: (x: { a: 1 }) => void; g: (x: { b: 2 }) => void }>;
// ^? { a: 1 } & { b: 2 }
처음엔 헷갈리지만, “값을 꺼내는 자리는 합집합, 값을 받는 자리는 교집합”이라고 기억해 두면 됩니다.
마치며
지금까지 infer 키워드로 타입 안에 숨은 타입을 꺼내는 법을 살펴봤습니다.
infer는 항상 조건부 타입의 extends 오른쪽에 등장해서, 매칭되는 자리의 타입을 변수로 붙잡아 둡니다.
배열 요소, Promise 결과, 함수의 반환 타입과 매개변수, 객체 속성까지 “이 타입 안의 저 타입”을 꺼내고 싶을 때면 거의 항상 infer가 답이죠.
ReturnType, Parameters, Awaited 같은 내장 유틸리티 타입도 전부 이 infer 위에 서 있고요.
처음엔 조건부 타입과 얽혀 낯설게 느껴지지만, 몇 번 직접 써 보면 타입을 분해하고 조립하는 강력한 도구가 됩니다.
더 자세한 내용은 TypeScript 공식 문서의 Conditional Types 페이지를 참고하세요.
This work is licensed under
CC BY 4.0