- Published on
TS 5.5 isolatedDeclarations 오류 해결 가이드
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서드파티 라이브러리 배포, 모노레포 패키지 분리, 혹은 declaration: true 기반의 타입 배포를 하다 보면 TS 5.5에서 새로(혹은 더 엄격하게) 체감되는 옵션이 있습니다. 바로 isolatedDeclarations입니다. 이 옵션을 켜는 순간, “빌드는 되는데 d.ts 생성에서만 죽는” 류의 문제가 한꺼번에 표면화됩니다.
이 글에서는 **왜 isolatedDeclarations가 오류를 내는지(원리)**를 먼저 잡고, 그 다음 실제로 가장 많이 마주치는 오류 패턴과 해결책을 코드 중심으로 정리합니다.
> 참고: 장애/진단 글을 자주 쓰는 편인데, 원인-재현-해결의 흐름은 예를 들어 Docker 빌드 캐시가 무효화되는 원인 7가지 같은 글에서 쓰는 방식과 동일합니다. 이번엔 대상이 TypeScript의 선언 파일 생성 파이프라인일 뿐입니다.
isolatedDeclarations가 하는 일: “파일 단독으로 d.ts를 만들 수 있어야 한다”
isolatedDeclarations: true는 요약하면 다음 요구사항을 강제합니다.
- 각 소스 파일이 다른 파일의 구현(런타임 코드)에 기대지 않고
- 해당 파일만 보고도
.d.ts를 생성할 수 있어야 함
즉, 타입 추론이 다른 파일의 값/구현을 따라가야만 성립하는 코드(또는 타입이 값에서 유도되는 코드)는 선언 생성 단계에서 막힙니다.
이 옵션이 특히 유용한 이유는:
- 번들러/트랜스파일러(예: SWC, esbuild, Babel)가 파일 단위로 병렬 빌드하는 흐름과 잘 맞고
- “우연히 통과하던” 취약한 타입 내보내기 패턴을 조기에 잡아
- 라이브러리 소비자에게 더 안정적인
.d.ts를 제공하기 때문입니다.
tsconfig 예시
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"declaration": true,
"emitDeclarationOnly": true,
"isolatedDeclarations": true,
"strict": true
}
}
emitDeclarationOnly는 문제를 더 빨리 드러내는 데 도움이 됩니다(타입 생성만 돌리므로).
오류가 나는 대표 패턴과 해결책
아래는 실무에서 가장 자주 터지는 유형들입니다. 핵심은 **“export되는 타입이 파일 외부 구현을 추론에 사용하지 않게 만들기”**입니다.
1) export const의 타입이 “값 추론”에 의존하는 경우
문제 코드
// config.ts
const DEFAULTS = {
retry: 3,
timeoutMs: 1500,
};
export const config = {
...DEFAULTS,
timeoutMs: 2000,
};
이 코드는 런타임에선 정상입니다. 하지만 config의 타입이 DEFAULTS 값(구현)에 강하게 묶입니다. isolatedDeclarations에서는 이런 “값 기반 추론”이 선언 생성 단계에서 문제가 될 수 있습니다.
해결 1: export되는 심볼에 명시적 타입 부여
// config.ts
export type Config = {
retry: number;
timeoutMs: number;
};
export const config: Config = {
retry: 3,
timeoutMs: 2000,
};
- 가장 확실한 방법입니다.
.d.ts가 깔끔해지고, 소비자에게도 의도가 명확합니다.
해결 2: satisfies로 형태만 검증하고 타입은 안정적으로 유지
export type Config = {
retry: number;
timeoutMs: number;
};
export const config = {
retry: 3,
timeoutMs: 2000,
} satisfies Config;
satisfies는 “검증”이지 “강제 캐스팅”이 아닙니다.- 단, export되는 타입 자체가 복잡한 추론에 걸리지 않도록 주의해야 합니다.
2) export type이 로컬 값/함수 구현에 의존하는 경우
문제 코드: typeof로 내부 구현을 끌어오는 패턴
// api.ts
function makeClient() {
return {
get: (url: string) => Promise.resolve(url),
};
}
export type Client = ReturnType<typeof makeClient>;
export const client = makeClient();
Client 타입이 makeClient 구현을 따라가야만 결정됩니다. 파일 단독 선언 생성에서 이런 패턴은 종종 막힙니다(특히 구현이 더 복잡해질수록).
해결: 구현과 타입을 분리
// api.ts
export type Client = {
get(url: string): Promise<string>;
};
function makeClient(): Client {
return {
get: (url) => Promise.resolve(url),
};
}
export const client: Client = makeClient();
- “타입은 타입로, 구현은 구현으로”를 강제하면
isolatedDeclarations에서 거의 안전합니다.
3) export된 함수의 반환 타입이 복잡한 제네릭 추론에 의존
문제 코드
// builder.ts
export function build<T extends Record<string, unknown>>(input: T) {
return {
...input,
createdAt: new Date(),
};
}
반환 타입이 T & { createdAt: Date }처럼 추론될 것 같지만, 내부 구현(스프레드/리터럴 결합)에 의존하는 순간 선언 생성이 불안정해질 수 있습니다.
해결: 반환 타입 명시
export type Built<T> = T & { createdAt: Date };
export function build<T extends Record<string, unknown>>(input: T): Built<T> {
return {
...input,
createdAt: new Date(),
};
}
- 반환 타입을 명시하면
.d.ts가 안정적으로 생성됩니다.
4) export default에 익명 객체/함수 리터럴을 바로 내보내는 경우
문제 코드
// index.ts
export default {
parse(input: string) {
return JSON.parse(input);
},
};
익명 리터럴을 그대로 export하면 타입이 추론되며, 선언 생성 시 “이름 없는 타입”/“추론 의존” 문제가 생기기 쉽습니다.
해결: 이름을 부여하고 타입을 고정
export type Parser = {
parse(input: string): unknown;
};
const parser: Parser = {
parse(input) {
return JSON.parse(input);
},
};
export default parser;
5) 클래스/객체의 private 필드가 타입에 섞여 나가는 경우
isolatedDeclarations는 “외부로 노출되는 타입”이 내부 구현 디테일(특히 private/protected)에 의해 오염되는 것도 싫어합니다.
해결: public API는 인터페이스로 감싸기
class InternalCache {
#store = new Map<string, string>();
get(k: string) {
return this.#store.get(k);
}
}
export interface Cache {
get(k: string): string | undefined;
}
export const cache: Cache = new InternalCache();
- 내부 클래스는 마음대로 바꿔도, 외부
.d.ts는 안정적입니다.
실전 디버깅 체크리스트
isolatedDeclarations 오류를 빠르게 줄이려면 아래 순서가 효율적입니다.
1) “export되는 것”부터 전부 훑기
export const/let/varexport functionexport defaultexport type/interface
오류는 대부분 export 경계에서 발생합니다.
2) export 심볼에 명시적 타입을 붙여서 차단
export const x: SomeType = ...export function f(...): ReturnType
대부분의 케이스는 이것만으로 해결됩니다.
3) typeof/ReturnType/InstanceType로 “구현을 타입으로 끌어오는” 패턴 제거
- 내부 구현이 바뀌면 타입이 흔들리고, 선언 생성이 깨집니다.
4) “타입 파일”과 “구현 파일”을 분리
규모가 커질수록 다음 구조가 안정적입니다.
types.ts: export되는 타입/인터페이스만impl.ts: 실제 구현index.ts: public API 재노출
이 방식은 운영에서 장애를 줄이는 “경계 분리”와도 유사합니다. 예컨대 네트워크 문제를 격리해 진단하는 글인 GCP Cloud NAT 포트 고갈로 egress 실패 진단법처럼, 원인을 좁히려면 경계가 선명해야 합니다.
마이그레이션 전략: 한 번에 켜지 말고, 패키지/경계부터
레거시가 큰 코드베이스에서 isolatedDeclarations를 한 번에 켜면 수정량이 폭발합니다. 다음 순서를 권합니다.
- 라이브러리/공용 패키지부터 적용 (외부로 타입을 배포하는 곳)
emitDeclarationOnly로 선언 생성만 CI에서 먼저 돌려보기- export 경계에 타입 주석을 우선 추가
- typeof 기반 타입 추론을 점진적으로 제거
이 과정은 “장애가 난 다음에 몰아서 고치는” 방식보다 훨씬 싸게 먹힙니다. 성능 문제를 원인 단위로 쪼개는 접근(예: Chrome INP 폭증 원인 찾기 - Long Task 분해)처럼, 타입 문제도 작은 단위로 쪼개면 해결이 빨라집니다.
결론: isolatedDeclarations는 귀찮지만, d.ts 품질을 강제하는 안전장치
isolatedDeclarations는 단순히 “더 엄격한 옵션”이 아니라, 선언 파일 생성의 재현성과 안정성을 강제합니다. 오류가 났다는 건 보통 다음 중 하나입니다.
- export된 타입이 로컬 구현/값 추론에 의존한다
- export 경계에서 타입이 명시되지 않아 추론이 흔들린다
- typeof/ReturnType 등으로 구현을 타입으로 끌어오고 있다
해결의 정답은 대부분 단순합니다.
- export 경계에 타입을 명시하고
- 구현과 타입을 분리하며
- 내부 구현을 타입에 새지 않게 만드는 것
이 3가지만 지키면 TS 5.5에서 isolatedDeclarations를 켠 상태로도 안정적으로 .d.ts를 생성할 수 있습니다.