Published on

TS 5.5+ isolatedDeclarations 에러 실전 해결법

Authors

서로 다른 빌드 파이프라인(예: 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+에서 체감이 커졌나

isolatedDeclarationstsconfig.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가 아닌 경우
  • 클래스/함수의 반환 타입이 구현에 의존(조건 분기/리터럴 추론/캡처된 로컬 타입 등)하는 경우

해결 방향은 거의 항상 동일합니다.

  1. export 되는 것에는 타입을 명시한다.
  2. 타입이 복잡하면 별도의 named type(인터페이스/타입 별칭) 으로 빼서 export 한다.
  3. 값 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처럼 외부가 의존하는 시그니처를 명시하는 것입니다.

“최소 수정” 체크리스트: 에러를 빠르게 줄이는 순서

대규모 프로젝트에서 한 번에 모두 고치기 어렵다면, 아래 순서로 접근하면 효율이 좋습니다.

  1. 에러가 난 파일에서 export 목록부터 확인
    • export const/let/var, export function, export class, export default
  2. export되는 심볼에 타입 주석 추가
    • 변수: export const x: X = ...
    • 함수: export function f(...): R {}
    • 클래스: public 멤버 타입 명시
  3. 복잡한 추론은 named type으로 분리
    • type/ interface로 빼서 재사용 및 선언 단순화
  4. type-only import/export 적용
    • import type, export type로 값/타입 경계 명확화
  5. 공개 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+ 전환 비용을 크게 줄일 수 있습니다.