Published on

TS 5.5 isolatedDeclarations 오류 해결 가이드

Authors

서드파티 라이브러리 배포, 모노레포 패키지 분리, 혹은 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/var
  • export function
  • export default
  • export 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를 한 번에 켜면 수정량이 폭발합니다. 다음 순서를 권합니다.

  1. 라이브러리/공용 패키지부터 적용 (외부로 타입을 배포하는 곳)
  2. emitDeclarationOnly로 선언 생성만 CI에서 먼저 돌려보기
  3. export 경계에 타입 주석을 우선 추가
  4. typeof 기반 타입 추론을 점진적으로 제거

이 과정은 “장애가 난 다음에 몰아서 고치는” 방식보다 훨씬 싸게 먹힙니다. 성능 문제를 원인 단위로 쪼개는 접근(예: Chrome INP 폭증 원인 찾기 - Long Task 분해)처럼, 타입 문제도 작은 단위로 쪼개면 해결이 빨라집니다.

결론: isolatedDeclarations는 귀찮지만, d.ts 품질을 강제하는 안전장치

isolatedDeclarations는 단순히 “더 엄격한 옵션”이 아니라, 선언 파일 생성의 재현성과 안정성을 강제합니다. 오류가 났다는 건 보통 다음 중 하나입니다.

  • export된 타입이 로컬 구현/값 추론에 의존한다
  • export 경계에서 타입이 명시되지 않아 추론이 흔들린다
  • typeof/ReturnType 등으로 구현을 타입으로 끌어오고 있다

해결의 정답은 대부분 단순합니다.

  • export 경계에 타입을 명시하고
  • 구현과 타입을 분리하며
  • 내부 구현을 타입에 새지 않게 만드는 것

이 3가지만 지키면 TS 5.5에서 isolatedDeclarations를 켠 상태로도 안정적으로 .d.ts를 생성할 수 있습니다.