- Published on
TS 5.5+ isolatedDeclarations 에러 실전 해결법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 빌드 파이프라인(예: tsc로 d.ts 생성 + 번들러로 JS 빌드)을 운영하다 보면, TS 5.5+에서 isolatedDeclarations를 켠 순간 기존에 조용히 지나가던 코드가 대거 깨지는 경험을 하게 됩니다. 특히 라이브러리/SDK 형태로 배포하거나, declaration: true로 타입 선언을 내보내는 프로젝트는 영향이 큽니다.
isolatedDeclarations는 “각 파일을 독립적으로 타입 선언(.d.ts)로 변환할 수 있어야 한다”는 제약을 강제합니다. 즉, 구현 파일(.ts)의 다른 파일 정보에 의존하지 않고도, 해당 파일만 보고 정확한 선언을 만들 수 있어야 합니다. 이 제약은 번들러의 isolatedModules와 결이 비슷하지만 목표가 더 명확합니다: 선언 생성의 안정성.
이 글에서는 TS 5.5+의 isolatedDeclarations 에러가 왜 생기는지, 어떤 코드 패턴이 문제인지, 그리고 팀 코드베이스에서 “최소 수정”으로 해결하는 실전 패턴을 정리합니다.
관련해서 TypeScript 5.x의 타입 오류를 조기 차단하는 패턴은 아래 글도 함께 보면 도움이 됩니다.
isolatedDeclarations란 무엇이고 왜 TS 5.5+에서 체감이 커졌나
isolatedDeclarations는 tsconfig.json의 컴파일 옵션입니다.
- 목적:
.d.ts생성 시, 각 소스 파일이 독립적으로 선언을 만들 수 있도록 제한 - 효과: 선언 생성 과정에서 “암묵적 추론(특히 다른 파일/구현에 기대는 추론)”을 줄이고, 공개 API 타입을 더 명시적으로 만들게 함
일반적으로 다음과 같이 켭니다.
{
"compilerOptions": {
"declaration": true,
"emitDeclarationOnly": true,
"isolatedDeclarations": true,
"stripInternal": true,
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler"
}
}
언제 켜야 하나
- 라이브러리 배포(사내 패키지/SDK 포함)에서
.d.ts품질이 중요할 때 - 빌드 시스템이 “타입은 tsc, 번들은 다른 도구(esbuild/rollup/vite)”로 분리되어 있을 때
- 코드베이스가 커서, 선언 생성이 특정 파일의 구현/순서에 흔들리는 것을 막고 싶을 때
반대로, 앱 단일 번들만 만들고 .d.ts 배포가 목적이 아니라면 굳이 강제하지 않아도 됩니다.
에러가 나는 핵심 원리: “공개 타입은 선언으로 복원 가능해야 한다”
isolatedDeclarations가 까다롭게 구는 지점은 대부분 export 되는 심볼입니다.
export const x = ...에서x의 타입이 파일 내부 구현에 의해 추론되는데, 그 추론이 선언 파일에서 재현 불가능한 경우export { something }가 사실상 “값”인지 “타입”인지 애매하거나, 타입 전용 export가 아닌 경우- 클래스/함수의 반환 타입이 구현에 의존(조건 분기/리터럴 추론/캡처된 로컬 타입 등)하는 경우
해결 방향은 거의 항상 동일합니다.
- export 되는 것에는 타입을 명시한다.
- 타입이 복잡하면 별도의 named type(인터페이스/타입 별칭) 으로 빼서 export 한다.
- 값 export와 타입 export를 명확히 분리한다(
export type,import type).
자주 터지는 패턴 1: export const의 “복원 불가” 추론
문제 코드
아래는 런타임에선 완벽히 동작하지만, 선언 생성 관점에서는 곤란해질 수 있는 전형적인 형태입니다.
// config.ts
const defaults = {
retries: 3,
backoff: (n: number) => Math.min(1000 * 2 ** n, 10_000),
};
export const config = {
...defaults,
mode: process.env.NODE_ENV === "production" ? "prod" : "dev",
};
config.mode는 리터럴 유니언("prod" | "dev")로 추론되기도 하고, 설정 조합/스프레드/조건식이 섞이면 선언 생성이 안정적이지 않다고 판단되어 에러가 발생할 수 있습니다.
해결: export되는 값에 타입을 부여
// config.ts
export type Mode = "prod" | "dev";
export interface Config {
retries: number;
backoff: (n: number) => number;
mode: Mode;
}
const defaults: Omit<Config, "mode"> = {
retries: 3,
backoff: (n) => Math.min(1000 * 2 ** n, 10_000),
};
export const config: Config = {
...defaults,
mode: process.env.NODE_ENV === "production" ? "prod" : "dev",
};
핵심은 export const config: Config처럼 export 지점에서 타입을 고정하는 것입니다.
자주 터지는 패턴 2: 함수 반환 타입이 구현에 의해 결정됨
문제 코드
// client.ts
export function createClient(baseUrl: string) {
return {
get: async (path: string) => fetch(baseUrl + path).then((r) => r.json()),
post: async (path: string, body: unknown) =>
fetch(baseUrl + path, { method: "POST", body: JSON.stringify(body) }).then((r) => r.json()),
};
}
이런 “팩토리 함수가 객체 리터럴을 반환”하는 패턴은 매우 흔합니다. 하지만 선언 생성은 이 객체의 정확한 형태를 파일 독립적으로 안정적으로 만들기 어렵거나(특히 제네릭/오버로드/조건부 타입이 섞이면), 결과 .d.ts가 의도치 않게 복잡해질 수 있습니다.
해결: 반환 타입을 named type으로 명시
// client.ts
export interface HttpClient {
get<T = unknown>(path: string): Promise<T>;
post<T = unknown>(path: string, body: unknown): Promise<T>;
}
export function createClient(baseUrl: string): HttpClient {
return {
get: async (path) => fetch(baseUrl + path).then((r) => r.json()),
post: async (path, body) =>
fetch(baseUrl + path, { method: "POST", body: JSON.stringify(body) }).then((r) => r.json()),
};
}
이 방식의 장점:
.d.ts가 읽기 쉬워짐- 공개 API가 고정되어 SemVer 관리에 유리
- 구현 변경이 선언에 불필요하게 전파되는 것을 차단
자주 터지는 패턴 3: 타입/값 export가 섞여서 생기는 문제
TS는 동일한 이름이 타입 공간과 값 공간에 공존할 수 있습니다. 하지만 선언 생성이 “파일 독립”으로 돌아갈 때는 모호성이 문제가 되곤 합니다.
해결 원칙: type-only import/export를 습관화
// types.ts
export interface User {
id: string;
}
// index.ts
export type { User } from "./types";
또한 외부 타입을 가져올 때도 import type을 쓰면, 번들러/트리쉐이킹/선언 생성 모두에서 의도가 명확해집니다.
import type { User } from "./types";
export function printUser(u: User) {
console.log(u.id);
}
자주 터지는 패턴 4: satisfies/const assertion과 공개 API의 충돌
satisfies는 타입 안정성을 높이되 추론을 유지하는 데 유용하지만, “추론된 결과”가 그대로 export될 때는 오히려 선언이 복잡해질 수 있습니다.
문제 코드
export const routes = {
home: "/",
user: (id: string) => `/users/${id}`,
} as const satisfies Record<string, string | ((...a: any[]) => string)>;
여기서 routes는 매우 정교한 리터럴 타입을 갖게 됩니다. 그게 의도라면 괜찮지만, isolatedDeclarations 환경에서는 이런 정교함이 선언 생성에 부담이 되거나 에러를 유발할 수 있습니다.
해결: 공개 타입을 별도로 정의하고 export 값에 주입
export type Routes = {
home: string;
user: (id: string) => string;
};
export const routes: Routes = {
home: "/",
user: (id) => `/users/${id}`,
};
satisfies는 내부 구현 검증용으로 쓰고, 외부로 노출되는 타입은 의도적으로 단순화하는 전략이 실무에서 유지보수에 유리합니다.
자주 터지는 패턴 5: 클래스의 private 필드/구현 디테일이 타입에 새는 경우
선언 파일은 “공개 표면(public surface)”만 표현해야 하는데, 구현 디테일이 타입 추론에 섞이면 문제가 됩니다. 특히 클래스에서 메서드 반환 타입이 내부 제네릭/로컬 타입에 기대면 선언 생성이 불안정해집니다.
해결: public 메서드/프로퍼티의 타입을 명시
type CacheEntry<T> = { value: T; expiresAt: number };
export class Cache {
private store = new Map<string, CacheEntry<unknown>>();
set(key: string, value: unknown, ttlMs: number): void {
this.store.set(key, { value, expiresAt: Date.now() + ttlMs });
}
get<T = unknown>(key: string): T | undefined {
const e = this.store.get(key);
if (!e || e.expiresAt < Date.now()) return undefined;
return e.value as T;
}
}
핵심은 get<T = unknown>(...): T | undefined처럼 외부가 의존하는 시그니처를 명시하는 것입니다.
“최소 수정” 체크리스트: 에러를 빠르게 줄이는 순서
대규모 프로젝트에서 한 번에 모두 고치기 어렵다면, 아래 순서로 접근하면 효율이 좋습니다.
- 에러가 난 파일에서 export 목록부터 확인
export const/let/var,export function,export class,export default
- export되는 심볼에 타입 주석 추가
- 변수:
export const x: X = ... - 함수:
export function f(...): R {} - 클래스: public 멤버 타입 명시
- 변수:
- 복잡한 추론은 named type으로 분리
type/ interface로 빼서 재사용 및 선언 단순화
- type-only import/export 적용
import type,export type로 값/타입 경계 명확화
- 공개 API의 “리터럴 과추론”을 의도적으로 완화
as const를 무조건 export에 적용하지 말고, 필요하면 내부 상수로 숨기기
모노레포/패키지 빌드에서 권장 설정 조합
라이브러리 패키지 기준으로는 보통 아래 조합이 안정적입니다.
{
"compilerOptions": {
"composite": true,
"declaration": true,
"declarationMap": true,
"emitDeclarationOnly": true,
"isolatedDeclarations": true,
"verbatimModuleSyntax": true,
"skipLibCheck": false,
"module": "ESNext",
"target": "ES2022",
"moduleResolution": "Bundler"
},
"include": ["src"]
}
verbatimModuleSyntax: type-only import/export를 더 엄격히 다루게 되어 선언 품질이 좋아지는 경우가 많습니다.skipLibCheck: false: 내부 패키지들의 타입 품질을 함께 끌어올릴 때 도움이 됩니다(단, 초기에는 비용이 큼).
트러블슈팅 팁: “선언 생성이 막히는 지점”을 좁히는 방법
emitDeclarationOnly로.d.ts만 뽑아보면 문제 원인이 더 명확해집니다.- 에러가 특정 파일에만 난다면, 그 파일의 export를 임시로 주석 처리해 “어떤 export가 원인인지”를 빠르게 찾을 수 있습니다.
- 공개 API를 얇게 유지하고, 복잡한 타입 연산은 내부로 숨기세요.
- 예:
src/internal/*에 구현 타입을 두고,src/index.ts에서는 단순한 타입만 재-export
- 예:
결론: isolatedDeclarations는 ‘에러’가 아니라 ‘API 문서화 강제’에 가깝다
isolatedDeclarations는 귀찮은 옵션처럼 보이지만, 실제로는 “공개 API를 명시적으로 설계하라”는 강한 신호입니다. export 지점에서 타입을 고정하고(named type으로 분리), type-only import/export로 경계를 명확히 하면 대부분의 에러는 정리됩니다. 그 과정에서 .d.ts가 읽기 쉬워지고, 라이브러리 사용자 입장에서도 타입 안정성이 크게 좋아집니다.
특히 satisfies 같은 TS 5.x 기능을 함께 쓰는 팀이라면, 내부 검증과 외부 노출 타입을 분리하는 습관이 중요합니다. 위 패턴대로 “export에 타입을 붙이고, 복잡함은 타입 별칭/인터페이스로 빼는 것”만으로도 TS 5.5+ 전환 비용을 크게 줄일 수 있습니다.