- Published on
TypeScript 5.5 isolatedDeclarations 오류 해결법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 빌드 파이프라인(예: tsc --emitDeclarationOnly, bundler의 타입 추출, 프로젝트 레퍼런스)에서 선언 파일(.d.ts)을 안정적으로 만들기 위해 TypeScript 5.5는 isolatedDeclarations 옵션을 강화했습니다. 문제는 이 옵션을 켜는 순간, 기존 코드가 “잘 돌아가던 런타임 코드”였더라도 선언 생성 관점에서 모호한 타입/값 경계 때문에 컴파일 오류가 쏟아질 수 있다는 점입니다.
이 글에서는 isolatedDeclarations가 정확히 무엇을 요구하는지, 대표적인 오류 패턴과 해결책(코드 수정 중심), 그리고 도입 전략까지 한 번에 정리합니다.
> 참고: 운영 환경에서 문제를 “재시도/백오프/큐잉”으로 흡수하듯이, 타입 시스템에서도 불확실성을 줄이는 규칙이 필요합니다. 레이트리밋 대응 패턴이 궁금하다면 OpenAI 429/Rate Limit 대응 - 재시도·백오프·큐잉도 함께 보시면 접근 방식이 비슷합니다.
isolatedDeclarations란 무엇이고 왜 5.5에서 더 자주 터지나
isolatedDeclarations는 “각 소스 파일을 독립적으로 선언 파일로 변환할 수 있어야 한다”는 제약을 강하게 거는 옵션입니다. 즉, 선언 생성 시에 다음과 같은 암묵적 정보에 기대면 안 됩니다.
- 다른 파일의 구현/초기화 코드에 의존해 타입이 결정되는 경우
- 값(value)로 존재하는지 타입(type)으로만 존재하는지 경계가 흐린 경우
export default { ... }같은 객체 리터럴에서 추론된 복잡한 타입을 그대로 외부 API로 노출하는 경우
TypeScript 5.5에서는 이 흐름을 더 엄격하게 체크하여, 선언 파일로 내보낼(public) API가 명확히 타입으로 표현 가능해야만 통과시키는 방향으로 개선되었습니다.
언제 켜지나
보통 아래 중 하나에서 켜집니다.
- 라이브러리 패키지에서
declaration: true+emitDeclarationOnly를 사용 composite: true(프로젝트 레퍼런스) 기반 모노레포- bundler가 타입 추출을 위해 내부적으로 유사한 제약을 요구
권장되는 설정 예시는 다음과 같습니다.
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"declaration": true,
"emitDeclarationOnly": true,
"declarationMap": true,
"isolatedDeclarations": true,
"stripInternal": true,
"verbatimModuleSyntax": true
}
}
증상: 자주 보는 오류 유형 6가지
프로젝트마다 메시지는 조금씩 다르지만, 본질은 “이 파일만 보고 .d.ts를 만들 수 없다”입니다.
1) export하는 값의 타입이 구현 추론에 과도하게 의존
문제 코드
// config.ts
export const config = {
retry: 3,
backoffMs: 200,
headers: {
"x-app": "demo"
}
};
이 자체는 간단해 보이지만, 실제로는 다른 곳에서 config.headers를 확장하거나, 조건부로 속성이 추가되는 패턴이 섞이면 선언 생성이 불안정해집니다. 특히 객체 리터럴 추론이 외부 API로 그대로 노출될 때 문제가 자주 납니다.
해결: 외부로 노출되는 타입을 명시적으로 고정
export interface Config {
retry: number;
backoffMs: number;
headers: Record<string, string>;
}
export const config: Config = {
retry: 3,
backoffMs: 200,
headers: {
"x-app": "demo"
}
};
또는 satisfies로 “검증은 하되 타입은 고정하지 않기”도 유용합니다.
export interface Config {
retry: number;
backoffMs: number;
headers: Record<string, string>;
}
export const config = {
retry: 3,
backoffMs: 200,
headers: {
"x-app": "demo"
}
} satisfies Config;
: Config는config의 타입이Config로 고정satisfies Config는 타입 체크만 하고config의 구체 타입(리터럴 타입 등)은 유지
라이브러리 API로는 보통 : Config가 더 예측 가능하고, 내부 상수에는 satisfies가 편합니다.
2) 타입/값 경계 충돌: enum/namespace/typeof 패턴
문제 코드(값을 타입처럼 내보내는 형태)
export const Status = {
Ready: "READY",
Busy: "BUSY"
};
export type Status = keyof typeof Status;
이 패턴은 흔하지만, isolatedDeclarations에서는 “동일한 이름이 타입/값으로 공존”하는 구조가 선언 생성에서 애매해질 수 있습니다(특히 re-export가 섞이면 더).
해결 1: 이름 분리
export const StatusValues = {
Ready: "READY",
Busy: "BUSY"
} as const;
export type Status = keyof typeof StatusValues;
해결 2: union을 직접 export(외부 API 단순화)
export type Status = "READY" | "BUSY";
외부에 “키”가 필요한 게 아니라 “값”이 필요한 것이라면 union이 가장 단순합니다.
3) default export 객체에 함수/클래스/복잡한 제네릭이 섞임
문제 코드
export default {
create(id: string) {
return { id, createdAt: new Date() };
}
};
이런 default export 객체는 시간이 지나면서 메서드가 늘고, 반환 타입이 복잡해지고, 내부 타입 alias가 섞이며 선언 생성 문제가 커집니다.
해결: named export + 명시적 타입
export interface Entity {
id: string;
createdAt: Date;
}
export function create(id: string): Entity {
return { id, createdAt: new Date() };
}
라이브러리/SDK 형태라면 named export가 트리쉐이킹/타입 추적 모두에서 유리합니다.
4) export된 함수의 반환 타입이 내부 구현 추론에 의존
문제 코드
const cache = new Map<string, { value: unknown; ts: number }>();
export function get(key: string) {
return cache.get(key);
}
반환 타입이 Map의 제네릭에 의해 추론되긴 하지만, 선언 생성 시에는 “외부 API로 노출할 타입이 명확한가?”가 중요합니다.
해결: 반환 타입을 명시
export interface CacheEntry {
value: unknown;
ts: number;
}
export function get(key: string): CacheEntry | undefined {
return cache.get(key);
}
5) re-export 바렐(barrel)에서 type-only export 누락
TypeScript 5.x에서 verbatimModuleSyntax를 같이 쓰는 경우가 많고, 이때 export { Foo } from "./foo"가 값 export로 처리되어 런타임 import가 생기거나, 반대로 타입이 사라지는 문제가 생깁니다. isolatedDeclarations는 이런 모호함을 싫어합니다.
문제 코드
// index.ts
export { User } from "./user";
User가 타입이면 export type으로 분리해야 합니다.
해결
export type { User } from "./user";
export { createUser } from "./user";
이 습관은 선언 생성뿐 아니라 번들 크기/사이드이펙트 측면에서도 이득입니다.
6) 클래스/함수의 private 필드가 외부 타입에 새어 나감
외부로 export되는 타입이 내부 구현(특히 private/protected 멤버)에 의존하면, .d.ts에서 표현이 제한되어 오류가 날 수 있습니다.
해결: 외부에 노출할 인터페이스를 따로 두고 반환 타입을 인터페이스로 제한
class InternalClient {
#token: string;
constructor(token: string) {
this.#token = token;
}
request(path: string) {
return { path, ok: true as const };
}
}
export interface Client {
request(path: string): { path: string; ok: true };
}
export function createClient(token: string): Client {
return new InternalClient(token);
}
이 패턴은 API 안정성(구현 교체 가능)도 함께 얻습니다.
실전 디버깅 루틴: “어디가 public API인지”부터 좁히기
isolatedDeclarations 오류는 보통 “내보낸(export) 심볼”에서 시작합니다. 아래 순서로 좁히면 빠릅니다.
- 오류가 난 파일에서 export 목록 확인:
export/export default/re-export(barrel) - export된 심볼의 타입이 명시되어 있는지 확인
- 타입이 명시되어도 깨지면, 그 타입이 참조하는 다른 타입이
- 값과 섞이지 않았는지
- private 구현을 참조하지 않는지
- 조건부/복잡한 추론 결과를 그대로 노출하지 않는지
- 바렐 파일에서는
export type { ... }와export { ... }를 엄격히 분리
규모가 큰 모노레포라면, 이런 “타입-경계 정리”는 캐시/재검증 문제가 생길 때 원인 범위를 좁히는 방식과 비슷합니다. Next.js 캐시 꼬임을 추적하는 접근이 궁금하면 Next.js App Router 캐시 꼬임·재검증 버그 해결도 참고할 만합니다.
권장 리팩터링 패턴(라이브러리/SDK 기준)
1) 외부 API 타입은 interface/type alias로 먼저 “고정”
- 함수 반환 타입, 클래스 public 메서드 시그니처, export const의 타입을 명시
- “추론 결과를 API로 노출”하지 않기
export type RetryPolicy = {
retries: number;
backoffMs: number;
};
export function buildPolicy(input: Partial<RetryPolicy>): RetryPolicy {
return {
retries: input.retries ?? 3,
backoffMs: input.backoffMs ?? 200
};
}
2) export default 지양, named export 우선
- 타입 추적이 단순해지고, 바렐 re-export도 쉬워집니다.
3) type-only export/import를 습관화
import type { User } from "./user";
export type { User };
export { createUser } from "./user";
4) 내부 구현은 stripInternal + /** @internal */로 숨기기
공개 API를 줄이면 isolatedDeclarations가 검사해야 할 표면적이 줄어듭니다.
/** @internal */
export type InternalShape = { debug: boolean };
(단, stripInternal이 켜져 있어야 .d.ts에서 제거됩니다.)
“일단 끄면 되지 않나?”에 대한 현실적인 답
단기적으로는 가능합니다. 하지만 isolatedDeclarations를 끄는 선택은 보통 아래 비용으로 돌아옵니다.
- 배포 시점에만 터지는 타입 추출/선언 생성 문제
- 패키지 소비자(다른 앱/서비스)에서 타입이 깨지거나,
any로 누락 - 번들러/빌드툴 변경 시(특히 ESM 전환) 문제 재발
즉, 이 옵션은 귀찮은 규제가 아니라 라이브러리 품질을 강제하는 안전장치에 가깝습니다. 운영에서 레이트리밋을 “무시”하면 결국 장애로 돌아오듯, 타입 선언의 모호함도 결국 소비자 쪽 장애(개발 경험 악화)로 돌아옵니다.
체크리스트: isolatedDeclarations 통과를 위한 최소 규칙
- export되는 함수/클래스/상수는 명시적 타입을 가진다
- 바렐 파일은
export type과export를 분리한다 - 동일 식별자에 타입/값을 겹치게 두지 않는다(불가피하면 이름 분리)
- default export 객체 리터럴로 “API 묶음”을 만들지 않는다
- 외부로 노출되는 타입이 private 구현을 참조하지 않는다
- 가능한 한 외부 API는 단순한 타입(alias/interface/union)으로 표현한다
마무리
TypeScript 5.5의 isolatedDeclarations 오류는 “타입이 틀렸다”기보다는 “선언 파일로 안정적으로 내보낼 수 없는 API 모양”을 발견했다는 신호인 경우가 많습니다. 해결의 핵심은 한 가지입니다.
- 추론에 기대지 말고, 외부로 나가는 타입을 설계(명시)하라
이 원칙대로 public API의 타입을 고정하고, type-only export/import를 정리하고, default export를 줄이면 대부분의 오류는 자연스럽게 사라집니다.