- Published on
TS 5.5+ const 타입파라미터로 추론 고정하기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서드파티 라이브러리나 사내 유틸을 만들다 보면, “호출부에서 넘긴 값의 형태를 타입 수준에서 그대로 보존하고 싶다”는 요구가 자주 나옵니다. 예를 들어 라우트 정의, 이벤트 이름 목록, 허용 필드 리스트 같은 DSL을 만들 때, 배열이 string[]로 넓혀지거나 객체 리터럴이 과하게 일반화되면(리터럴이 사라지면) 타입 안전성이 급격히 떨어집니다.
TypeScript 5.5+에서 도입된 const 타입파라미터는 이런 문제를 해결하는 핵심 도구입니다. 함수의 제네릭 타입파라미터에 const를 붙이면, 호출 시 전달된 리터럴/튜플/객체의 “구체성”을 더 잘 보존하도록 추론을 유도할 수 있습니다. 결과적으로 호출부에서 as const를 남발하지 않고도, 반환 타입이나 후속 제네릭 계산이 원하는 수준으로 정확해집니다.
아래에서 왜 필요한지, 기존 대안의 한계는 무엇인지, 그리고 실전에서 어떻게 설계하면 좋은지 코드로 정리해 보겠습니다.
문제: 추론이 넓혀져서 타입이 무너지는 순간
가장 흔한 케이스는 배열/튜플입니다. 특정 키 목록을 받고 그 키만 선택하는 유틸을 만든다고 해봅시다.
type PickByKeys<T, K extends readonly (keyof T)[]> = Pick<T, K[number]>;
function pickKeys<T, K extends readonly (keyof T)[]>(obj: T, keys: K): PickByKeys<T, K> {
const out: any = {};
for (const k of keys) out[k] = (obj as any)[k];
return out;
}
const user = { id: 1, name: "kim", email: "a@b.com" };
const r1 = pickKeys(user, ["id", "email"]);
// 기대: { id: number; email: string }
// 현실(자주 발생): keys가 string[]로 넓혀져 K[number]가 keyof T 전체가 되어 타입이 흐려짐
호출부에서 ["id", "email"]이 “튜플”로 남아야 K[number]가 정확히 "id" | "email"이 되는데, 상황에 따라 string[]로 넓혀지면 K[number]가 keyof T로 퍼져 버립니다.
기존 해결책 1: as const
const r2 = pickKeys(user, ["id", "email"] as const);
동작은 하지만 호출부가 지저분해지고, “타입을 위해 런타임 코드를 오염”시키는 느낌이 들며, 팀 차원에서 일관되게 적용하기도 어렵습니다.
기존 해결책 2: 오버로드/도우미 함수
오버로드로 튜플 케이스를 따로 잡거나, tuple("id", "email") 같은 헬퍼를 만들기도 합니다. 하지만 API가 복잡해지고 유지보수 비용이 증가합니다.
TS 5.5+ 해법: const 타입파라미터로 추론을 고정
핵심은 제네릭에 const를 붙이는 것입니다.
type PickByKeys<T, K extends readonly (keyof T)[]> = Pick<T, K[number]>;
function pickKeys<T, const K extends readonly (keyof T)[]>(obj: T, keys: K): PickByKeys<T, K> {
const out: any = {};
for (const k of keys) out[k] = (obj as any)[k];
return out;
}
const user = { id: 1, name: "kim", email: "a@b.com" };
const r = pickKeys(user, ["id", "email"]);
// r: { id: number; email: string }
const K는 “가능하면 리터럴/튜플 형태로 추론해 달라”는 힌트로 작동합니다. 덕분에 호출부는 깔끔하게 유지하면서도 타입은 정밀하게 보존됩니다.
const 타입파라미터가 특히 강력한 패턴들
1) 이벤트/액션 이름을 안전하게 고정하기
이벤트 버스나 액션 디스패처를 만들 때, 이벤트 이름 목록이 string[]로 넓혀지면 이후 매핑이 전부 무너집니다.
type EventMapFromNames<Names extends readonly string[]> = {
[K in Names[number]]: unknown;
};
function defineEvents<const Names extends readonly string[]>(...names: Names) {
return names;
}
const events = defineEvents("user.created", "user.deleted");
// events: readonly ["user.created", "user.deleted"]
type Events = EventMapFromNames<typeof events>;
// type Events = {
// "user.created": unknown;
// "user.deleted": unknown;
// }
이 패턴은 “문자열 리터럴 유니온을 안전하게 뽑아내고 싶다”는 거의 모든 DSL에 재사용됩니다.
2) 라우팅 정의 DSL에서 경로/메서드 리터럴 보존
프론트엔드 라우터나 API 클라이언트에서, 라우트 정의를 리터럴로 유지하면 파생 타입(예: 경로별 파라미터, 메서드 제한)을 강하게 만들 수 있습니다.
type Route = {
method: "GET" | "POST" | "PUT" | "DELETE";
path: string;
};
function defineRoutes<const R extends readonly Route[]>(routes: R) {
return routes;
}
const routes = defineRoutes([
{ method: "GET", path: "/users" },
{ method: "POST", path: "/users" },
{ method: "GET", path: "/users/:id" },
]);
type Paths = typeof routes[number]["path"];
// "/users" | "/users/:id"
type Methods = typeof routes[number]["method"];
// "GET" | "POST"
이런 식으로 “정의에서 타입을 뽑아” 자동완성/검증을 강화할 수 있습니다. 인증/인가 흐름처럼 상태 값이 다양하고 실수가 치명적인 영역에서는 특히 효과적입니다. 관련해서 OAuth 같은 프로토콜 디버깅 글도 함께 보면, 타입으로 실수를 줄이는 관점이 더 잘 연결됩니다: NextAuth.js OAuth 401 - state·PKCE 오류 해결
3) 허용 필드 리스트 기반의 안전한 쿼리 빌더
백엔드/프론트 공통으로 “select 가능한 필드만 허용” 같은 요구가 많습니다.
type SelectResult<T, Fields extends readonly (keyof T)[]> = Pick<T, Fields[number]>;
function selectFields<T, const Fields extends readonly (keyof T)[]>(
row: T,
fields: Fields
): SelectResult<T, Fields> {
const out: any = {};
for (const f of fields) out[f] = (row as any)[f];
return out;
}
type User = { id: number; name: string; email: string; createdAt: string };
const u: User = { id: 1, name: "kim", email: "a@b.com", createdAt: "2026-01-01" };
const s = selectFields(u, ["id", "createdAt"]);
// { id: number; createdAt: string }
데이터 계층에서 이런 타입 안정성을 확보하면, 런타임 장애(예: 잘못된 컬럼 접근)와 디버깅 비용을 줄이는 데 도움이 됩니다. DB 레벨에서의 사고 대응 관점은 다음 글도 참고할 만합니다: MySQL InnoDB 데드락(1213) 로그로 범인 찾기
const 타입파라미터 사용 시 주의할 점
1) 모든 것이 “자동으로” 리터럴이 되는 건 아니다
const 타입파라미터는 추론 전략을 개선하지만, 입력이 이미 넓혀진 변수라면 되돌릴 수 없습니다.
const keys = ["id", "email"]; // 여기서 이미 string[]로 추론될 수 있음(상황에 따라)
const r = pickKeys({ id: 1, email: "x" }, keys);
// keys가 넓혀졌다면 결과도 넓어짐
해결은 두 가지입니다.
- 변수 선언 시점에서 리터럴을 보존:
as const또는satisfies - 혹은 애초에 “정의 함수”를 통해 리터럴을 캡처
const keys2 = ["id", "email"] as const;
const 타입파라미터는 “호출 시점 리터럴”에 특히 강합니다. 따라서 DSL을 설계할 때는 사용자가 리터럴을 직접 넘기게 하거나, defineXxx 형태로 한 번 캡처하는 API가 깔끔합니다.
2) satisfies와의 조합이 매우 좋다
객체 배열 DSL에서 각 원소가 특정 형태를 만족하는지 검증하면서도 리터럴은 유지하고 싶다면 satisfies가 유용합니다.
type Route = { method: "GET" | "POST"; path: string };
const routes = [
{ method: "GET", path: "/users" },
{ method: "POST", path: "/users" },
] as const satisfies readonly Route[];
이 방식은 “타입 검증”과 “리터럴 보존”을 동시에 잡습니다.
3) 반환 타입 설계에서 readonly를 의식하자
const 타입파라미터로 잡힌 튜플/배열은 보통 readonly로 추론됩니다. DSL 정의 결과를 외부에서 변경하지 못하게 하는 데는 장점이지만, 이후 로직에서 가변 배열이 필요하면 변환 단계가 필요할 수 있습니다.
const names = defineEvents("a", "b");
const mutable = [...names]; // string[]
실전 예제: 타입 안전한 핸들러 레지스트리
이벤트 이름 목록을 고정하고, 그 이름에 대한 핸들러를 강제하는 레지스트리를 만들어 보겠습니다.
type Handler = (payload: unknown) =\u003e void;
type Registry<Names extends readonly string[]> = {
on: (name: Names[number], handler: Handler) =\u003e void;
emit: (name: Names[number], payload: unknown) =\u003e void;
};
function createRegistry<const Names extends readonly string[]>(...names: Names): Registry<Names> {
const handlers = new Map<string, Handler[]>();
for (const n of names) handlers.set(n, []);
return {
on(name, handler) {
handlers.get(name)?.push(handler);
},
emit(name, payload) {
for (const h of handlers.get(name) ?? []) h(payload);
},
};
}
const reg = createRegistry("user.created", "user.deleted");
reg.on("user.created", (p) =\u003e console.log(p));
reg.emit("user.deleted", { id: 1 });
// reg.on("user.updated", () =\u003e {});
// 컴파일 에러: "user.updated"는 허용되지 않음
위 예제에서 화살표 함수 기호 때문에 본문에 =와 \u003e 형태로 표기했는데, 이는 MDX에서 부등호가 노출될 때 생길 수 있는 파싱 문제를 피하기 위한 안전장치입니다. 실제 코드베이스에서는 일반적인 =와 > 형태로 작성하면 됩니다.
이처럼 const 타입파라미터는 “정의된 집합에서만 선택 가능”이라는 제약을 간결하게 구현하게 해줍니다. 런타임 검증을 완전히 대체하진 못하지만, 실수의 상당 부분을 컴파일 단계에서 제거합니다.
언제 const 타입파라미터를 쓰면 좋은가
- 문자열/숫자 리터럴 집합을 기반으로 유니온을 만들고 싶을 때
- 튜플 길이와 각 원소의 리터럴을 보존해야 할 때
- DSL 정의에서 파생 타입을 많이 뽑아내는 라이브러리/유틸을 만들 때
- 호출부에
as const를 강요하고 싶지 않을 때
반대로, 입력이 이미 넓혀져 들어오는 경우가 대부분(예: 외부에서 받아온 string[])이라면 const 타입파라미터만으로는 효과가 제한적이고, 런타임 검증 및 스키마(예: Zod) 같은 도구와 함께 설계하는 편이 낫습니다.
마무리
TS 5.5+의 const 타입파라미터는 “추론을 더 구체적으로 유지”하게 만드는 작은 문법이지만, DSL/유틸 설계의 사용성을 크게 바꿉니다. 특히 키 리스트, 라우트 정의, 이벤트 이름처럼 리터럴 집합이 API 안정성의 핵심인 영역에서 효과가 큽니다.
기존에는 호출부 as const나 오버로드로 해결하던 문제를, 이제는 함수 시그니처 한 줄로 정리할 수 있습니다. 사내 공용 유틸부터 시작해서, 타입이 무너지는 지점을 const 타입파라미터로 하나씩 고정해 보시면 체감이 클 것입니다.
추가로 배포/운영 환경에서 “작은 실수”가 큰 장애로 이어지는 사례들을 함께 보면, 컴파일 타임 안정성에 투자할 이유가 더 분명해집니다: GitHub Actions 캐시가 안 먹을 때 key 전략과 디버깅