- Published on
TS 5.5+ const 타입 파라미터 추론 깨짐 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서드파티 라이브러리나 내부 유틸리티 타입을 TS 5.5 이상으로 올렸더니, 리터럴 타입이 넓어지거나 튜플이 배열로 붕괴하면서 오버로드 선택이 바뀌는 식의 “추론 깨짐”을 겪는 경우가 있습니다. 겉으로는 컴파일 에러가 아니라 타입이 미묘하게 달라져서, 이후 단계에서 갑자기 타입 에러가 연쇄적으로 터지기도 합니다.
TS 5.5+에서 특히 체감이 큰 이유는 const 타입 파라미터를 활용하는 패턴이 늘었고(또는 라이브러리들이 적극 도입했고), 그에 따라 추론 경로가 달라지는 케이스가 생기기 때문입니다. 이 글에서는 재현 가능한 예제를 통해 어떤 상황에서 문제가 발생하는지, 그리고 코드베이스를 크게 흔들지 않고 해결하는 방법을 정리합니다.
참고: Node 런타임/모듈 시스템 이슈로 TS 업그레이드가 동반되는 경우도 많습니다. ESM 전환 과정이 겹친다면 Node 22에서 require가 안 될 때 ESM 전환법도 같이 확인해두면 좋습니다.
배경: const 타입 파라미터가 뭘 바꾸나
TypeScript의 const 타입 파라미터는 “이 제네릭은 가능하면 리터럴/튜플 같은 좁은 타입으로 유지해서 추론하라”는 의도를 타입 시스템에 전달하는 장치입니다.
대표적으로 다음 두 가지를 기대합니다.
- 배열 인자를
string[]로 넓히지 말고readonly ["a", "b"]같은 튜플로 유지 - 객체 인자를
{ mode: "dev" }같은 리터럴로 유지
하지만 현실에서는 다음과 같은 이유로 “기대와 다르게” 보일 수 있습니다.
- 기존 코드가 넓은 타입으로 추론되는 것에 의존하고 있었음
- 오버로드/조건부 타입이 넓은 타입을 전제로 분기하고 있었음
readonly가 끼면서T extends any[]같은 제약과 충돌- 타입 파라미터가 여러 개일 때,
const가 붙은 파라미터를 중심으로 추론 우선순위가 바뀜
즉, TS가 틀렸다기보다 “추론 결과가 달라졌고, 그 결과 기존 설계의 빈틈이 드러난다”에 가깝습니다.
증상 1: 튜플 유지로 인해 오버로드가 바뀌는 케이스
아래는 흔한 패턴입니다. 어떤 함수가 배열을 받으면 합치고, 문자열을 받으면 다른 처리를 한다고 가정해봅시다.
// TS 5.4까지는 대개 string[]로 넓어져서 아래 오버로드가 선택되곤 했던 코드가
// TS 5.5+에서 튜플로 유지되면서 다른 오버로드로 갈 수 있습니다.
declare function f(x: string[]): "array";
declare function f<const T extends readonly string[]>(x: T): "tuple";
type R1 = ReturnType<typeof f>; // 오버로드 집합이라 직접적 의미는 약함
const a = ["a", "b"]; // 상황에 따라 string[] 또는 tuple로 추론
const r = f(a);
// 기대: "array"였는데 실제: "tuple"로 바뀌는 식
여기서 중요한 건 “어떤 게 더 맞다”가 아니라, 오버로드 설계가 불안정하다는 점입니다. const 타입 파라미터를 도입한 오버로드가 기존 오버로드를 잠식하면, 호출부 타입이 조금만 바뀌어도 결과가 달라집니다.
해결 1: 오버로드를 분리하지 말고 단일 시그니처로 정리
오버로드를 유지해야 하는 이유가 없다면, 단일 시그니처로 정리하는 게 가장 안전합니다.
type FResult<T extends readonly string[]> =
T extends readonly [string, ...string[]] ? "tuple" : "array";
function f<const T extends readonly string[]>(x: T): FResult<T> {
// 런타임 구현은 단순화
return (Array.isArray(x) && x.length > 0 ? "tuple" : "array") as FResult<T>;
}
const r1 = f(["a", "b"] as const); // "tuple"
const r2 = f(["a", "b"]); // "tuple" 또는 "array"가 아니라, 조건에 의해 안정적으로 결정
핵심은 “추론 결과가 바뀌어도 반환 타입이 예측 가능하도록” 타입 설계를 바꾸는 것입니다.
증상 2: readonly 때문에 제약이 깨지는 케이스
const 타입 파라미터는 종종 readonly 튜플로 추론됩니다. 그런데 기존 유틸이 T extends any[]처럼 mutable 배열만 허용하고 있으면 갑자기 제약이 깨집니다.
// 기존 코드
function head<T extends any[]>(xs: T) {
return xs[0];
}
const xs = [1, 2] as const;
// TS 5.5+에서 다른 경로로 xs가 readonly로 강하게 유지되면
// head(xs) 가 제약에서 튕길 수 있음
해결 2: 배열 제약을 readonly로 넓혀라
가장 권장되는 수정입니다.
function head<const T extends readonly unknown[]>(xs: T): T[0] {
return xs[0];
}
const xs = [1, 2] as const;
const h = head(xs); // 1
- 입력을 변경하지 않는 함수라면
readonly를 받는 게 맞습니다. unknown[]를 기본으로 두면 불필요한any전파도 막습니다.
증상 3: 객체 리터럴이 너무 좁게 고정되어 조건부 타입 분기가 달라짐
const 타입 파라미터는 객체 리터럴도 좁게 유지합니다. 이때 조건부 타입이 “넓은 타입”을 전제로 작성되어 있으면, 분기 결과가 바뀝니다.
type Mode = "dev" | "prod";
type Config<T> = T extends { mode: "dev" } ? { debug: true } : { debug: false };
function make<const T extends { mode: Mode }>(cfg: T): Config<T> {
return ({ debug: cfg.mode === "dev" } as unknown) as Config<T>;
}
const c1 = make({ mode: "dev" });
// c1: { debug: true } 로 매우 좁게 나옴
let m: Mode = "dev";
const c2 = make({ mode: m });
// c2: { debug: false } | { debug: true } 같은 형태로 넓어질 수 있음
여기서 “추론 깨짐”은 보통 c1이 너무 좁아져서 이후 코드에서 debug가 true로 고정되며 분기가 사라지는 형태로 나타납니다.
해결 3: 호출부에서 의도적으로 widen 시키기
“항상 넓은 모드로 다루고 싶다”면 호출부에서 타입을 명시해 추론을 넓히는 게 가장 단순합니다.
const c3 = make<{ mode: Mode }>({ mode: "dev" });
// c3: { debug: true } | { debug: false }
또는 satisfies를 사용해 구조를 검증하되, 변수 자체는 widen 되도록 만들 수 있습니다.
const cfg = { mode: "dev" } satisfies { mode: Mode };
// cfg.mode 는 "dev" 리터럴로 남을 수 있으니, 정말 widen이 필요하면 아래처럼
const cfg2: { mode: Mode } = { mode: "dev" };
const c4 = make(cfg2);
정리하면:
- “리터럴로 고정되는 게 싫다”면 제네릭 인자 명시 또는 변수에 넓은 타입 주기
- “리터럴로 고정되는 게 좋다”면
const타입 파라미터 유지
증상 4: NoInfer/보조 제네릭 패턴과 충돌
기존에 추론을 특정 인자에서만 하도록 강제하기 위해 NoInfer 유사 패턴을 쓰는 경우가 있습니다. TS 버전 업과 const 타입 파라미터 적용으로 추론 우선순위가 바뀌면, 이 패턴이 의도대로 작동하지 않는 것처럼 보일 수 있습니다.
type NoInfer<T> = [T][T extends any ? 0 : never];
function pick<const T extends object, K extends keyof T>(
obj: T,
key: NoInfer<K>
): T[K] {
return obj[key as K];
}
해결 4: “추론을 막는 자리”를 더 명확히 분리
아예 key를 별도 함수로 분리하거나, 제네릭 파라미터를 재배치해 추론 경로를 단순화하면 안정성이 올라갑니다.
function pickKey<K extends PropertyKey>(key: K) {
return function pickFrom<const T extends Record<K, unknown>>(obj: T): T[K] {
return obj[key];
};
}
const getId = pickKey("id");
const v = getId({ id: 123, name: "a" }); // 123
이 방식은 추론 경로가 명확해져서 TS 버전 변화에 덜 흔들립니다.
실전 체크리스트: TS 5.5+에서 “추론 깨짐”을 빠르게 잡는 법
- 어디서 넓어지던 타입이 좁아졌는지 먼저 확인
- 배열이
readonly튜플로 바뀌었는지 - 객체 프로퍼티가 리터럴로 고정되었는지
- 배열이
- 함수/유틸의 제약이
any[]처럼 mutable 전용인지 점검- 가능하면
readonly unknown[]로 변경
- 가능하면
- 오버로드가 있다면,
const도입된 시그니처가 기존 시그니처를 잠식하는지 확인- 단일 시그니처+조건부 타입으로 재구성 고려
- 호출부에서 의도적으로 widen 해야 하는 지점은 제네릭 인자 명시로 고정
- 타입 테스트를 추가
tsd,expectType류 도구 또는// @ts-expect-error기반 회귀 테스트
이런 “버전 업 후 미묘한 타입 변화”는 런타임 장애처럼 즉시 폭발하지 않아서 더 위험합니다. 운영에서 천천히 쌓이다가 특정 PR에서 갑자기 타입 에러가 폭발하는 양상은, 인프라에서 커넥션/리소스가 서서히 고갈되다가 임계점에서 터지는 현상과도 닮았습니다. 비슷한 관점의 트러블슈팅 글로 Spring Boot DB 커넥션 고갈 - HikariCP 튜닝 가이드도 참고할 만합니다.
권장 패턴 모음 (복붙용)
1) 배열 입력 유틸은 기본적으로 readonly로
export function last<const T extends readonly unknown[]>(xs: T): T[number] {
return xs[xs.length - 1] as T[number];
}
const v1 = last([1, 2, 3] as const); // 1 | 2 | 3 중 실제로는 3
const v2 = last(["a", "b"]); // string
2) “리터럴 유지”가 목적이면 const + as const를 함께
function defineRoutes<const T extends readonly string[]>(routes: T) {
return routes;
}
const routes = defineRoutes(["/", "/health"] as const);
// 타입: readonly ["/", "/health"]
3) “리터럴 고정이 싫다”면 호출부에서 widen
type Route = string;
function defineRoutes<const T extends readonly Route[]>(routes: T) {
return routes;
}
const routes2 = defineRoutes<Route[]>(["/", "/health"]);
// 타입: Route[] (즉 string[])
마무리
TS 5.5+의 const 타입 파라미터는 타입 안정성을 높이는 강력한 도구이지만, 기존 코드가 “넓은 추론”에 기대고 있던 부분을 드러내면서 추론이 깨진 것처럼 느껴질 수 있습니다. 해결의 핵심은 다음 둘 중 하나를 명확히 선택하는 것입니다.
- 정말로 리터럴/튜플을 유지하고 싶다:
const타입 파라미터와readonly제약을 받아들이고, 반환 타입/조건부 타입을 그에 맞게 설계 - 넓은 타입이 필요하다: 호출부에서 타입을 명시해 widen 하거나, API 자체를 widen 중심으로 재설계
업그레이드 후 타입 변화는 “어디서부터 좁아졌는지”만 잡히면 대부분 국소 수정으로 끝납니다. 위 체크리스트와 패턴을 기준으로, 오버로드/제약/readonly 충돌 지점을 먼저 점검해보면 빠르게 안정화할 수 있습니다.