- Published on
TS 5.5 const 타입매개변수로 추론 깨짐 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 팀이 만든 유틸을 조합하다 보면, 업그레이드 이후 "분명히 되던 타입 추론이 갑자기 깨졌다" 같은 이슈를 겪습니다. TypeScript 5.5의 const 타입 매개변수는 기본적으로 추론을 더 강하게 만들어주지만, 기존에 "적당히 넓게" 추론되던 제네릭 API에서는 오히려 리터럴이 과도하게 고정되거나, 반대로 제약 조건 때문에 추론이 실패하는 형태로 드러날 수 있습니다.
이 글에서는 TS 5.5의 const 타입매개변수 때문에 발생하는 추론 깨짐을 재현하고, 라이브러리 작성자와 애플리케이션 사용자 관점에서 각각 어떤 식으로 해결하는지 정리합니다.
관련해서 TS 5.5에서 const 타입 파라미터 자체를 더 잘 활용하는 방법은 TS 5.5 const 타입 파라미터로 추론 강화 글도 같이 보면 맥락이 더 잘 이어집니다. 또한 enum 대체 패턴과 함께 쓰는 경우가 많아 TS 5.5에서 enum 대신 const 객체+as const 패턴도 참고할 만합니다.
const 타입 매개변수란 무엇이 달라졌나
TS 5.5의 const 타입 매개변수는 "추론된 타입을 가능한 한 리터럴로 유지"하려는 의도를 명시합니다. 특히 배열, 튜플, 객체 리터럴을 인자로 받을 때 효과가 큽니다.
예를 들어 아래처럼 "키 목록"을 받아서 유니온을 만드는 API를 생각해봅시다.
// TS 5.5 이전에도 흔한 패턴
function keysToUnion<T extends readonly string[]>(keys: T) {
return keys;
}
const keys = keysToUnion(["id", "email"]);
// 기대: ("id" | "email")[] 혹은 readonly ["id","email"] 같은 형태
여기에 const 타입 매개변수를 붙이면, 호출부에서 as const를 쓰지 않아도 리터럴이 더 잘 보존됩니다.
function keysToUnion<const T extends readonly string[]>(keys: T) {
return keys;
}
const keys = keysToUnion(["id", "email"]);
// T는 readonly ["id", "email"]로 추론될 가능성이 커짐
문제는 이 "더 강한 고정"이 기존 API 설계 가정과 충돌할 때입니다.
증상 1: 과도한 리터럴 고정으로 제네릭이 폭발한다
재현: "옵션 객체"가 지나치게 좁혀져서 재사용이 어려워짐
아래는 흔한 설정 병합 함수입니다.
type Config = {
mode: "dev" | "prod";
retry: number;
};
function mergeConfig<T extends Partial<Config>>(base: Config, patch: T) {
return { ...base, ...patch } as Config & T;
}
const base: Config = { mode: "dev", retry: 3 };
const c1 = mergeConfig(base, { mode: "prod" });
// c1.mode는 "prod"로 좁혀질 수 있음 (대개 문제 없음)
여기까지는 괜찮습니다. 그런데 라이브러리 작성자가 "추론을 강화"하려고 아래처럼 바꿨다고 가정해봅시다.
function mergeConfig<const T extends Partial<Config>>(base: Config, patch: T) {
return { ...base, ...patch } as Config & T;
}
const patch = { mode: "prod" };
const c2 = mergeConfig(base, patch);
이제 patch가 어디서 왔느냐에 따라 결과가 달라집니다.
patch가 리터럴로 직접 전달되면T가{"mode":"prod"}로 강하게 고정됩니다.- 이 고정된 타입이 다른 제네릭과 결합할 때, 불필요하게 타입이 세분화되어 조건부 타입이나 매핑 타입에서 계산량이 늘거나, 오버로드 선택이 꼬일 수 있습니다.
해결 1: 호출부에서 "의도적으로 넓히기" (satisfies 또는 명시적 주석)
호출부에서 리터럴 고정을 원치 않는다면, 넓은 타입으로 주석을 달거나 satisfies로 모양만 검증하고 타입은 넓히는 식으로 제어할 수 있습니다.
const patch1: Partial<Config> = { mode: "prod" };
const c = mergeConfig(base, patch1);
// patch1의 타입은 Partial<Config>로 넓어짐
또는 satisfies를 쓰되, 변수 타입을 넓히는 쪽으로 설계합니다.
const patch2 = { mode: "prod" } satisfies Partial<Config>;
// patch2의 타입 자체는 여전히 { mode: "prod" }에 가깝지만,
// API가 요구하는 곳에서 넓힘이 필요하면 별도 캐스팅/주석이 필요할 수 있음
const patch2Wide: Partial<Config> = patch2;
const c = mergeConfig(base, patch2Wide);
정리하면 satisfies는 "검증"이고, "넓힘"은 별도 타입 주석이 더 확실합니다.
해결 2: 라이브러리에서 const를 남발하지 말고 "경계"에만 둔다
const 타입 매개변수는 특히 "리터럴 목록"이나 "스키마 정의"처럼 고정이 이득인 API에 적합합니다. 반면 Partial 같은 패치 객체는 호출부 재사용성이 중요한 경우가 많아, 오히려 const가 독이 될 수 있습니다.
라이브러리라면 아래처럼 두 함수를 분리하는 것도 실전적인 선택입니다.
function mergeConfig<T extends Partial<Config>>(base: Config, patch: T) {
return { ...base, ...patch } as Config & T;
}
function mergeConfigConst<const T extends Partial<Config>>(base: Config, patch: T) {
return { ...base, ...patch } as Config & T;
}
사용자가 "고정" 버전과 "일반" 버전을 선택할 수 있게 하면 업그레이드 호환성이 좋아집니다.
증상 2: 오버로드 선택이 바뀌어 반환 타입이 달라진다
TS는 오버로드에서 가장 먼저 매칭되는 시그니처를 고르는 경향이 있는데, const 추론으로 인수 타입이 더 구체화되면 "다른 오버로드"가 선택되는 일이 생깁니다.
재현: 문자열 리터럴이 고정되면서 특정 오버로드로 빨려 들어감
function read(key: string): string;
function read<const K extends "id" | "email">(key: K): K extends "id" ? number : string;
function read(key: string) {
return key === "id" ? 123 : "a@b.com";
}
const a = read("id");
// a: number
const k = "id";
const b = read(k);
// k가 string이면 b: string
// k가 "id"로 고정되면 b: number
TS 5.5에서 주변 문맥에 따라 k가 더 좁게 잡히면, 기존에는 string 오버로드로 가던 코드가 리터럴 오버로드로 가면서 반환 타입이 달라질 수 있습니다.
해결: 오버로드를 "구체적인 것부터"가 아니라 "의도한 우선순위"로 재정렬
오버로드는 설계가 중요합니다. 반환 타입이 바뀌면 하위 호환성 문제가 되므로, 아래처럼 범용 시그니처를 마지막 구현 시그니처로 두되, 구체 오버로드의 조건을 더 명확히 하거나, 필요하면 오버로드를 분리합니다.
function read<const K extends "id" | "email">(key: K): K extends "id" ? number : string;
function read(key: string): string;
function read(key: string) {
return key === "id" ? 123 : "a@b.com";
}
또는 호출부에서 의도적으로 넓혀서 특정 오버로드를 피할 수도 있습니다.
const k2: string = "id";
const v = read(k2);
// v: string
증상 3: 제약 조건과 결합되면 추론이 실패하거나 never로 수렴
const 타입 매개변수는 "리터럴을 유지"하지만, 그 리터럴이 제약 조건과 충돌하면 추론이 더 쉽게 막힐 수 있습니다.
재현: 키 배열을 받아 Pick을 만들 때 생기는 함정
type User = {
id: number;
email: string;
role: "admin" | "user";
};
function pick<UserObj, const K extends readonly (keyof UserObj)[]>(
obj: UserObj,
keys: K
): Pick<UserObj, K[number]> {
const out: any = {};
for (const k of keys) out[k] = (obj as any)[k];
return out;
}
const u: User = { id: 1, email: "a@b.com", role: "user" };
const p1 = pick(u, ["id", "email"]);
// 기대: { id: number; email: string }
여기서 keys가 다른 경로에서 만들어져 string[]로 넓어지면, K extends readonly (keyof UserObj)[] 제약을 만족하지 못해 에러가 나거나, 억지 캐스팅으로 never가 섞이기 시작합니다.
해결 1: 입력을 만드는 곳에서 as const 또는 satisfies로 키를 고정
const userKeys = ["id", "email"] as const;
const p2 = pick(u, userKeys);
또는 satisfies로 "키 유효성"을 보장합니다.
const userKeys2 = ["id", "email"] satisfies readonly (keyof User)[];
// userKeys2는 readonly ["id","email"]로 유지되기 쉬움
const p3 = pick(u, userKeys2);
해결 2: 라이브러리에서 입력 타입을 두 갈래로 받기
실전에서는 키 목록이 런타임에 만들어지는 경우도 많습니다. 그때는 "정밀 타입"과 "런타임 배열"을 분리해서 받는 오버로드가 안전합니다.
function pick<UserObj, const K extends readonly (keyof UserObj)[]>(
obj: UserObj,
keys: K
): Pick<UserObj, K[number]>;
function pick<UserObj>(
obj: UserObj,
keys: readonly (keyof UserObj)[]
): Partial<UserObj>;
function pick(obj: any, keys: readonly PropertyKey[]) {
const out: any = {};
for (const k of keys) out[k] = obj[k];
return out;
}
- 컴파일 타임에 키가 고정되면
Pick으로 정확한 타입 - 런타임 배열이면 안전하게
Partial로 다운그레이드
이 패턴은 const 타입 매개변수의 장점을 살리면서도, "모든 입력이 리터럴일 것"이라는 비현실적 가정을 제거합니다.
"추론 깨짐"을 빠르게 진단하는 체크리스트
- 리터럴이 과도하게 고정됐는지 확인
- 변수에 타입 주석이 없고, 객체/배열 리터럴이 직접 전달되는지
- 오버로드 선택이 바뀌었는지 확인
- TS 서버에서 타입 호버로 어떤 시그니처가 선택됐는지 확인
- 제약 조건이 너무 빡센지 확인
extends keyof ...같은 제약에서 입력이string[]로 넓어져 충돌하는지
- 해결 방향을 결정
- 호출부에서 넓힐지, 라이브러리에서 오버로드로 양쪽을 지원할지, 혹은
const를 제거할지
- 호출부에서 넓힐지, 라이브러리에서 오버로드로 양쪽을 지원할지, 혹은
실전 권장 패턴: const는 "정의 API"에, 일반 제네릭은 "데이터 API"에
경험적으로 const 타입 매개변수는 다음과 같은 곳에서 효과가 좋습니다.
- 라우트 정의, 이벤트 이름, 커맨드 이름처럼 "리스트를 선언"하는 API
- 스키마, 매핑 테이블처럼 "키와 값의 관계"를 고정하고 싶은 API
반면 다음은 const를 신중히 적용해야 합니다.
merge,assign,patch처럼 "데이터를 섞는" API- 런타임 입력 비중이 큰 함수 (예: 사용자 입력, DB 결과 기반 키 배열)
const를 붙이는 순간 사용자 코드의 리터럴이 더 강하게 전파되어, 타입이 지나치게 세분화되거나 오버로드가 바뀌는 등 호환성 이슈가 생길 수 있습니다.
마무리
TS 5.5의 const 타입 매개변수는 분명 강력하지만, "좋아진 추론"이 항상 "좋은 API"로 이어지지는 않습니다. 추론이 깨져 보이는 대부분의 사례는 사실 아래 둘 중 하나로 정리됩니다.
- 리터럴 고정이 의도보다 강해져서 타입이 과도하게 좁아짐
- 더 구체화된 타입 때문에 오버로드나 제약 조건 경로가 달라짐
해결은 의외로 단순합니다.
- 호출부에서는 타입 주석으로 넓히거나,
as const또는satisfies로 고정 의도를 명확히 하고 - 라이브러리에서는
const적용 범위를 줄이거나, 정밀 오버로드와 런타임 오버로드를 함께 제공하세요.
이렇게 정리해두면 TS 5.5 업그레이드 이후에도 "추론이 깨졌다"는 이슈를 재현 가능하게 만들고, 팀 내에서 일관된 해결책을 선택할 수 있습니다.