TypeScript: 'Type instantiation is excessively deep' 재귀 타입 제한 해제
- 원인: 재귀적으로 자기 자신을 참조하는 제네릭 타입이 TypeScript 컴파일러의 타입 인스턴스화 깊이 제한을 넘어감
- 해결: 급할 때 → 재귀 호출을 조건부 타입의 마지막 반환값으로 바로 두는 꼬리 재귀 형태로 재작성 / 근본적으로 → 중첩 구조 자체를 얕게 리팩토링
- 주의:
// @ts-ignore나any로 눌러 끄면 그 타입이 지켜주던 자동완성·오타 검증이 통째로 사라짐
// Before: 매 단계 결과를 감싸면서 내려가 인스턴스화가 누적됨
type Keys<T> = T extends object
? { [K in keyof T]: `${K & string}.${Keys<T[K]>}` }[keyof T]
: "";
// After: 누산값을 파라미터로 넘기는 꼬리 재귀 형태
type Keys<T, P extends string = ""> = T extends object
? { [K in keyof T]: Keys<T[K], `${P}${K & string}.`> }[keyof T]
: P;
↓ 왜 이런 에러가 나는지 원리 보기
React 컴포넌트의 props나 폼 스키마처럼 중첩된 객체 구조를 다루다 보면, 그 구조의 모든 키 경로를 문자열 리터럴 타입으로 뽑아내거나 DeepPartial<T> 같은 유틸리티 타입을 재귀적으로 만들어야 하는 순간이 옵니다. 그런데 어느 순간 에디터가 멈추는 것처럼 느려지다가 Type instantiation is excessively deep and possibly infinite.라는 빨간 줄이 뜨면, 코드는 분명 멀쩡해 보이는데 뭘 고쳐야 할지 감이 안 잡힙니다.
결론부터 말하면 이건 런타임 버그가 아니라 TypeScript 컴파일러가 타입을 계산하다가 스스로 정한 재귀 한도에 걸린 겁니다. 재귀 호출을 꼬리 재귀 형태로 바꾸거나, 애초에 타입 중첩 구조를 얕게 만들면 해결됩니다.
왜 재귀 타입은 어느 순간 폭발할까요?
TypeScript의 타입 시스템은 조건부 타입과 매핑된 타입을 조합하면 튜링 완전에 가까운 계산을 할 수 있습니다. 문제는 이 계산이 컴파일 타임에, 그것도 무한 루프에 빠지지 않는다는 보장 없이 실행된다는 점입니다. 그래서 컴파일러는 타입 하나를 평가하면서 내부적으로 다른 타입을 몇 번이나 다시 인스턴스화하는지 세다가, 특정 임계치를 넘기면 "더는 못 간다"며 이 에러를 던지고 계산을 중단합니다.
구체적인 임계값은 TypeScript 버전마다 조금씩 달라지는 컴파일러 내부 구현 세부사항이라 특정 숫자에 의존하면 안 되지만, 대략적인 감을 잡자면 일반적인 타입 인스턴스화 깊이 제한은 TS 4.1 기준 50 정도로 알려져 있습니다. 이것과는 별개로, 조건부 타입이 마지막에 자기 자신을 그대로 반환하는 "꼬리 재귀" 형태로 작성돼 있으면 TS 4.5부터 컴파일러가 이를 감지해 훨씬 많은 재귀 단계(최대 1000 안팎)까지 평가를 허용해줍니다. 즉 하나의 제한이 늘어난 게 아니라, 꼬리 재귀 형태에 한해 더 관대한 별도 한도가 적용되는 것에 가깝습니다. 다만 이 최적화가 어떤 재귀 형태까지 적용되는지는 TypeScript 버전마다 이견이 있는 영역이라, 정확한 수치보다는 "재귀 결과를 한 번 더 감싸지 않고 그대로 반환할수록 유리하다" 정도로 이해해두는 게 안전합니다. 아래 세 가지 상황에서 특히 잘 걸립니다.
- 객체의 모든 중첩 키 경로를 문자열 리터럴 유니온으로 뽑아내는
NestedKeyOf<T>같은 유틸리티 타입을 5~6단계 이상 깊은 설정 객체에 적용할 때 - 재귀 호출 결과를 템플릿 리터럴이나 매핑된 타입으로 한 번 더 감싸서 반환하는(=꼬리 재귀가 아닌) 형태로 작성했을 때
- 동일한 제네릭 타입을 서로 다른 타입 인자로 반복 인스턴스화하는 라이브러리(폼 스키마 검증, 상태 관리 셀렉터 등)를 깊게 중첩된 상태 트리에 적용할 때
실제로 겪었던 사례
여러 프로젝트를 옮겨다니며 일하다 보면 재귀 타입을 손댈 일이 생각보다 자주 생깁니다. 몇 해 전 외주로 맡았던 관리자 대시보드 프로젝트에서, 설정 화면 하나가 6~7단계까지 중첩된 큰 객체였고 그 안의 모든 키 경로를 form.setValue("a.b.c.d")처럼 문자열로 타입 안전하게 넘기고 싶어서 NestedKeyOf<T> 유틸 타입을 만들었습니다.
타입 3~4단계까지는 잘 되다가, 실제 설정 객체 타입에 적용하자마자 에디터가 버벅이면서 이 에러가 떴습니다. 처음엔 TypeScript 버전 문제인 줄 알고 node_modules를 지우고 다시 깔았는데 당연히 소용이 없었고, 그다음엔 tsconfig의 skipLibCheck 설정을 의심하며 한참 헤맸습니다. 결국 GitHub 이슈를 뒤지다가 재귀 호출 자체가 문제라는 걸 알았고, 재귀 결과를 템플릿 리터럴로 한 번 더 감싸던 부분을 누산 파라미터를 넘기는 꼬리 재귀 형태로 고치고 나서야 에러가 사라졌습니다.
바로 적용하는 해결법
가장 먼저 확인할 건 "이 타입이 정말 이만큼 깊게 재귀해야 하는가"입니다. 대부분은 원본 데이터 구조 자체가 과도하게 중첩돼 있어서 생기는 문제라, 구조를 얕게 나누는 쪽이 가장 근본적인 해결책입니다. 그게 당장 어렵다면 아래 두 가지 기법으로 재귀 한도를 늘릴 수 있습니다.
기법 1: 꼬리 재귀 형태로 재작성
재귀 호출 결과를 템플릿 리터럴이나 매핑된 타입으로 한 번 더 감싸서 반환하면 TS는 이걸 꼬리 재귀로 인식하지 못합니다. 대신 다음 단계로 넘길 값을 파라미터(아래 예제의 Acc)로 미리 계산해 조건부 타입의 마지막 줄에서 재귀 호출 자체를 그대로 반환해야 최적화 대상이 됩니다.
// 중첩 객체의 모든 키 경로를 "a.b.c" 형태 문자열로 뽑아내는 유틸 타입
type NestedKeyOf<T, Acc extends string = ""> = T extends object
? {
[K in keyof T & string]: T[K] extends object
// 재귀 호출을 감싸지 않고 그대로 반환 (꼬리 재귀)
? NestedKeyOf<T[K], `${Acc}${K}.`>
: `${Acc}${K}`;
}[keyof T & string]
: Acc;
interface FormConfig {
user: { profile: { address: { city: string; zip: string } } };
}
// "user.profile.address.city" | "user.profile.address.zip"
type FormPath = NestedKeyOf<FormConfig>;
기법 2: 재귀 깊이 카운터로 안전장치 걸기
꼬리 재귀로 바꿔도 실제 데이터가 워낙 깊거나 순환 참조 가능성이 있다면, 튜플 길이로 깊이를 세다가 한계에 도달하면 never로 수렴시켜 컴파일러가 무한히 파고들지 않도록 막을 수 있습니다.
type Prev = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
type DeepPartial<T, D extends number = 8> = D extends never
? never // 지정한 깊이를 넘으면 더 파고들지 않고 종료
: T extends object
? { [K in keyof T]?: DeepPartial<T[K], Prev[D]> }
: T;
이 방식은 지정한 깊이 이상은 타입 정확도를 포기하는 트레이드오프가 있어서, 실제로 그 깊이까지 갈 일이 없는 구조에만 안전장치 용도로 씁니다.
기법별 비교
| 기법 | 특징 | 트레이드오프 |
|---|---|---|
| 구조 자체를 얕게 리팩토링 | 재귀 한도 문제를 원천적으로 없앰. 데이터 구조를 바꿀 수 있을 때 가장 확실함 | 기존 구조 변경 비용 발생 |
| 꼬리 재귀 재작성 | TS 4.5+부터 꼬리 재귀 형태에 한해 별도의 더 관대한 한도(수백~1000 안팎)가 적용됨. 타입 정확도는 그대로 유지 | 누산 파라미터 문법이 살짝 낯설어짐 |
| 깊이 카운터로 강제 종료 | 순환 참조 가능성이 있거나 깊이를 예측하기 힘든 구조에 안전장치로 사용 | 지정 깊이를 넘으면 타입 정확도를 포기 |
조건부 타입의 꼬리 재귀 최적화가 처음 도입된 배경과 정확한 동작 조건은 TypeScript 공식 릴리스 노트에서 확인할 수 있습니다.
호마다의 웹개발 팁: 재귀 타입을 매번 이런 식으로 우회하기보다, 애초에 타입이 이렇게 깊어질 필요가 없도록 데이터 구조와 인터페이스를 설계하는 쪽이 근본적인 해결책입니다. 이런 타입 설계 감각을 잡는 데 도움이 되는 글은 [호마다의 IT 개발 입문 블로그] 에서 확인하실 수 있습니다.
댓글
댓글 쓰기