- Published on
TS 5.5 const 타입 파라미터로 제네릭 추론 고치기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서드파티 SDK나 사내 유틸을 만들다 보면 “런타임 값은 분명히 고정인데, 타입은 왜 이렇게 넓어지지?” 같은 순간이 자주 옵니다. 특히 배열/튜플을 입력으로 받아 그 요소를 기반으로 반환 타입을 구성하는 API에서 제네릭 추론이 string[] 같은 형태로 퍼져버리면, 이후의 체이닝과 자동완성 품질이 급격히 떨어집니다.
TypeScript 5.5는 이런 문제를 자주 일으키던 패턴을 더 깔끔하게 해결할 수 있도록 const 타입 파라미터를 도입했습니다. 핵심은 “호출 시 전달한 리터럴/튜플 구조를 제네릭 추론 단계에서 더 강하게 보존”하는 것입니다.
이 글에서는 TS 5.5의 const 타입 파라미터가 무엇을 해결하는지, 기존의 as const/오버로드/헬퍼 함수와 비교하면 어떤 차이가 있는지, 그리고 실무에서 바로 적용 가능한 패턴을 코드로 정리합니다. 추가로 타입 시스템 관점에서 비슷한 맥락의 문제 해결 글로 TS 5.7 - satisfies로 타입 좁히기 실패 해결도 함께 보면 좋습니다.
제네릭 추론이 “넓어지는” 전형적인 문제
예를 들어, 키 목록을 받아 해당 키만 pick 해서 반환하는 함수를 만든다고 해보겠습니다.
type User = {
id: string;
name: string;
age: number;
};
function pick<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: User = { id: "u1", name: "kim", age: 20 };
const r1 = pick(user, ["id", "name"]);
// 기대: { id: string; name: string }
// 실제(자주 발생): K가 string으로 넓어지거나, keys가 ("id"|"name")[]로 잘 잡히지 않으면 결과도 덜 정밀해짐
여기서 관건은 두 번째 인자인 keys가 “튜플 리터럴”로 남아 있어야 K가 정확히 "id" | "name"으로 추론된다는 점입니다. 하지만 호출부에서 배열 리터럴은 상황에 따라 쉽게 string[]로 넓어질 수 있고, 그러면 K도 넓어져 결과 타입이 무의미해집니다.
실무에서는 이런 케이스가 더 자주 등장합니다.
- 라우트 정의:
createRoutes([ ... ]) - 이벤트 이름 목록:
on(["created", "deleted"], handler) - SQL select 컬럼 목록:
select(["id", "name"]) - i18n 키 목록:
t(["home.title", "home.desc"])
TS 5.5 const 타입 파라미터란
TS 5.5의 const 타입 파라미터는 제네릭 타입 파라미터 선언에 const를 붙여, 추론 시 리터럴/튜플 성질을 더 강하게 보존하도록 하는 기능입니다.
형태는 다음과 같습니다.
function f<const T>(value: T) {
return value;
}
이때 T는 단순히 “readonly가 된다”가 아니라, “추론이 리터럴 중심으로 고정되는 방향”으로 작동합니다. 특히 배열 리터럴을 넣었을 때 T가 string[]로 넓어지기보다 readonly ["id", "name"] 같은 튜플로 잡히는 것이 중요한 포인트입니다.
pick 예제를 const 타입 파라미터로 고치기
아까의 pick을 TS 5.5 방식으로 개선해보겠습니다.
type User = {
id: string;
name: string;
age: number;
};
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: User = { id: "u1", name: "kim", age: 20 };
const r2 = pick(user, ["id", "name"]);
// r2: { id: string; name: string }
여기서 바뀐 점은 두 가지입니다.
K를const타입 파라미터로 선언해서,["id", "name"]가 튜플로 더 잘 유지되도록 함keys의 타입을K[]가 아니라K그 자체로 받아서(즉, “튜플/readonly 배열”을 그대로 받음)K[number]로 유니온을 뽑아냄
이 패턴은 “배열을 입력받아 그 요소 유니온을 기반으로 반환 타입을 구성”하는 함수에 거의 그대로 적용됩니다.
왜 readonly가 함께 나오나
const 타입 파라미터는 튜플/리터럴 보존과 매우 친하고, 이때 결과 타입은 대개 readonly 성격을 띱니다. 그래서 제약을 readonly (keyof T)[]로 거는 것이 자연스럽습니다.
호출부에서 굳이 as const를 쓰지 않아도 되는 게 장점이지만, 타입 제약 자체는 “readonly 배열도 받는다”로 열어두는 편이 좋습니다.
기존 해결책과 비교: as const 남발 vs API 설계
TS 5.5 이전에는 보통 다음 중 하나로 해결했습니다.
1) 호출부에서 as const 강제
const r = pick(user, ["id", "name"] as const);
단점은 호출부가 지저분해지고, 라이브러리 사용자가 매번 타입 트릭을 기억해야 한다는 점입니다.
2) 헬퍼 tuple 함수로 감싸기
const tuple = <T extends readonly unknown[]>(...t: T) => t;
const r = pick(user, tuple("id", "name"));
호출은 깔끔하지만, “왜 이런 헬퍼가 있어야 하지?”라는 학습 비용이 생깁니다.
3) 오버로드로 경우의 수 나열
키가 1개, 2개, 3개일 때 오버로드를 만들면 어느 정도 해결되지만, 유지보수 비용이 매우 큽니다.
TS 5.5의 const 타입 파라미터는 이 문제를 “API 설계로 해결”하게 해줍니다. 즉, 라이브러리 작성자가 한 번 고치면 사용자 경험이 전반적으로 좋아집니다.
실전 패턴 1: createQuery에서 select 컬럼 추론
ORM/쿼리 빌더 스타일 API에서 select(["id", "name"]) 같은 입력은 흔합니다.
type Row = {
id: number;
name: string;
email: string;
};
function select<const Cols extends readonly (keyof Row)[]>(cols: Cols) {
type Selected = Pick<Row, Cols[number]>;
return {
cols,
map(row: Row): Selected {
const out = {} as Selected;
for (const c of cols) out[c] = row[c];
return out;
},
};
}
const q = select(["id", "email"]);
// q.map(...)의 반환이 { id: number; email: string }로 유지됨
포인트는 Cols[number]로 유니온을 뽑는 방식입니다. 이때 Cols가 튜플로 잡혀야 "id" | "email"이 되는데, const 타입 파라미터가 그 역할을 안정적으로 해줍니다.
실전 패턴 2: 라우트 정의에서 path 파라미터 추론 유지
라우트 테이블을 배열로 정의하고, 그 배열을 기반으로 타입 안전한 navigate를 만들고 싶은 경우가 많습니다.
type Route = {
name: string;
path: string;
};
function defineRoutes<const R extends readonly Route[]>(routes: R) {
type Names = R[number]["name"];
function navigate(name: Names) {
return name;
}
return { routes, navigate };
}
const appRoutes = defineRoutes([
{ name: "home", path: "/" },
{ name: "user", path: "/users/:id" },
]);
appRoutes.navigate("home");
// appRoutes.navigate("settings"); // 컴파일 에러
이런 구조는 Next.js/React Router/사내 라우터 등 어디든 적용됩니다. 특히 라우트 목록이 “그냥 배열 리터럴”일 때 추론이 무너지기 쉬운데, const 타입 파라미터로 라우트의 리터럴 성질을 최대한 보존할 수 있습니다.
Next.js를 쓰는 팀이라면 런타임 이슈와 타입 이슈가 함께 얽히는 경우가 많은데, 렌더 단계 문제는 Next.js Hydration mismatch 원인 9가지와 해결법도 참고할 만합니다.
주의할 점: “항상 더 좁게”가 정답은 아니다
const 타입 파라미터는 강력하지만, 다음 상황에서는 의도치 않게 너무 좁아져 불편할 수 있습니다.
- 입력이 사용자 입력/외부 데이터라서 원래 넓어야 하는 경우
- 리터럴이 지나치게 고정되어, 후속 연산에서 재사용성이 떨어지는 경우
예를 들어, function f<const T extends string>(x: T): T는 호출부에서 "abc"를 넣으면 반환도 "abc"로 고정됩니다. 어떤 API는 그게 장점이지만, 어떤 API는 오히려 string을 원할 수 있습니다.
이럴 땐 다음 중 하나를 선택합니다.
const를 빼고 기존 추론을 유지- 반환 타입을 의도적으로 widen 하도록 설계(예:
string으로 반환) - 입력 타입을
T | string같은 형태로 조정
마이그레이션 가이드: 기존 코드에 어떻게 적용할까
사내 유틸/라이브러리에 적용할 때는 아래 순서가 안전합니다.
- 배열/튜플 입력을 받는 제네릭 함수 중, 반환 타입이
T[number]또는K[number]에 의존하는 곳을 찾기 - 해당 제네릭 파라미터를
const로 바꾸고, 파라미터 타입을K[]가 아니라K로 받도록 수정 - 제약을
readonly ...[]로 바꿔 호출부의 호환성을 확보 - 호출부의
as const를 점진적으로 제거(테스트/타입체크 통과 확인)
예시 체크리스트는 다음과 같습니다.
// 변경 전
function f<T, K extends string>(keys: K[]) {
type U = K;
return keys;
}
// 변경 후
function f<const K extends readonly string[]>(keys: K) {
type U = K[number];
return keys;
}
정리
TS 5.5의 const 타입 파라미터는 “리터럴/튜플 기반 API에서 제네릭 추론이 넓어져 타입 정보가 사라지는 문제”를 라이브러리 설계 단계에서 해결하게 해줍니다.
- 호출부
as const의존도를 줄이고 K[number]패턴의 정확도를 높이며- 라우트/쿼리/이벤트/스키마 같은 선언적 배열 정의에서 DX를 크게 개선합니다.
타입이 기대보다 넓어져 자동완성이 망가지거나, 반환 타입이 Record<string, ...> 같은 형태로 퉁쳐지는 순간이 있다면, 그 함수의 제네릭 파라미터에 const를 붙일 수 있는지부터 점검해보는 것이 가장 빠른 개선책입니다.