- Published on
TS 5.5+ inferred type 너무 커짐 오류 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 라이브러리 타입이 얽히거나, 과도하게 정교한 제네릭/리터럴 타입을 한 번에 추론시키면 TS 5.5+에서 The inferred type of this node is too large to represent 류의 오류가 종종 터집니다. 특히 as const로 고정한 대형 객체, zod/trpc/react-hook-form/tanstack 계열의 조합, 그리고 “한 방에 모든 걸 추론”하는 패턴이 겹치면 타입 체커가 내부적으로 생성하는 유니온/교차 타입이 기하급수적으로 커집니다.
이 글에서는 오류가 나는 이유를 감각적으로 이해하고, 실제 코드에서 “추론 폭발”을 줄이는 방법을 단계별로 정리합니다.
- 관련 이슈로 함께 자주 등장하는
isolatedDeclarations대응은 별도 글에 정리해두었습니다: TS 5.5+ isolatedDeclarations 에러 실전 해결법
오류 메시지와 원인 요약
대표적으로 아래 같은 메시지가 보입니다.
The inferred type of 'X' cannot be named without a reference to '...'. This is likely not portable. A type annotation is necessary.The inferred type of this node is too large to represent.
첫 번째는 “추론된 타입이 외부 모듈의 복잡한 타입 조합에 의존해서 이름 붙이기 어렵다”에 가깝고, 두 번째는 “추론 결과가 너무 커서 타입 시스템이 표현/표시하기 어렵다”에 가깝습니다. 둘 다 해결 방향은 비슷합니다.
핵심 원인은 다음 중 하나(혹은 조합)입니다.
- 대형 리터럴 객체 +
as const - 조건부 타입, 매핑 타입, 분산 조건부 타입이 중첩
- 제네릭이 여러 단계로 전파되며 교차 타입이 누적
satisfies/infer/ReturnType/Parameters로 한 번에 끝까지 추론- 라이브러리 타입(특히 스키마 기반)에서 생성되는 거대한 타입을 그대로 노출
재현 예시: as const + 대형 맵에서 타입 폭발
아래처럼 라우트 테이블이나 권한 테이블을 as const로 고정하면, 키/값이 모두 리터럴로 굳어지고 그 유니온이 커집니다.
// routes.ts
export const routes = {
home: { path: "/", auth: false },
login: { path: "/login", auth: false },
dashboard: { path: "/dashboard", auth: true },
// ... 수십~수백개가 되면 추론 타입이 매우 커짐
} as const;
export const routeList = Object.values(routes);
// 여기서 routeList의 추론 타입이 거대해질 수 있음
Object.values는 값들의 유니온을 만들고, 값이 리터럴로 고정되어 있으면 그 유니온이 엄청 커집니다.
해결 1: “리터럴 고정” 범위를 줄이기
정말 필요한 필드만 리터럴로 유지하고, 나머지는 일반 타입으로 넓히면 효과가 큽니다.
type RouteDef = {
path: string;
auth: boolean;
};
export const routes = {
home: { path: "/", auth: false },
login: { path: "/login", auth: false },
dashboard: { path: "/dashboard", auth: true },
} satisfies Record<string, RouteDef>;
export const routeList: RouteDef[] = Object.values(routes);
포인트는 두 가지입니다.
satisfies로 형태 검증은 하되 타입을 과도하게 좁히지 않기routeList에 명시적 타입 주석을 달아 추론을 끊기
해결 2: “추론을 끊는” 타입 주석을 의도적으로 배치
타입 폭발은 보통 “한 줄에서 너무 많은 걸 추론”할 때 발생합니다. 중간 변수에 타입을 달아 추론을 잘라내면 안정적입니다.
const values = Object.values(routes) as RouteDef[];
export const routeList = values;
as를 남발하자는 뜻이 아니라, “라이브러리/유틸이 생성하는 거대한 리터럴 유니온을 외부로 새지 않게” 하는 방어막으로 쓰자는 의미입니다.
실전에서 가장 많이 터지는 패턴: 스키마 기반 타입의 과도한 노출
zod 같은 스키마에서 z.infer를 깊게 쓰고, 그 결과를 다시 제네릭으로 전파시키면 타입이 커집니다.
import { z } from "zod";
const UserSchema = z.object({
id: z.string(),
email: z.string().email(),
roles: z.array(z.enum(["ADMIN", "USER", "GUEST"])),
});
type User = z.infer<typeof UserSchema>;
// 문제: 아래처럼 더 큰 조합을 계속 만들면 타입이 폭발할 수 있음
type ApiResponse<T> = { ok: true; data: T } | { ok: false; error: string };
type UserResponse = ApiResponse<User>;
이 자체는 괜찮지만, 실제 앱에서는 UserSchema가 훨씬 크고, pick/omit/merge/discriminatedUnion 등으로 스키마가 조합되며 타입이 비대해집니다.
해결 1: 외부로 노출하는 타입은 “단순한 DTO”로 한 번 더 감싸기
스키마에서 추론한 타입을 그대로 퍼뜨리지 말고, 외부 경계에서 단순화된 타입으로 변환합니다.
type UserDTO = {
id: string;
email: string;
roles: ("ADMIN" | "USER" | "GUEST")[];
};
// 내부에서는 User를 써도 되지만, 모듈 경계(export)에서는 DTO를 쓰기
export type UserResponse =
| { ok: true; data: UserDTO }
| { ok: false; error: string };
이 방식은 타입 안정성을 일부 포기하는 게 아니라, “표현 비용이 큰 타입을 경계에서 정리”하는 전략입니다. 런타임 검증은 zod가 이미 해주기 때문에, 컴파일러에 과도한 부담을 주지 않는 선에서 타입을 설계할 수 있습니다.
해결 2: type-fest의 Simplify 같은 유틸을 직접 구현해 평탄화
교차 타입이 누적되면 표시/추론이 무거워집니다. 얕은 평탄화만으로도 효과가 있습니다.
type Simplify<T> = { [K in keyof T]: T[K] } & {};
type A = { a: string };
type B = { b: number };
type AB = Simplify<A & B>; // 표현이 단순해짐
주의할 점은, Simplify가 모든 상황에서 타입 크기를 “근본적으로” 줄이진 못합니다. 하지만 교차 타입이 길게 이어지는 코드에서는 체감이 큽니다.
“한 줄에 모든 걸” 하는 코드를 쪼개기
다음은 실제로 타입 체커가 힘들어하는 형태입니다.
export const makeClient = (cfg: Config) =>
createClient(cfg)
.withAuth()
.withCache()
.withRoutes(routes)
.withMiddlewares([
mw1(cfg),
mw2(cfg),
// ...
]);
체이닝이 길어질수록 각 단계의 반환 타입이 누적되어 거대한 제네릭이 됩니다.
해결: 단계별로 타입을 고정하거나, 반환 타입을 명시
type Client = {
request: (path: string, init?: RequestInit) => Promise<unknown>;
};
export function makeClient(cfg: Config): Client {
const base = createClient(cfg);
const authed = base.withAuth();
const cached = authed.withCache();
const routed = cached.withRoutes(routes);
const client = routed.withMiddlewares([mw1(cfg), mw2(cfg)]);
// 최종적으로 외부에 노출할 표면적을 좁혀 타입 크기를 제한
return client;
}
여기서 중요한 건 “실제 client가 가진 모든 세부 타입”을 외부로 노출하지 않는 것입니다. 외부에 필요한 최소 인터페이스만 공개하면 inferred type이 커질 여지를 크게 줄입니다.
라이브러리 경계에서 interface와 명시적 export 타입을 사용
TS는 type alias로 복잡한 조합을 계속 만들기 쉽습니다. 모듈 경계에서는 interface가 더 유리할 때가 많습니다(표현이 단순하고, 확장이 쉽고, 표시도 덜 복잡해지는 경향).
interface PublicStore {
getState(): unknown;
setState(next: unknown): void;
}
export const store: PublicStore = createVeryComplexStore();
특히 Next.js 앱에서 상태/렌더링 최적화와 맞물려 타입이 커지는 경우가 많습니다. 상태 라이브러리 조합으로 렌더링이 폭발하는 문제를 다룬 글도 함께 참고하면 설계 방향을 잡는 데 도움이 됩니다.
const assertion을 “전체 객체”가 아니라 “키”에만 적용
라우팅/이벤트/액션 타입을 만들 때, 전체 객체를 as const로 고정하면 값 리터럴까지 모두 굳어 타입이 커집니다. 키만 리터럴로 유지하면 대부분의 목적을 달성합니다.
const ACTIONS = [
"USER_LOGIN",
"USER_LOGOUT",
"USER_REFRESH",
// ...
] as const;
type ActionType = (typeof ACTIONS)[number];
type ActionPayload = {
type: ActionType;
// payload는 넓게
payload?: unknown;
};
반대로 아래처럼 “값까지” 전부 리터럴로 고정하면 payload/metadata가 커질수록 타입 비용이 증가합니다.
// 이런 형태는 규모가 커지면 위험
export const actions = {
USER_LOGIN: { audit: true, retry: 2 },
USER_LOGOUT: { audit: false, retry: 0 },
// ...
} as const;
TS 설정으로 해결할 수 있나
결론부터 말하면, 이 오류는 대부분 “설정으로 무시”하기보다 “타입 설계를 조정”하는 게 정답입니다. 그래도 현실적인 체크리스트는 있습니다.
1) declaration을 켠 프로젝트라면 export 표면을 줄이기
.d.ts를 생성하는 프로젝트(라이브러리, 모노레포 패키지 등)에서는 추론된 타입을 이름 붙여서 내보내야 하므로 문제가 더 잘 드러납니다. 이때는 다음이 효과적입니다.
- export 하는 값/함수에 반환 타입 명시
- export 하는 객체에 명시적 인터페이스 부여
- 내부 타입은 복잡해도 되지만, export 타입은 단순 DTO로 제한
2) isolatedDeclarations를 켰다면 더 엄격해진다
TS 5.5+에서 isolatedDeclarations를 켜면 “추론에 기대어 export”하는 패턴이 더 쉽게 깨집니다. inferred type too large와 같은 결로 같이 터지기도 합니다.
- 실전 대응은 다음 글에서 자세히 다뤘습니다: TS 5.5+ isolatedDeclarations 에러 실전 해결법
디버깅 팁: 어디서 타입이 폭발하는지 찾는 방법
- 문제가 되는 export를 찾는다
- 보통
export const something = ...또는export function ...에서 터집니다.
- 보통
- 중간 변수를 도입해 추론을 분리한다
- 체이닝을 끊고 각 단계에 타입을 달아보면, 어느 단계에서 커지는지 감이 옵니다.
- 리터럴 고정을 줄인다
as const를 제거하거나,satisfies로 형태만 체크하고 타입은 넓힌다.
- 외부로 내보내는 타입을 단순화한다
interface/DTO/명시적 반환 타입으로 경계를 만든다.
- 유니온 개수를 줄인다
- “N개의 케이스를 모두 리터럴로 추론”하는 구조(대형 테이블, 라우트, 이벤트 목록)를 재검토한다.
종합 처방전: 가장 효과 좋은 6가지 패턴
1) export 되는 함수는 반환 타입을 명시
export function buildQuery(input: Input): string {
// 내부 구현이 아무리 복잡해도 외부 타입은 단순
return JSON.stringify(input);
}
2) export 되는 객체는 인터페이스로 표면적 제한
interface PublicApi {
run(): Promise<void>;
}
export const api: PublicApi = createMegaComplexApi();
3) satisfies로 형태만 검증하고 타입 과도 축소 방지
type FeatureFlag = { enabled: boolean; rollout: number };
export const flags = {
newNav: { enabled: true, rollout: 100 },
expSearch: { enabled: false, rollout: 0 },
} satisfies Record<string, FeatureFlag>;
4) 대형 리터럴 유니온이 필요한 경우 “키 목록”으로만 유지
export const PERMISSIONS = ["READ", "WRITE", "ADMIN"] as const;
export type Permission = (typeof PERMISSIONS)[number];
5) 교차 타입 누적 시 얕은 평탄화
type Simplify<T> = { [K in keyof T]: T[K] } & {};
type Combined = Simplify<A & B & C>;
6) 라이브러리 타입은 경계에서 DTO로 변환
type InternalUser = {
id: string;
// ... 실제로는 훨씬 복잡
};
export type UserDTO = {
id: string;
};
export function toUserDTO(u: InternalUser): UserDTO {
return { id: u.id };
}
마무리
TS 5.5+의 inferred type too large 문제는 “타입이 너무 정교해서 생기는 성능/표현 한계”에 가깝습니다. 해결책은 컴파일러를 설득하는 게 아니라, 타입의 경계를 설계하는 쪽에 있습니다.
- 리터럴 추론을 필요한 곳에만 제한하고
- export 경계에서 반환 타입/인터페이스/DTO로 타입을 정리하고
- 체이닝과 제네릭 전파를 중간에서 끊어
타입 폭발을 구조적으로 막으면, 빌드 안정성과 IDE 성능(자동완성/Go to definition)까지 같이 좋아집니다.