- Published on
TS 5.5+ const 타입 파라미터로 안전하게 좁히기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서드파티 SDK나 사내 유틸 함수를 만들다 보면, 호출자가 넘긴 값의 “리터럴 정보”가 함수 내부에서 사라져 타입이 넓어지는 문제가 자주 발생합니다. 예를 들어 ['id', 'name']을 넘겼는데 내부에서는 string[]로 추론되어, 결과 타입도 덩달아 any/unknown 또는 과하게 넓은 형태가 되는 식입니다.
TypeScript 5.5+에서 도입된 const 타입 파라미터는 이런 상황에서 호출 시점의 리터럴 타입을 더 강하게 보존하도록 유도합니다. 결과적으로
as const남발을 줄이고- 오버로드 개수를 줄이며
- 반환 타입을 더 정밀하게 만들고
- 런타임 분기와 타입 분기를 자연스럽게 맞출 수 있습니다.
아래에서 “왜 넓어지는지”, “const 타입 파라미터가 무엇을 바꾸는지”, “실무 패턴에서 어떻게 쓰는지”를 코드로 정리합니다.
왜 타입이 넓어질까: 함수 경계에서 리터럴이 증발하는 패턴
TypeScript는 기본적으로 리터럴을 상황에 따라 넓혀(widening) string, number, boolean 같은 기본 타입으로 추론합니다. 특히 제네릭 함수의 매개변수로 들어오면, 추론 결과가 “너무 일반적”이 되기 쉽습니다.
예를 들어 특정 키 목록만 뽑는 pick을 만든다고 해보겠습니다.
type PickResult<T, K extends readonly (keyof T)[]> = {
[P in K[number]]: T[P];
};
function pick<T, K extends readonly (keyof T)[]>(obj: T, keys: K): PickResult<T, K> {
const out: any = {};
for (const k of keys) out[k] = (obj as any)[k];
return out;
}
const user = { id: 1, name: 'neo', email: 'neo@example.com' };
const r1 = pick(user, ['id', 'name']);
// 기대: { id: number; name: string }
// 현실: keys가 (string | number | symbol)[] 쪽으로 넓어지면 결과도 넓어질 수 있음
호출부에서 ['id', 'name']은 얼핏 리터럴 배열처럼 보이지만, 문맥에 따라 string[]로 추론될 수 있고, 그 순간 K[number]가 string이 되어 버리면서 반환 타입이 무너집니다.
기존 해결책은 보통 다음 중 하나였습니다.
- 호출부에서
as const를 붙인다 - 오버로드를 여러 개 만든다
keys에 대한 헬퍼(예:tuple('id','name'))를 추가한다
하지만 이런 방식은 호출자 경험이 나쁘거나(매번 as const) 유지보수 비용이 큽니다(오버로드 폭발).
TS 5.5+ const 타입 파라미터란
const 타입 파라미터는 제네릭 파라미터 선언에서 const를 붙여, 추론 시 리터럴 타입을 더 보존하도록 만드는 기능입니다.
핵심 형태는 다음처럼 생겼습니다.
function f<const T>(value: T) {
return value;
}
여기서 T는 단순히 T가 아니라, “가능한 한 리터럴로 유지되는 T”로 추론됩니다.
주의할 점도 있습니다.
- 모든 widening을 마법처럼 막는 기능은 아닙니다.
- 특히 변수에 담긴 값(이미 넓어진 값)은 다시 리터럴로 되돌릴 수 없습니다.
- 하지만 함수 호출 시점의 리터럴(인라인 리터럴, 객체/배열 리터럴 등)에 대해 체감 효과가 큽니다.
예제 1: 배열 인자 좁히기 (pick 개선)
위의 pick을 const 타입 파라미터로 개선해보겠습니다.
type PickResult<T, K extends readonly (keyof T)[]> = {
[P in K[number]]: T[P];
};
function pick<T, const K extends readonly (keyof T)[]>(obj: T, keys: K): PickResult<T, K> {
const out: any = {};
for (const k of keys) out[k] = (obj as any)[k];
return out;
}
const user = { id: 1, name: 'neo', email: 'neo@example.com' };
const r2 = pick(user, ['id', 'name']);
// r2: { id: number; name: string }
포인트는 const K입니다.
- 호출부가
['id', 'name']를 넘기면K가readonly ['id', 'name']같은 튜플로 더 잘 잡힙니다. - 결과적으로
K[number]가'id' | 'name'이 되어 반환 타입이 정확해집니다.
실무 팁: keys를 변수로 빼면?
const 타입 파라미터는 “호출 시점 리터럴”에 강합니다. 하지만 다음은 다릅니다.
const keys = ['id', 'name'];
const r3 = pick(user, keys);
// keys 자체가 이미 string[]로 넓어졌다면, r3도 기대만큼 좁아지지 않을 수 있음
이 경우는 keys 선언 시점에서 이미 widening이 발생했기 때문입니다. 해결책은 다음 중 하나입니다.
const keys = ['id', 'name'] as const;
const r4 = pick(user, keys);
또는 TS 5.0+의 satisfies를 결합해 의도를 더 명확히 할 수 있습니다.
const keys = ['id', 'name'] as const satisfies readonly (keyof typeof user)[];
const r5 = pick(user, keys);
정리하면, const 타입 파라미터는 “인라인 인자”에서 가장 큰 이득을 주고, 변수로 분리된 값은 분리 시점에서 타입을 잘 잡아줘야 합니다.
예제 2: 옵션 객체에서 리터럴 보존 (분기와 반환 타입 동기화)
옵션 객체에 따라 반환 타입이 달라지는 함수는 오버로드가 쉽게 늘어납니다.
예: parse가 mode에 따라 반환 타입을 바꾸는 경우.
type ParseMode = 'strict' | 'loose';
function parse(input: string, opts: { mode: ParseMode }) {
if (opts.mode === 'strict') {
return { ok: true as const, value: input.trim() };
}
return { ok: true as const, value: input };
}
const a = parse(' x ', { mode: 'strict' });
// mode가 제대로 보존되지 않으면 분기 기반 타입 추론이 약해질 수 있음
const 타입 파라미터를 쓰면, 옵션 리터럴이 더 잘 보존되어 반환 타입 설계를 정교하게 가져갈 수 있습니다.
type ResultStrict = { ok: true; value: string; normalized: true };
type ResultLoose = { ok: true; value: string; normalized: false };
type ParseResult<M extends ParseMode> = M extends 'strict'
? ResultStrict
: ResultLoose;
function parse<const M extends ParseMode>(
input: string,
opts: { mode: M }
): ParseResult<M> {
if (opts.mode === 'strict') {
return { ok: true, value: input.trim(), normalized: true } as ParseResult<M>;
}
return { ok: true, value: input, normalized: false } as ParseResult<M>;
}
const s = parse(' x ', { mode: 'strict' });
// s: ResultStrict
const l = parse(' x ', { mode: 'loose' });
// l: ResultLoose
여기서도 장점은 호출부가 mode: 'strict'를 썼을 때 M이 'strict'로 유지되며, 그에 따라 ParseResult<M>가 자동으로 결정된다는 점입니다.
예제 3: 이벤트/커맨드 라우팅에서 오버로드 줄이기
프론트엔드/백엔드에서 “문자열 리터럴 기반 라우팅”은 흔합니다.
type CommandMap = {
'user.create': { name: string };
'user.delete': { id: number };
};
type Command = keyof CommandMap;
type CommandResult<C extends Command> =
C extends 'user.create' ? { id: number } :
C extends 'user.delete' ? { ok: true } :
never;
function dispatch<const C extends Command>(cmd: C, payload: CommandMap[C]): Promise<CommandResult<C>> {
// ...
return Promise.resolve(null as any);
}
const created = await dispatch('user.create', { name: 'neo' });
// created: { id: number }
const deleted = await dispatch('user.delete', { id: 1 });
// deleted: { ok: true }
이 패턴은 오버로드로도 구현 가능하지만, 커맨드가 늘어날수록 유지보수 비용이 커집니다. const C를 두면 호출부의 리터럴 커맨드가 더 안정적으로 보존되어, 분기형 결과 타입이 잘 따라옵니다.
언제 특히 유용한가
1) “리터럴 배열”을 받는 유틸
pick,omit,pluckselect(fields)같은 쿼리 빌더route(['GET', '/users'])같은 라우팅 DSL
2) “옵션 객체”가 타입을 결정하는 API
mode,format,strategy같은 플래그return: 'raw' | 'json'같은 반환 형식 선택
3) 런타임 분기와 타입 분기를 맞추고 싶은 경우
if (opts.mode === 'strict')같은 분기가 있을 때- 반환 타입을 조건부 타입으로 모델링할 때
흔한 함정과 체크리스트
이미 넓어진 값은 되돌릴 수 없다
앞서 본 것처럼 변수에 담는 순간 string[]로 넓어졌다면, 함수에서 const 타입 파라미터를 써도 한계가 있습니다. 이럴 땐 선언 시점에 as const 또는 satisfies로 방어하세요.
const 타입 파라미터는 “더 강한 추론”이지 “런타임 강제”가 아니다
타입 시스템이 더 정확해질 뿐, 런타임 검증은 여전히 필요합니다. 외부 입력(HTTP, 메시지 큐, 사용자 입력)을 다룬다면 런타임 스키마 검증을 병행하세요.
반환 타입이 복잡해지면 오히려 가독성이 떨어질 수 있다
조건부 타입이 과도하게 중첩되면 팀 내에서 이해 비용이 올라갑니다. 이럴 땐 타입 별칭을 잘 쪼개고, “사용자에게 중요한 표면 타입”만 노출하는 방식이 좋습니다.
실전 예시: 쿼리 select에서 필드 기반 반환 타입 만들기
간단한 ORM 스타일의 select를 만들어 보겠습니다.
type Row = {
id: number;
name: string;
email: string;
createdAt: Date;
};
type SelectResult<T, K extends readonly (keyof T)[]> = {
[P in K[number]]: T[P];
};
function select<const K extends readonly (keyof Row)[]>(fields: K) {
// 실제로는 SQL 생성 및 실행
return async function run(): Promise<Array<SelectResult<Row, K>>> {
return [] as any;
};
}
const run = select(['id', 'createdAt']);
const rows = await run();
// rows: Array<{ id: number; createdAt: Date }>
여기서 fields가 readonly ['id', 'createdAt']로 유지되면, 결과 row 타입도 정확히 그 두 필드만 포함하게 됩니다. 호출자는 as const 없이도 “선택한 필드만 오는 것”을 타입으로 보장받습니다.
팀/프로덕션 관점: 타입 좁히기는 번들 최적화와도 닮아있다
const 타입 파라미터는 런타임 성능을 직접 올리는 기능은 아니지만, “필요한 것만 남기고 불필요한 경우의 수를 줄인다”는 점에서 최적화 사고방식과 닮아 있습니다. 예를 들어 RSC 환경에서 클라이언트 번들로 넘어가는 코드 경계를 정리해 번들 폭증을 줄이는 것처럼, 타입 경계에서도 리터럴 정보를 보존하면 불필요한 오버로드/캐스팅/가드 코드가 줄어듭니다.
관련해서 Next.js 경계 최적화에 관심이 있다면 다음 글도 함께 보면 맥락이 이어집니다.
또한 “원인을 좁혀 정확히 진단한다”는 관점은 장애 분석에서도 동일합니다. 타입을 좁히는 습관은 문제 공간을 줄여 디버깅을 단순화합니다.
마이그레이션 가이드: 기존 코드에 어떻게 적용할까
- 리터럴 배열/객체를 받는 제네릭 유틸부터 찾습니다.
function foo<T, K extends ...>(..., k: K)형태가 후보입니다.
- 해당 제네릭 파라미터에
const를 붙여봅니다.- 예:
function foo<T, const K extends ...>(..., k: K)
- 예:
- 호출부에서 여전히 넓어지는 케이스(변수로 분리된 배열 등)를 확인합니다.
- 필요한 곳에만
as const또는satisfies를 추가합니다.
- 필요한 곳에만
- 불필요해진 오버로드/캐스팅을 제거합니다.
결론
TS 5.5+의 const 타입 파라미터는 “함수 경계에서 리터럴 타입이 사라지는 문제”를 완화해, 좁히기와 반환 타입 정밀도를 크게 개선합니다. 특히
- 키 배열을 받는 유틸
- 모드/전략 옵션에 따라 반환 타입이 달라지는 API
- 커맨드/이벤트 기반 라우팅
에서 효과가 큽니다.
다만 이미 widening된 값을 되돌리진 못하므로, 변수로 분리하는 순간에는 as const나 satisfies로 선언을 보강하는 습관이 필요합니다. 이 조합을 잘 쓰면 “타입 때문에 생기는 보일러플레이트”를 줄이면서도 더 안전한 API를 만들 수 있습니다.