Published on

TS 5.5 Isolated Declarations 에러 해결 가이드

Authors

TypeScript 5.5에서 isolatedDeclarations를 켜면, 기존에는 문제 없이 빌드되던 코드가 갑자기 에러를 뿜는 경우가 많습니다. 특히 라이브러리 패키지에서 declaration을 생성하거나, 모노레포에서 공용 패키지를 배포할 때 이 옵션이 사실상 필수가 되면서(또는 CI에서 강제되면서) 처음 마주치는 팀이 많습니다.

이 글에서는 isolatedDeclarations가 무엇을 강제하는지, 어떤 유형의 코드가 깨지는지, 그리고 “코드 의미는 유지하면서 선언 파일 생성만 안전하게” 만드는 수정 패턴을 정리합니다.

Isolated Declarations가 뭘 검사하나

isolatedDeclarations는 “각 파일을 독립적으로 선언 파일로 변환할 수 있는가”를 강하게 요구합니다. 즉, 타입 정보를 얻기 위해 다른 파일의 구현을 따라가거나, 복잡한 타입 추론에 의존해야 하는 코드를 제한합니다.

왜 이런 제약이 생길까요?

  • tsc.d.ts를 생성할 때, 파일 단위로 안전하게 타입을 산출할 수 있어야 빌드가 빠르고 예측 가능해집니다.
  • 번들러 기반 빌드(예: tsup, rollup, vite)에서 타입 선언만 별도로 뽑는 파이프라인과 궁합이 좋아집니다.
  • “구현을 바꿨는데 선언이 조용히 바뀌어 API가 흔들리는” 상황을 줄입니다.

대신, 아래와 같은 코드는 “타입을 더 명확히 써라”라는 요구를 받게 됩니다.

가장 흔한 에러 패턴 7가지와 해결법

아래 예시는 에러 메시지가 환경마다 조금씩 다를 수 있지만, 원인과 해결 방향은 동일합니다.

1) export되는 값의 타입이 추론에만 의존

문제 예:

// bad.ts
export const parse = (input: string) => {
  if (input.startsWith("{")) return JSON.parse(input);
  return input;
};

이 코드는 반환 타입이 any | string처럼 흐려지거나(특히 JSON.parseany), 구현을 따라가야 타입이 결정됩니다. isolatedDeclarations에서는 이런 추론 의존을 싫어합니다.

해결:

// good.ts
export const parse = (input: string): unknown | string => {
  if (input.startsWith("{")) return JSON.parse(input) as unknown;
  return input;
};

핵심은 “외부로 노출되는 심볼”에 대해 반환 타입/변수 타입을 명시하는 것입니다.

2) as const와 과한 리터럴 추론이 선언 생성에서 충돌

문제 예:

export const routes = {
  home: "/",
  user: "/users/:id",
} as const;

export const getRoute = (key: keyof typeof routes) => routes[key];

이 자체는 괜찮아 보이지만, 다른 곳에서 routes[key]의 타입이 너무 복잡하게 전파되거나, 특정 조합에서 선언 생성이 불안정해질 수 있습니다.

해결은 “외부 API는 단순한 타입 별칭으로 고정”하는 방식이 안전합니다.

export const routes = {
  home: "/",
  user: "/users/:id",
} as const;

export type RouteKey = keyof typeof routes;
export type RoutePath = (typeof routes)[RouteKey];

export const getRoute = (key: RouteKey): RoutePath => routes[key];

3) export된 함수가 내부 구현 타입에 묶임

문제 예(내부 변수 추론이 API로 새어 나감):

const defaultOptions = {
  retry: 3,
  timeoutMs: 1500,
};

export function createClient(opts = defaultOptions) {
  return {
    opts,
  };
}

이 경우 opts의 타입이 typeof defaultOptions에 묶이면서, 선언 파일이 내부 구현 디테일을 끌고 가거나, 파일 간 추론이 필요해질 수 있습니다.

해결: 옵션 타입을 명시적으로 선언하고, 기본값은 그 타입을 만족하도록 둡니다.

export interface ClientOptions {
  retry: number;
  timeoutMs: number;
}

const defaultOptions: ClientOptions = {
  retry: 3,
  timeoutMs: 1500,
};

export function createClient(opts: ClientOptions = defaultOptions) {
  return { opts };
}

4) 클래스 필드 초기화가 복잡한 추론을 유발

문제 예:

export class Cache {
  store = new Map();
}

new Map()Map의 제네릭 인자가 비어 있어 MapMap으로만 남거나 Map<any, any>로 흐를 수 있습니다. 선언 파일에서는 이런 타입이 “의도인지 실수인지” 판단하기 어렵습니다.

해결:

export class Cache {
  store: Map<string, string> = new Map<string, string>();
}

혹은 더 간단히:

export class Cache {
  store: Map<string, string> = new Map();
}

5) satisfies 사용 시 외부 API 타입이 애매해짐

satisfies는 “검증은 하되 타입을 좁히지 않는” 도구라서 좋지만, 외부로 export되는 값에서 의도치 않게 타입이 넓게 남을 수 있습니다.

문제 예:

type Level = "debug" | "info" | "warn" | "error";

export const config = {
  level: "info",
} satisfies { level: Level };

이때 config.level은 여전히 리터럴 "info"로 남는 등, API 타입이 팀 의도와 다르게 굳을 수 있습니다.

해결(외부로 노출할 타입을 별칭으로 고정):

type Level = "debug" | "info" | "warn" | "error";

export type AppConfig = { level: Level };

export const config: AppConfig = {
  level: "info",
};

satisfies는 내부 상수 검증에 쓰고, export되는 타입은 명시하는 편이 선언 생성 관점에서 안전합니다.

6) export된 값이 조건부 타입/복잡한 인덱싱에 의존

문제 예:

type Api = {
  getUser: (id: string) => Promise<{ id: string }>;
  getPost: (id: string) => Promise<{ id: string }>;
};

export const call = <K extends keyof Api>(api: Api, key: K, ...args: Parameters<Api[K]>) => {
  return api[key](...args);
};

이런 고급 타입은 그 자체로는 유효하지만, isolatedDeclarations에서는 “이 파일만으로 선언을 만들 때 필요한 정보가 충분히 명시돼 있는가”를 더 엄격히 봅니다. 특히 반환 타입이 추론에만 맡겨져 있으면 에러가 나기 쉽습니다.

해결: 반환 타입까지 명시합니다.

type Api = {
  getUser: (id: string) => Promise<{ id: string }>;
  getPost: (id: string) => Promise<{ id: string }>;
};

export const call = <K extends keyof Api>(
  api: Api,
  key: K,
  ...args: Parameters<Api[K]>
): ReturnType<Api[K]> => {
  return api[key](...args);
};

7) 타입만 필요한데 값 import로 가져와서 꼬임

isolatedDeclarations와 함께 보통 verbatimModuleSyntax나 ESM 설정을 강화하는 경우가 많습니다. 이때 타입을 값처럼 import하면 런타임 import가 생기거나, 선언 생성에서 혼동이 생깁니다.

문제 예:

import { User } from "./types";

export const toName = (u: User) => u.name;

해결: 타입은 타입으로 import합니다.

import type { User } from "./types";

export const toName = (u: User): string => u.name;

이 패턴은 선언 생성뿐 아니라 번들 크기/사이드이펙트 측면에서도 유리합니다.

실전 체크리스트: 에러를 빠르게 줄이는 순서

  1. export되는 함수/상수/클래스의 공개 타입부터 명시
    • 매개변수 타입, 반환 타입, export const의 타입 주석을 우선 추가합니다.
  2. any가 새는 지점 차단
    • JSON.parse, 서드파티 SDK의 느슨한 타입, Map/Set 제네릭 누락이 대표적입니다.
  3. 내부 구현 디테일이 API로 노출되지 않게 분리
    • defaultOptions 같은 내부 상수의 typeof가 공개 API에 묶이지 않게 interfacetype을 만듭니다.
  4. 타입 import는 import type으로 통일
  5. 복잡한 타입은 별칭으로 추출해서 선언을 안정화
    • 조건부 타입/인덱스 접근/리터럴 타입 조합은 export type으로 한 번 고정해두면 선언 생성이 훨씬 안정적입니다.

tsconfig 권장 설정 예시

라이브러리 또는 공용 패키지 기준으로, 선언 생성 파이프라인에서 자주 쓰는 조합입니다.

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "declaration": true,
    "emitDeclarationOnly": true,
    "isolatedDeclarations": true,
    "verbatimModuleSyntax": true,
    "strict": true,
    "skipLibCheck": true
  }
}
  • emitDeclarationOnly는 타입 선언만 뽑을 때 유용합니다.
  • moduleResolution은 번들러 기반이면 Bundler가 자연스럽습니다.
  • skipLibCheck는 외부 라이브러리의 선언 검사 비용을 줄이지만, 내부 코드 품질과는 별개이므로 isolatedDeclarations를 대체하진 않습니다.

마이그레이션 전략: 한 번에 고치지 말고 “경계”부터

대규모 코드베이스에서 한 번에 다 켜면 PR이 폭발합니다. 추천 순서는 다음과 같습니다.

  1. 패키지 경계(공용 라이브러리, SDK, utils)부터 isolatedDeclarations를 켭니다.
  2. 에러가 나는 파일은 “외부로 export되는 심볼”만 우선 타입을 고정합니다.
  3. 내부 구현은 나중에 리팩터링해도 됩니다. 선언 생성만 통과시키는 것이 1차 목표입니다.

이 접근은 장애 대응과 비슷합니다. 원인을 다 뜯어고치기보다, 먼저 영향 범위를 줄이고 재발 방지 장치를 세우는 게 효율적입니다. 운영 장애를 빠르게 진단하듯이, 빌드 실패도 체크리스트 기반으로 경계를 좁혀가면 시간을 크게 줄일 수 있습니다. 비슷한 “원인 좁히기” 관점은 AWS VPC Reachability Analyzer로 502 추적하기 글의 접근과도 닮아 있습니다.

자주 묻는 질문

Q1. 그냥 옵션을 끄면 안 되나

앱만 빌드하는 단일 리포지토리라면 당장 끄는 선택도 가능합니다. 하지만 라이브러리 배포, 모노레포 공유 패키지, 타입 선언을 CI에서 검증해야 하는 환경에서는 결국 다시 켜야 하는 경우가 많습니다. 특히 “타입 선언이 곧 API 계약”인 조직에서는 장기적으로 켜는 편이 유지보수 비용을 줄입니다.

Q2. 에러가 나는 파일만 예외 처리할 수 있나

TypeScript 컴파일러 옵션은 프로젝트 단위라 “파일별로 isolatedDeclarations만 끄기”는 어렵습니다. 대신 패키지를 나누거나(프로젝트 레퍼런스), 선언 생성 대상에서 제외하는 방식이 현실적입니다. 다만 제외는 기술 부채가 되기 쉬우므로, 가능하면 “export 경계의 타입 명시”로 해결하는 것을 권합니다.

결론

isolatedDeclarations 에러의 본질은 “추론에 기대지 말고, 공개 API의 타입을 명시하라”입니다. 해결책도 대부분 단순합니다.

  • export되는 심볼에 타입 주석 추가
  • any 유입 차단
  • 복잡한 타입은 export type으로 고정
  • import type으로 런타임/타입 경계 정리

이 네 가지만 체계적으로 적용해도 TS 5.5 마이그레이션에서 선언 생성 실패의 대부분을 빠르게 정리할 수 있습니다.