Published on

TS 5.5 const 타입추론 깨짐·해결 7가지

Authors

서로 다른 팀/레포에서 TypeScript 5.5로 올린 뒤 가장 자주 체감하는 변화 중 하나가 const를 써도 기대했던 리터럴 타입이 유지되지 않고 더 넓은 타입으로 추론(widen)되거나, 제네릭/헬퍼 함수 경계에서 타입이 흐려지는 현상입니다. 대부분은 TS가 “더 안전하고 일관된” 방향으로 추론을 조정하면서 기존에 우연히 성립하던 패턴이 깨지는 경우가 많습니다.

이 글은 “TS 5.5에서 const 타입추론이 깨졌다”고 느끼는 대표 케이스를 7가지로 나누고, 각각을 안정적으로 복구하는 실전 해법을 제시합니다. (아래 예시는 최소 재현을 위해 단순화했습니다.)

또한 타입 시스템 이슈를 디버깅하는 감각은 런타임 장애 대응과 유사합니다. 예를 들어 장애 원인을 단계적으로 좁혀가는 방식은 systemd 서비스가 재부팅 후 안 뜰 때 9분 진단 같은 글에서 다루는 사고방식과 닮아 있습니다.

0. 먼저 확인할 체크리스트

업그레이드 직후 “추론이 깨졌다”는 보고가 들어오면, 아래부터 확인하세요.

  • tsconfig.jsonstrict 계열 옵션 변경 여부
  • exactOptionalPropertyTypes, noUncheckedIndexedAccess, useUnknownInCatchVariables 등 추가로 켠 옵션 여부
  • 의존 라이브러리의 타입 정의 업데이트 여부
  • as const를 어디에 적용했는지(객체/배열/리턴값/인자) 위치

이제 본격적으로, 자주 깨지는 7가지 패턴과 해결책입니다.

1) 객체 리터럴이 함수 경계에서 widen 되는 문제

증상

객체를 const로 선언해도, 함수 인자로 들어가면서 리터럴이 string으로 widen 되어 유니온 매칭이 깨집니다.

type Mode = "dev" | "prod";

function setMode(mode: Mode) {
  return mode;
}

const cfg = { mode: "dev" };
// 기대: cfg.mode가 "dev"
// 현실: cfg.mode가 string으로 취급되어 아래가 에러가 나기도 함
setMode(cfg.mode);

원인

const는 “변수 바인딩이 재할당 불가”일 뿐, 객체 내부 프로퍼티까지 리터럴로 고정하지는 않습니다. TS 5.5에서 추론 경계(특히 함수 인자/리턴)에서 widen이 더 잘 드러나는 프로젝트가 많습니다.

해결 1: as const를 값에 적용

const cfg = { mode: "dev" } as const;
setMode(cfg.mode);

해결 2: satisfies로 형태 검증 + 리터럴 유지

type Cfg = { mode: Mode };

const cfg = {
  mode: "dev",
} satisfies Cfg;

setMode(cfg.mode);

as const는 깊게 readonly가 되지만, satisfies는 “검증만” 하고 값의 구체 타입(리터럴)을 최대한 유지하는 데 유리합니다.

2) 배열/튜플이 string[]로 변해 인덱스 타입이 무너짐

증상

튜플처럼 쓰고 싶은데, 어느 순간 string[]로 추론되어 arr[0]이 특정 리터럴이 아니라 string이 됩니다.

const roles = ["admin", "viewer"]; // string[] 로 추론될 수 있음

type Role = (typeof roles)[number];
// 기대: "admin" | "viewer"
// 현실: string

해결 1: as const로 튜플 고정

const roles = ["admin", "viewer"] as const;

type Role = (typeof roles)[number];
// "admin" | "viewer"

해결 2: satisfies readonly string[]로 리터럴 유지

const roles = ["admin", "viewer"] satisfies readonly string[];

type Role = (typeof roles)[number];
// "admin" | "viewer"

as const는 튜플 길이까지 고정되며, satisfies는 “문자열 배열임”을 보장하면서도 리터럴 유니온을 보존하는 데 자주 사용됩니다.

3) 제네릭 함수가 리터럴을 먹고 string으로 돌려주는 문제

증상

제네릭 헬퍼를 만들었는데, 입력은 리터럴인데 출력이 string으로 넓어집니다.

function id<T>(x: T) {
  return x;
}

const x = id("dev");
// 기대: "dev"
// 상황에 따라 string 으로 보일 때가 있음

해결 1: const 타입 파라미터 사용(리터럴 보존)

TS 5.x에서 지원되는 const 타입 파라미터는 “리터럴을 최대한 유지”하는 데 큰 도움이 됩니다.

function id<const T>(x: T) {
  return x;
}

const x = id("dev");
// "dev"

해결 2: 불필요한 widen을 유발하는 제약 제거

다음처럼 T extends string 제약을 걸면 추론이 string 쪽으로 당겨질 수 있습니다.

function wrap<T extends string>(x: T) {
  return x;
}

const a = wrap("dev");

이 경우에도 function wrap<const T extends string>(x: T)처럼 const 제네릭을 적용하면 리터럴 유지에 유리합니다.

4) 조건부 타입/오버로드에서 리터럴이 소실되는 문제

증상

오버로드나 조건부 타입을 통해 리턴 타입을 정교하게 만들었는데, 입력 리터럴이 유지되지 않습니다.

function parseEnv(x: "dev"): { debug: true };
function parseEnv(x: "prod"): { debug: false };
function parseEnv(x: string) {
  return { debug: x === "dev" };
}

const env = "dev";
const r = parseEnv(env);
// 기대: { debug: true }
// 현실: env가 string으로 widen되면 오버로드 매칭 실패

해결 1: 호출부에서 리터럴 고정

const env = "dev" as const;
const r = parseEnv(env);

해결 2: satisfies로 유니온에 맞추기

type Env = "dev" | "prod";
const env = "dev" satisfies Env;
const r = parseEnv(env);

핵심은 “오버로드는 리터럴/유니온을 정확히 받아야” 원하는 리턴 타입이 선택된다는 점입니다.

5) Object.keys/Object.entries에서 키가 string으로 변함

증상

as const로 객체를 고정했는데도 Object.keys를 쓰면 키가 string[]이 되어, 인덱싱 시 타입이 깨집니다.

const routes = {
  home: "/",
  about: "/about",
} as const;

for (const k of Object.keys(routes)) {
  // k: string
  // routes[k] 인덱싱이 안전하지 않다고 나옴
}

해결 1: 키를 keyof typeof로 캐스팅(가장 흔한 패턴)

type RouteKey = keyof typeof routes;

for (const k of Object.keys(routes) as RouteKey[]) {
  const path = routes[k];
  // path: "/" | "/about"
}

해결 2: 타입 안전한 헬퍼 함수로 감싸기

function typedKeys<T extends object>(obj: T) {
  return Object.keys(obj) as Array<keyof T>;
}

for (const k of typedKeys(routes)) {
  const path = routes[k];
}

이 이슈는 TS 5.5만의 문제라기보다, 표준 라이브러리 함수가 런타임에서 string을 반환하기 때문에 타입 시스템이 보수적으로 잡는 전형적인 케이스입니다.

6) let으로 바꾸는 순간 리터럴이 즉시 widen 되는 문제

증상

const였을 때는 괜찮았는데, 리팩터링으로 let으로 바꾸자마자 유니온 매칭이 깨집니다.

type Status = "idle" | "loading" | "done";

let status = "idle";
// status: string

const next: Status = status; // 에러

해결 1: 변수 선언 시점에 타입을 명시

let status: Status = "idle";

해결 2: 상태 전이를 함수로 감싸고 입력을 제한

type Status = "idle" | "loading" | "done";

let status: Status = "idle";

function setStatus(next: Status) {
  status = next;
}

setStatus("loading");

상태를 let으로 들고 가야 한다면, “초기값 리터럴에 의존한 추론” 대신 명시 타입을 주는 게 장기적으로 안전합니다.

7) as const 남발로 readonly가 전파되어 오히려 깨지는 문제

증상

리터럴을 고정하려고 as const를 붙였더니, 깊은 readonly가 전파되어 라이브러리 함수 인자 타입과 충돌합니다.

type Payload = { tags: string[] };

function send(p: Payload) {
  return p;
}

const p = {
  tags: ["a", "b"],
} as const;

send(p);
// tags가 readonly 튜플로 고정되어 string[]와 호환이 깨질 수 있음

해결 1: satisfies로 “검증만” 하고 readonly 전파를 피하기

type Payload = { tags: string[] };

const p = {
  tags: ["a", "b"],
} satisfies Payload;

send(p);

해결 2: 필요한 부분만 좁히고 나머지는 넓히기

const p = {
  tags: ["a", "b"] as string[],
  kind: "event" as const,
};

as const는 강력하지만, “리터럴 고정”과 “깊은 readonly”가 한 세트로 따라옵니다. TS 5.5 업그레이드 후 충돌이 늘었다면, 기존 코드가 as const에 과도하게 의존했을 가능성이 큽니다.

TS 5.5 업그레이드 실전 팁: 재현 코드부터 작게 만들기

타입 추론 문제는 원인이 복합적인 경우가 많습니다. 다음 순서로 줄이면 해결 속도가 빨라집니다.

  1. 문제가 난 변수/리턴값의 “기대 타입”을 주석으로 써두기
  2. satisfies를 먼저 적용해 “형태 검증”과 “리터럴 유지”를 분리하기
  3. 그래도 안 되면 const 제네릭 또는 선언부 타입 명시로 경계를 고정하기
  4. 마지막으로 as const를 최소 범위에만 사용하기

이런 식의 단계적 접근은 타입 이슈뿐 아니라 배포/런타임 장애에서도 동일하게 통합니다. 예를 들어 설정 한두 줄 차이로 장애가 나기도 하는데, 원인을 좁혀가는 과정은 GitHub Actions 캐시 무효화로 빌드가 느릴 때 같은 글에서의 “변수 통제”와 같은 결입니다.

정리: 7가지 해결책을 한 문장으로

  1. 함수 경계 widen: as const 또는 satisfies로 리터럴 고정
  2. 배열이 string[]로 변함: as const 튜플 또는 satisfies readonly string[]
  3. 제네릭에서 리터럴 소실: function f<const T>(...) 적용
  4. 오버로드/조건부 타입 매칭 실패: 호출부 리터럴 고정(as const/satisfies)
  5. Object.keys 키가 string: keyof typeof 기반 typed helper 사용
  6. let로 widen: 선언 시점 타입 명시로 고정
  7. as const readonly 충돌: satisfies로 대체하거나 부분 좁히기

추론이 “깨진” 것처럼 보여도, 대부분은 타입 경계가 명확하지 않았던 코드가 TS 5.5에서 더 엄격하게 드러난 경우입니다. 위 7가지 패턴을 기준으로 코드베이스를 검색해보면, 업그레이드 후 발생한 타입 에러의 상당수를 짧은 수정으로 안정화할 수 있습니다.

추가로 데코레이터/메타프로그래밍을 함께 쓰는 프로젝트라면 TS 5.x 전반의 변화가 겹칠 수 있으니, 관련 이슈는 TypeScript 5.x 데코레이터 TS1240 해결 가이드도 함께 참고하면 좋습니다.