- Published on
TS 5.5 const type params로 리터럴 추론 고치기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버/프론트 경계에서 타입 안정성을 밀어붙이다 보면, 결국 발목을 잡는 건 “리터럴 추론이 왜 갑자기 string이 됐지?” 같은 순간입니다. 특히 fetch 래퍼, 라우팅 헬퍼, 이벤트 이름/액션 타입, 설정 키 목록처럼 문자열 리터럴 유니온을 기반으로 API를 설계할 때 이 문제가 자주 터집니다.
TypeScript는 기본적으로 “최대한 유연하게 쓰게 해주자”는 방향으로 추론이 동작합니다. 그래서 함수 인자로 들어온 값이 리터럴이어도, 제네릭을 거치거나 배열/객체로 포장되는 순간 리터럴이 넓은 타입으로 확장(widening) 되는 경우가 많습니다.
TypeScript 5.5에서 도입된 const type parameters(이하 const 타입 파라미터)는 이 문제를 정면으로 해결합니다. 이 글에서는
- 리터럴 추론이 깨지는 대표 패턴
- 기존 우회책의 한계
- TS 5.5
const타입 파라미터로 고치는 방법 - 실무에서 바로 쓰는 헬퍼 패턴
을 코드로 정리합니다.
리터럴 추론이 깨지는 전형적인 상황
1) 제네릭 함수에서 배열 인자가 string[] 으로 넓어짐
예를 들어 “허용된 키 목록”을 받아서 그 키만 pick 하는 헬퍼를 만든다고 합시다.
function pickKeys<T, K extends keyof T>(obj: T, keys: K[]) {
const out = {} as Pick<T, K>;
for (const k of keys) out[k] = obj[k];
return out;
}
const user = { id: 1, name: "a", role: "admin" as const };
const picked = pickKeys(user, ["id", "name"]);
// 기대: Pick<{...}, "id" | "name">
// 현실(자주): keys가 string[]로 넓어지면 K가 제대로 추론되지 않거나,
// 호출 지점에서 추가 타입 힌트가 필요해짐
왜 이런 일이 생길까요?
["id", "name"]는 문맥에 따라("id" | "name")[]로도 추론될 수 있지만- 제네릭 경계에서
K[]를 만족시키기 위해 넓게 잡히는 경우가 있습니다. - 특히
keys를 다른 변수로 빼거나, 함수 체인을 끼우면 widening이 더 쉽게 발생합니다.
2) 객체 인자의 값이 리터럴이 아닌 string 으로 변함
라우팅/액션 같은 곳에서 흔한 패턴입니다.
type Route = {
name: string;
path: string;
};
function defineRoute<T extends Route>(route: T) {
return route;
}
const r = defineRoute({ name: "home", path: "/" });
// 기대: name이 "home" 리터럴
// 현실: name: string, path: string 로 넓어질 수 있음
이건 as const 로 어느 정도 막을 수 있지만, as const 는 다음 단점이 있습니다.
- 객체 전체가 readonly가 되어 downstream에서 불편할 수 있음
- “딱 필요한 필드만 리터럴로 유지” 같은 미세 조정이 어려움
기존 해결책과 한계
as const 의 장단점
const r = defineRoute({ name: "home", path: "/" } as const);
- 장점: 간단하고 즉시 해결
- 단점: 객체 전체가 깊게 readonly가 되고, 타입이 지나치게 좁아져서 재사용이 불편해질 수 있음
overload 또는 보조 제네릭으로 유도하기
function pickKeys<T, const K extends readonly (keyof T)[]>(obj: T, keys: K) {
type KeyUnion = K[number];
const out = {} as Pick<T, KeyUnion>;
for (const k of keys) out[k] = obj[k];
return out;
}
위 코드는 사실상 “TS 5.5 방식”에 가까운 형태인데, TS 5.5 이전에는 const 타입 파라미터가 없어서 as const 를 강요하거나, 호출부에서 as const 를 붙이게 만드는 식으로 끝나는 경우가 많았습니다.
TS 5.5 const type parameters란?
핵심은 이겁니다.
- 제네릭 타입 파라미터 앞에
const를 붙이면 - 리터럴 타입을 최대한 보존한 채로 추론하려고 합니다.
즉, 함수 인자로 들어온 값이 리터럴이면, 제네릭을 통과하면서도 그 리터럴이 string 같은 넓은 타입으로 퍼지는 것을 줄여줍니다.
주의할 점:
const타입 파라미터는 “값을 const로 만든다”가 아니라 “타입 추론을 const처럼 한다”에 가깝습니다.- 런타임 동작은 바뀌지 않습니다.
배열/튜플 인자에서 리터럴 유니온 유지하기
가장 체감이 큰 케이스가 “키 목록”, “이벤트 목록” 같은 배열 인자입니다.
before: 호출부에서 as const 를 강요
function onEvents<E extends string>(events: E[], handler: (e: E) => void) {
// ...
}
onEvents(["open", "close"], (e) => {
// e가 string으로 넓어지면 분기에서 손해
});
after: const 타입 파라미터로 고치기
function onEvents<const E extends readonly string[]>(
events: E,
handler: (e: E[number]) => void
) {
// ...
}
onEvents(["open", "close"], (e) => {
// e: "open" | "close"
if (e === "open") {
// ...
}
});
포인트는 두 가지입니다.
E를readonly string[]로 받고- 실제 이벤트 유니온은
E[number]로 뽑습니다.
이 패턴은 pickKeys, allowlist, enum-like 헬퍼에 그대로 재사용됩니다.
객체 인자에서도 리터럴을 보존하기
라우트 정의, 설정 스키마, 액션 정의 같은 곳에서 유용합니다.
type RouteDef = {
name: string;
path: string;
method?: "GET" | "POST";
};
function defineRoute<const R extends RouteDef>(r: R) {
return r;
}
const route = defineRoute({
name: "home",
path: "/",
method: "GET",
});
// route.name: "home"
// route.path: "/"
// route.method: "GET"
여기서 중요한 실무적 이점은 다음입니다.
- 호출부에
as const를 붙이지 않아도 리터럴이 살아남음 - 객체가 불필요하게 deep readonly로 굳지 않음
- 라우트 이름 기반으로 타입 안전한 맵을 만들기 쉬워짐
실전 패턴 1: 타입 안전한 pick 유틸
pick 은 실무에서 자주 만들지만, 키 배열 때문에 타입이 깨지는 대표 사례입니다.
function pick<T, const K extends readonly (keyof T)[]>(obj: T, keys: K) {
type KeyUnion = K[number];
const out = {} as Pick<T, KeyUnion>;
for (const k of keys) out[k] = obj[k];
return out;
}
const user = { id: 1, name: "a", role: "admin" as const };
const a = pick(user, ["id", "role"]);
// a: { id: number; role: "admin" }
이제 호출부는 그냥 배열 리터럴을 넘기기만 하면 됩니다.
실전 패턴 2: 허용된 문자열 집합으로 validator 만들기
API 입력 검증이나 쿼리 파라미터에서 많이 씁니다.
function oneOf<const V extends readonly string[]>(...values: V) {
const set = new Set(values);
return (x: string): x is V[number] => set.has(x);
}
const isEnv = oneOf("dev", "stage", "prod");
function boot(env: string) {
if (!isEnv(env)) throw new Error("invalid env");
// 여기서 env: "dev" | "stage" | "prod"
}
...values 가 튜플로 유지되면서 V[number] 가 정확한 유니온이 됩니다.
실전 패턴 3: 라우트/핸들러 매핑에서 키 추론 유지
Next.js나 API 라우터를 만들 때 흔히 “이름 기반 매핑”을 합니다.
function defineHandlers<const R extends readonly { name: string }[]>(routes: R) {
type Names = R[number]["name"];
return function register<const H extends Record<Names, () => void>>(handlers: H) {
return handlers;
};
}
const register = defineHandlers([
{ name: "home" },
{ name: "settings" },
]);
const handlers = register({
home: () => {},
settings: () => {},
// other: () => {}, // 에러: 허용되지 않은 키
});
routes 의 name 들이 리터럴로 유지되기 때문에, handlers 의 키 제약이 정확해집니다.
이런 “키 기반 타입 안전성”은 프론트에서도 중요하지만, 서버에서도 “스키마 기반 라우팅”이나 “이벤트 기반 처리”에서 큰 효과가 있습니다. 스키마 검증을 다룬 글인 OpenAI Responses API 422 스키마 검증 에러 해결 가이드처럼, 입력 스키마를 엄격히 가져갈수록 타입 쪽도 리터럴 추론이 탄탄해야 디버깅 비용이 줄어듭니다.
const type params를 쓸 때의 체크리스트
1) 배열은 readonly 로 받기
const 타입 파라미터를 쓰더라도, 배열을 그냥 string[] 로 받으면 “튜플로 유지”가 깨질 수 있습니다.
- 권장:
const T extends readonly string[] - 그리고 유니온은
T[number]
2) 객체도 const 로 받되, 제약 타입을 너무 빡세게 잡지 않기
function defineThing<const T extends { name: string }>(t: T) {
return t;
}
이처럼 “필수 형태만 제약”하고 나머지는 열어두는 편이 실무에서 확장성이 좋습니다.
3) 반환 타입에 “추론 결과”를 그대로 노출하기
const 타입 파라미터로 얻은 이점을 살리려면, 반환 타입에서 그 정보를 잃지 않아야 합니다.
- 배열이면
T[number] - 객체면
T["field"] - 맵이면
keyof T등을 적극 사용
마이그레이션 관점: 기존 코드에 어떻게 적용할까?
- 리터럴 유니온이 핵심인 유틸부터 찾습니다.
pick,omit,select,allowlist,defineRoute,defineConfig- 이벤트 등록, 커맨드 등록, 라우터 등록
해당 함수의 제네릭에
const를 붙이고, 인자 타입을readonly로 조정합니다.호출부에 붙어 있던
as const를 제거해도 타입이 유지되는지 확인합니다.타입 테스트를 추가합니다.
// 타입 테스트 예시(컴파일 단계에서만 확인)
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends
(<T>() => T extends B ? 1 : 2)
? true
: false;
type Expect<T extends true> = T;
const events = ["open", "close"] as const;
type _t1 = Expect<Equal<(typeof events)[number], "open" | "close">>;
테스트 코드에서도 부등호 기호가 들어갈 수 있으니, MDX 환경에서는 본문에 > 나 < 가 노출되지 않도록 항상 코드 블록 안에만 두는 습관이 안전합니다.
정리
TypeScript 5.5의 const 타입 파라미터는 “리터럴 추론이 무너지는” 실무 고질병을 꽤 깔끔하게 정리해줍니다.
- 배열/튜플 인자에서 리터럴 유니온을 유지하려면
const T extends readonly ...[]와T[number]패턴을 씁니다. - 객체 인자에서도
const로 받아서as const의 과도한 readonly 전파 없이 리터럴을 살릴 수 있습니다. - 라우트/이벤트/핸들러 등록 같은 “키 기반 타입 안전성”이 필요한 곳에서 특히 효과가 큽니다.
Next.js 프로젝트에서 이런 헬퍼들이 늘어나면, 캐시/ISR 같은 런타임 이슈를 디버깅할 때도 타입이 단단한 쪽이 원인 범위를 빨리 줄여줍니다. 관련해서는 Next.js 14 캐시 때문에 ISR 갱신 안 될 때 디버깅도 함께 참고하면 좋습니다.
리터럴 추론이 흔들리는 지점을 const 타입 파라미터로 먼저 고정해두면, 이후의 리팩터링과 기능 추가가 훨씬 덜 무섭습니다.