- Published on
TS 5.5+ const 타입 파라미터로 추론 고정하기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버/프론트 공통 유틸을 만들다 보면 “호출 지점에서 넘긴 값의 리터럴 타입을 그대로 유지하고 싶다”는 요구가 자주 생깁니다. 예를 들어 라우트 목록, 이벤트 이름 목록, 권한 스코프 목록처럼 값 자체가 스키마 역할을 하는 데이터는 "user.created" 같은 리터럴이 string으로 넓어지는 순간 타입 안정성이 급격히 떨어집니다.
TypeScript는 원래도 리터럴 추론을 꽤 잘 해주지만, 제네릭 함수의 매개변수로 들어오는 순간 추론이 넓어지거나(특히 배열), 혹은 as const를 매번 강제해야 하는 상황이 생깁니다. TS 5.5+에서 도입된 const 타입 파라미터는 이런 문제를 “함수 시그니처 차원에서” 해결해 줍니다.
이 글에서는 const 타입 파라미터가 무엇인지, 어디에 쓰면 좋은지, 기존의 as const / 오버로드 / satisfies와 어떤 관계인지까지 실전 예제로 정리합니다. satisfies를 통한 추론-검증 동시 해결은 아래 글도 함께 보면 맥락이 더 잘 이어집니다.
const 타입 파라미터란
const 타입 파라미터는 제네릭 타입 파라미터 선언에 const를 붙여 추론 결과를 가능한 한 리터럴(좁은 타입)로 유지하도록 지시합니다.
형태는 아래처럼 생깁니다.
function f<const T>(value: T): T {
return value;
}
핵심 효과는 다음과 같습니다.
- 배열 리터럴을 넘길 때
string[]로 넓어지기 쉬운 상황에서readonly ["a", "b"]같은 튜플/리터럴을 더 잘 유지 - 객체 리터럴의 프로퍼티가
string으로 넓어지는 경우를 줄이고, 가능한 리터럴로 유지 - 호출자에게
as const를 매번 강요하지 않고도 “값 기반 API”를 만들기 쉬움
주의할 점도 있습니다.
const는 “무조건 튜플로 만들어라”가 아니라, 추론을 더 좁게 유지하려는 힌트입니다. 컨텍스트 타입, 매개변수 타입 선언 방식에 따라 결과가 달라질 수 있습니다.- 리터럴 고정이 과하면 재사용성이 떨어질 수 있으니, “정말 값이 스키마인 부분”에만 쓰는 게 좋습니다.
왜 기존 방식만으로는 부족했나
문제 1: 배열 인자에서 유니온이 풀려버림
이벤트 이름을 배열로 받고, 그 유니온을 뽑아 타입으로 쓰고 싶다고 해봅시다.
function makeEmitter<T extends string>(events: T[]) {
return {
on(event: T, handler: () => void) {
// ...
},
};
}
const emitter = makeEmitter(["created", "deleted"]);
// 기대: T = "created" | "deleted"
// 현실: T = string (상황에 따라 넓어짐)
호출부에서 as const를 붙이면 해결되지만, 라이브러리/유틸 관점에서는 그게 늘 최선은 아닙니다.
const emitter2 = makeEmitter(["created", "deleted"] as const);
문제 2: 오브젝트 리터럴에서도 값이 넓어짐
function defineRoutes<T extends Record<string, string>>(routes: T) {
return routes;
}
const routes = defineRoutes({
home: "/",
user: "/users/:id",
});
// routes.user가 "/users/:id" 리터럴로 남길 기대가 있지만
// 종종 string으로 넓어지는 설계가 나옵니다.
여기서도 as const로 고정할 수는 있지만, 호출자 경험이 나빠지고 “왜 필요한지” 설명 비용이 생깁니다.
const 타입 파라미터로 추론을 고정하는 패턴
1) 배열 리터럴을 튜플로 유지: 이벤트/명령 목록
const를 붙이면 호출 시점의 배열 리터럴이 더 잘 보존됩니다.
function makeEmitter<const T extends readonly string[]>(events: T) {
type Event = T[number];
return {
on(event: Event, handler: () => void) {
// ...
},
};
}
const emitter = makeEmitter(["created", "deleted"]);
emitter.on("created", () => {});
// emitter.on("updated", () => {}); // 타입 에러
포인트는 두 가지입니다.
- 타입 파라미터에
const를 붙임:const T - 매개변수 타입을
readonly string[]계열로 받음:T extends readonly string[]
이렇게 하면 굳이 호출부에서 as const를 쓰지 않아도, T[number]가 안정적으로 유니온을 만들어줍니다.
2) 객체 리터럴의 값 리터럴 유지: 라우트/스키마 정의
function defineRoutes<const T extends Record<string, string>>(routes: T) {
return routes;
}
const routes = defineRoutes({
home: "/",
user: "/users/:id",
});
type UserRoute = typeof routes.user;
// 기대대로 "/users/:id" 리터럴로 유지될 가능성이 커집니다.
여기서 얻는 실전 이점은 “문자열 기반 DSL”을 만들 때 큽니다.
- 라우트 패턴 문자열
- GraphQL operation name
- 권한 스코프 문자열
- 피처 플래그 키
3) 키 집합을 입력받아 안전한 pick 만들기
다음은 객체와 키 배열을 받아 pick을 구현하는 흔한 예시입니다. 키 배열이 string[]로 넓어지면 결과 타입이 무너집니다.
function pick<const TObj extends Record<string, unknown>, const TKeys extends readonly (keyof TObj)[]>(
obj: TObj,
keys: TKeys
): Pick<TObj, TKeys[number]> {
const out: Partial<TObj> = {};
for (const k of keys) {
out[k] = obj[k];
}
return out as Pick<TObj, TKeys[number]>;
}
const user = { id: 1, name: "kim", admin: false };
const picked = pick(user, ["id", "name"]);
// picked: { id: number; name: string }
// pick(user, ["id", "nope"]); // 타입 에러
여기서 keys가 리터럴 튜플로 유지되면 TKeys[number]가 정확한 유니온이 되고, 반환 타입이 깔끔하게 떨어집니다.
4) “옵션 객체”에서 플래그를 리터럴로 유지
옵션 객체의 불리언이 boolean으로 넓어지면 분기 기반 반환 타입을 만들기 어렵습니다.
type ParseOptions = {
strict?: boolean;
returnType?: "json" | "text";
};
function parseResponse<const T extends ParseOptions>(
input: string,
options?: T
): T extends { returnType: "json" } ? unknown : string {
const rt = options?.returnType;
if (rt === "json") return JSON.parse(input) as any;
return input as any;
}
const a = parseResponse("{\"x\":1}", { returnType: "json" });
// a: unknown
const b = parseResponse("hello", { returnType: "text" });
// b: string
const T가 없으면 returnType이 쉽게 "json" | "text"로 넓어져 조건부 타입이 흐릿해질 수 있습니다.
const 타입 파라미터 vs as const vs satisfies
as const와의 관계
as const는 “값 자체를 읽기 전용 + 리터럴로 고정”하는 강력한 도구- 하지만 호출자에게 매번 요구하면 API 사용성이 떨어짐
const 타입 파라미터는 다음 상황에서 특히 좋습니다.
- 라이브러리/유틸 함수가 호출부 리터럴을 최대한 보존해주길 원할 때
as const를 강제하지 않고도 대부분의 케이스에서 원하는 추론을 얻고 싶을 때
반대로 다음 상황에서는 여전히 as const가 필요할 수 있습니다.
- 값이 변수에 담기면서 이미 넓어진 뒤 전달되는 경우
- “반드시 readonly 튜플/readonly 객체”가 되어야만 하는 경우
예를 들어 아래처럼 중간 변수에 담으면 넓어질 수 있습니다.
const events = ["created", "deleted"]; // string[]로 넓어질 수 있음
const emitter = makeEmitter(events);
이때는 중간 변수 선언에서 as const 또는 명시 타입이 필요합니다.
const events = ["created", "deleted"] as const;
const emitter = makeEmitter(events);
satisfies와의 관계
satisfies는 “값이 어떤 타입 조건을 만족하는지 검증”하면서도 추론은 유지하는 데 강점- const 타입 파라미터는 “함수 호출에서의 추론을 더 좁게 유지”하는 데 강점
둘은 경쟁 관계가 아니라, 같이 쓰면 더 강력합니다.
type RouteTable = Record<string, `/${string}`>;
function defineRoutes<const T extends RouteTable>(routes: T) {
return routes;
}
const routes = defineRoutes({
home: "/",
user: "/users/:id",
} satisfies RouteTable);
위 패턴은 다음을 동시에 달성합니다.
satisfies로 값이RouteTable규칙을 만족하는지 검증const T로 각 값의 리터럴을 최대한 유지
실전 적용 체크리스트
1) “값 기반 API”인가를 먼저 판단
const 타입 파라미터는 특히 다음 케이스에서 투자 대비 효과가 큽니다.
- 이벤트/액션 이름 목록에서 유니온 타입을 뽑아야 함
- 라우트/쿼리 키/피처 플래그 키처럼 문자열 리터럴이 곧 계약인 경우
pick/omit/groupBy같은 유틸에서 키 배열 리터럴을 보존해야 함
반대로 단순 데이터 처리 함수(예: 숫자 배열 합계)에는 굳이 필요 없습니다.
2) 타입 파라미터 제약을 readonly로 잡기
배열 인자를 받는다면 보통 아래처럼 시작하는 게 안전합니다.
const T extends readonly unknown[]- 혹은
const T extends readonly string[]
이렇게 해야 튜플 추론과 잘 맞습니다.
3) 반환 타입에서 T[number], keyof T를 적극 활용
const 타입 파라미터의 장점은 “리터럴이 남아있을 때” 폭발합니다.
- 배열이면
T[number]로 유니온 생성 - 객체면
keyof T로 키 유니온 생성 - 매핑 타입으로 결과 스키마 생성
디버깅 팁: 추론이 넓어질 때 보는 포인트
- 값이 한 번이라도 변수에 저장되며 넓어지지 않았는지 확인
const x = ["a", "b"];는 상황에 따라string[]const x = ["a", "b"] as const;는readonly ["a", "b"]
- 함수 매개변수 타입이 너무 넓게 선언되어 있지 않은지 확인
예를 들어 아래는 T를 좁히기 어렵게 만듭니다.
function bad<const T>(x: string[]): T {
throw new Error("no");
}
입력 타입을 T와 연결해야 합니다.
function good<const T extends readonly string[]>(x: T): T {
return x;
}
- 컨텍스트 타입이 리터럴을 덮어쓰고 있지 않은지 확인
객체 리터럴을 특정 타입으로 먼저 주석 처리하면 값이 넓어질 수 있습니다.
type Opt = { mode: string };
const opt: Opt = { mode: "dev" }; // mode가 string으로 확정
이때는 satisfies가 대안입니다.
type Opt = { mode: string };
const opt = { mode: "dev" } satisfies Opt; // mode는 "dev" 리터럴 유지 가능
마무리
TS 5.5+의 const 타입 파라미터는 “호출부 리터럴을 최대한 유지해 타입 계약을 강화하는” 도구입니다. 특히 배열/객체 리터럴을 입력받아 유니온 타입을 만들거나, 옵션 객체 기반으로 조건부 반환 타입을 제공하는 유틸에서 효과가 큽니다.
정리하면 다음처럼 가져가면 실전에서 실패 확률이 낮습니다.
- 배열 입력:
function f<const T extends readonly string[]>(x: T)패턴으로 시작 - 키 배열 유틸:
const TKeys extends readonly (keyof TObj)[]조합 - 스키마 검증은
satisfies로, 호출 추론 고정은 const 타입 파라미터로 역할 분담
이미 satisfies를 적극 쓰고 있다면, 이제는 “함수 경계에서의 추론”을 const 타입 파라미터로 한 단계 더 단단하게 만들 수 있습니다.