Published on

TypeScript 5.5 isolatedDeclarations 오류 해결법

Authors

서로 다른 빌드 파이프라인(예: 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;
  • : Configconfig의 타입이 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) 심볼”에서 시작합니다. 아래 순서로 좁히면 빠릅니다.

  1. 오류가 난 파일에서 export 목록 확인: export/export default/re-export(barrel)
  2. export된 심볼의 타입이 명시되어 있는지 확인
  3. 타입이 명시되어도 깨지면, 그 타입이 참조하는 다른 타입이
    • 값과 섞이지 않았는지
    • private 구현을 참조하지 않는지
    • 조건부/복잡한 추론 결과를 그대로 노출하지 않는지
  4. 바렐 파일에서는 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 typeexport를 분리한다
  • 동일 식별자에 타입/값을 겹치게 두지 않는다(불가피하면 이름 분리)
  • default export 객체 리터럴로 “API 묶음”을 만들지 않는다
  • 외부로 노출되는 타입이 private 구현을 참조하지 않는다
  • 가능한 한 외부 API는 단순한 타입(alias/interface/union)으로 표현한다

마무리

TypeScript 5.5의 isolatedDeclarations 오류는 “타입이 틀렸다”기보다는 “선언 파일로 안정적으로 내보낼 수 없는 API 모양”을 발견했다는 신호인 경우가 많습니다. 해결의 핵심은 한 가지입니다.

  • 추론에 기대지 말고, 외부로 나가는 타입을 설계(명시)하라

이 원칙대로 public API의 타입을 고정하고, type-only export/import를 정리하고, default export를 줄이면 대부분의 오류는 자연스럽게 사라집니다.