- Published on
TypeScript 5.5 infer로 타입 폭발 줄이는 법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
대규모 TypeScript 코드베이스에서 가장 먼저 체감되는 병목은 런타임이 아니라 타입 체커인 경우가 많습니다. 특히 조건부 타입과 재귀 타입을 조합해 유틸리티 타입을 만들다 보면, 타입이 눈덩이처럼 커지며(일명 타입 폭발) 다음 문제가 연쇄적으로 발생합니다.
tsc시간이 길어짐- IDE에서 자동완성/점프가 느려짐
- 에러 메시지가 지나치게 길어져 디버깅이 어려움
Type instantiation is excessively deep and possibly infinite같은 경고가 빈번
TypeScript 5.5는 이런 상황에서 체감 개선이 가능한 변경들이 있고, 그중 핵심 도구가 infer를 이용한 중간 결과 캐싱(메모이제이션)과 분기 축소입니다. 이 글에서는 “왜 폭발하는지”를 짚고, infer로 폭발을 줄이는 패턴을 코드 중심으로 정리합니다.
참고: TS 5.5에서
undefined관련 진단을 줄이는 팁은 별도 글로 정리해 두었습니다. TS 5.5에서 Object is possibly undefined 줄이기
타입 폭발이 생기는 전형적인 구조
타입 폭발은 보통 아래 조합에서 발생합니다.
- 조건부 타입이 유니온에 대해 분배(distributive)되며 경우의 수가 급증
- 각 분기에서 다시 재귀 또는 매핑 타입이 수행
- 중간 결과가 재사용되지 않고 계속 “다시 계산”됨
예를 들어, “객체 타입을 깊게 펼쳐서(path) 모든 키 경로를 유니온으로 만드는 타입”은 대표적인 폭발 제조기입니다.
type Join<K, P> = K extends string | number
? P extends string | number
? `${K}.${P}`
: never
: never;
type Paths<T> = T extends object
? {
[K in keyof T]-?: K extends string | number
? T[K] extends object
? K | Join<K, Paths<T[K]>>
: K
: never;
}[keyof T]
: never;
이 타입은 작을 땐 유용하지만, 실제 스키마가 커지거나 유니온이 섞이면 Paths가 분배되면서 중간 단계가 기하급수로 커질 수 있습니다.
infer로 폭발을 줄이는 핵심 아이디어: “중간 결과를 변수로 잡아라”
infer는 조건부 타입 내부에서 타입을 추출하는 기능으로 알려져 있지만, 실무에서는 다음 역할이 더 중요합니다.
- 중간 계산 결과를 한 번만 평가하고, 이후 분기에서 재사용
- 분배 조건을 제어하기 위해 “한 번 감싸서” 유니온 분배를 늦춤
- 복잡한 조건식을 여러 번 반복하지 않고,
infer로 뽑아 단순화
패턴 1) 분배를 늦추기: T extends any ? ... : ...를 피하고 infer로 고정
조건부 타입은 T가 유니온이면 기본적으로 분배됩니다.
type Dist<T> = T extends string ? "S" : "N";
// Dist<"a" | 1> -> "S" | "N"
분배가 필요 없거나, “한 번에” 판단하고 싶다면 튜플로 감싸 분배를 막는 방식이 흔합니다.
type NoDist<T> = [T] extends [string] ? "S" : "N";
// NoDist<"a" | 1> -> "N"
여기서 infer를 섞으면, 분배는 막되 내부에서는 필요한 형태로만 추출해 계산량을 줄일 수 있습니다.
type NormalizeToString<T> = [T] extends [infer U]
? U extends string
? U
: never
: never;
이 패턴은 “유니온 전체를 한 번에 다루되, 내부에서 필요한 경우에만 좁히기”에 유용합니다.
패턴 2) 중간 타입 캐싱: T extends infer U ? ...U... : never
타입 폭발의 큰 원인은 같은 표현식이 여러 번 평가되는 것입니다. infer로 중간 결과를 U로 잡아두면, 복잡한 표현을 반복하지 않게 만들 수 있습니다.
type Simplify<T> = { [K in keyof T]: T[K] } & {};
type DeepSimplify<T> = T extends object
? Simplify<{ [K in keyof T]: DeepSimplify<T[K]> }>
: T;
// 캐싱 버전
type DeepSimplifyCached<T> = T extends infer U
? U extends object
? Simplify<{ [K in keyof U]: DeepSimplifyCached<U[K]> }>
: U
: never;
겉보기엔 큰 차이가 없어 보이지만, 실전에서는 T 자리에 복잡한 조건부 타입/교차 타입이 들어오는 순간 차이가 납니다. infer U로 “한 번 확정한 U”를 기준으로 반복 연산을 수행하면, 타입 체커가 불필요하게 동일 표현을 재평가하는 경우를 줄일 수 있습니다.
infer로 재귀 유틸리티 타입을 안전하게 만들기
재귀 타입은 깊이가 커질수록 폭발 가능성이 높습니다. 이때 infer로 재귀에 들어가기 전 형태를 정규화하거나, 유니온을 조기에 줄이는 필터를 넣는 것이 효과적입니다.
패턴 3) 재귀 진입 전에 “객체만 남기기”를 infer로 고정
아래는 Paths 같은 타입에서 흔히 하는 최적화입니다.
- 재귀로 들어갈 때
object인지 검사 - 그런데
T[K]가 유니온이면 분배가 일어나며 경우의 수가 늘어남
이를 줄이기 위해 infer V로 T[K]를 한 번 잡고, 그 다음에만 조건을 적용합니다.
type Join<K, P> = K extends string | number
? P extends string | number
? `${K}.${P}`
: never
: never;
type PathsOptimized<T> = T extends object
? {
[K in keyof T]-?: T[K] extends infer V
? K extends string | number
? V extends object
? K | Join<K, PathsOptimized<V>>
: K
: never
: never;
}[keyof T]
: never;
포인트는 T[K] extends infer V ? ...로 각 프로퍼티 타입을 한 번만 꺼내서 이후 분기에서 재사용한다는 점입니다.
패턴 4) “유니온을 객체로 바꾸는 연산”은 infer로 쪼개기
유니온을 교차로 바꾸는 UnionToIntersection 류는 강력하지만, 대형 유니온에서 비용이 큽니다.
type UnionToIntersection<U> = (
U extends any ? (x: U) => void : never
) extends (x: infer I) => void
? I
: never;
이 타입을 여러 번 호출하면 폭발이 빨라집니다. 해결은 단순합니다.
- 교차 변환을 한 번만 수행
- 결과를
infer로 고정해 이후 로직에서 재사용
type MergeUnion<U> = UnionToIntersection<U> extends infer I
? { [K in keyof I]: I[K] }
: never;
type Example = MergeUnion<{ a: 1 } | { b: 2 } | { c: 3 }>;
// { a: 1; b: 2; c: 3 }
UnionToIntersection 같은 고비용 타입을 파이프라인 중간중간 호출하지 말고, 한 번만 계산해서 infer I로 붙잡아두는 것이 중요합니다.
타입 폭발을 줄이는 설계 규칙 5가지
infer는 만능이 아니고, “어떤 지점에서 폭발이 시작되는지”를 같이 봐야 합니다. 아래 규칙을 함께 적용하면 효과가 커집니다.
1) 분배가 필요 없는 조건부 타입은 튜플로 감싸기
- 유니온 분배가 시작되면 대부분 비용이 급증합니다.
- 분배가 꼭 필요한 곳에서만 분배시키고, 나머지는
[T] extends [...]로 막습니다.
2) 고비용 타입은 한 번만 계산하고 infer로 고정
UnionToIntersection- 깊은
Simplify/Prettify - 대형 매핑 타입
이런 것들은 “중간 결과 타입”을 만들어 재사용하세요.
type Prettify<T> = { [K in keyof T]: T[K] } & {};
type Build<T> = T extends infer U ? Prettify<U> : never;
3) 재귀는 “진입 조건”을 먼저 좁히고 들어가기
T extends object같은 큰 조건만으로 재귀를 돌리면 폭발합니다.- 실제로 처리할 타입만 남기도록,
infer로 꺼낸 다음 필터링하세요.
4) 에러 메시지/IDE 성능을 위해 “표면 타입”을 단순화
교차 타입이 계속 쌓이면 IDE가 특히 느려집니다. 마지막에만 Prettify하고, 중간에는 가능한 한 그대로 두는 편이 낫습니다.
5) satisfies와 조합해 “추론은 살리고, 검증만 하라”
실무에서 타입 폭발은 “추론을 지나치게 강제”할 때도 생깁니다. 객체 리터럴을 강하게 단언(assert)하기보다 satisfies로 검증만 하고 추론은 유지하는 접근이 좋습니다.
실전 예제: 라우트 정의에서 타입 폭발 줄이기
라우트 테이블을 만들 때 흔히 하는 패턴입니다.
- 라우트 목록에서
path유니온을 만들고 - 각 path에 대응하는 params 타입을 뽑아
navigate(path, params)를 타입 안전하게 만들기
잘못 만들면 유니온 분배와 매핑이 겹쳐 폭발합니다.
폭발하기 쉬운 버전
type Route =
| { path: "/users"; params: {} }
| { path: "/users/:id"; params: { id: string } }
| { path: "/posts/:pid"; params: { pid: number } };
type ParamsOf<P> = Route extends { path: P; params: infer R } ? R : never;
declare function navigate<P extends Route["path"]>(path: P, params: ParamsOf<P>): void;
ParamsOf는 Route 유니온에 대해 분배되며, 프로젝트가 커질수록 비용이 커집니다.
infer로 캐싱하는 버전
핵심은 라우트 유니온을 “한 번” 정규화한 뒤, 그 결과를 기반으로 조회하는 것입니다.
type Route =
| { path: "/users"; params: {} }
| { path: "/users/:id"; params: { id: string } }
| { path: "/posts/:pid"; params: { pid: number } };
type RouteTable<R> = R extends infer U
? U extends { path: infer P; params: infer S }
? P extends string
? { path: P; params: S }
: never
: never
: never;
type RT = RouteTable<Route>;
type ParamsOf<P> = Extract<RT, { path: P }> extends infer X
? X extends { params: infer S }
? S
: never
: never;
declare function navigate<P extends RT["path"]>(path: P, params: ParamsOf<P>): void;
navigate("/users/:id", { id: "abc" });
// navigate("/users/:id", { id: 123 }); // 타입 에러
여기서의 최적화 포인트는 다음과 같습니다.
RouteTable에서infer U로 유니온을 한 번 정규화- 조회는
Extract로 단순화하고, 결과에서params만infer로 뽑음 - 분배 로직을 여러 군데 흩뿌리지 않고 “한 군데”에 모음
대형 라우트/이벤트 테이블에서 이 구조가 특히 효과적입니다.
디버깅 팁: 어디서 폭발하는지 찾는 방법
타입 폭발은 “어떤 타입이 문제인지”부터 찾는 게 어렵습니다. 아래 순서로 접근하면 빠릅니다.
- 의심 구간의 유틸리티 타입을 임시로
unknown또는 얕은 타입으로 바꿔 빌드 시간을 비교 - 폭발 지점이 좁혀지면, 그 타입의 중간 결과를 별칭으로 분리
- 중간 결과를
infer로 고정하거나, 분배를 막는 튜플 래핑을 적용 - 마지막에만
Prettify로 표면 타입을 정리
또한 TS 설정에서 --extendedDiagnostics를 켜면 타입 체크 시간이 어디서 많이 쓰이는지 힌트를 얻을 수 있습니다.
npx tsc --noEmit --extendedDiagnostics
정리
TypeScript 5.5 환경에서 infer는 단순히 “타입을 뽑아내는 문법”을 넘어, 타입 체커의 계산량을 줄이는 설계 도구로 쓰는 게 핵심입니다.
- 유니온 분배를 필요 이상으로 일으키지 않기
- 고비용 타입 연산은 한 번만 계산하고
infer로 고정하기 - 재귀는 진입 전에 좁히고, 중간 결과를 캐싱하기
이 세 가지만 습관화해도 tsc 속도와 IDE 체감이 확 달라집니다. 특히 라우트/이벤트/스키마처럼 “유니온이 계속 커지는 도메인”에서는 초기에 infer 기반 구조로 잡아두는 것이 장기적으로 가장 큰 비용 절감으로 돌아옵니다.