- Published on
TS 5.5+ const 제네릭 추론 함정 7가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서론부터 결론까지 한 번에 읽히도록, TS 5.5+의 const 제네릭이 어떤 상황에서 추론을 ‘너무’ 좁히거나 고정해 버리는지 7가지 함정으로 정리합니다. as const를 남발하지 않아도 리터럴 추론이 강해지는 만큼, API 설계/유틸 함수에서 타입이 예상과 달라지는 순간이 늘었습니다.
아래 예제들은 TypeScript 5.5+ 기준으로 설명하며, 핵심은 “const 제네릭은 편리하지만 항상 좋은 방향으로만 추론되지 않는다”는 점입니다. 필요하면 satisfies나 오버로드, 또는 의도적인 widening(확장)을 섞어 균형을 잡아야 합니다.
관련해서 satisfies로 추론과 안전성을 같이 가져가는 패턴은 이 글도 함께 보면 좋습니다: TS 5.x satisfies로 타입 안전과 추론을 함께
배경: const 제네릭이 뭘 바꾸나
일반적인 제네릭은 인자에 리터럴이 들어와도, 상황에 따라 string, number 같은 넓은 타입으로 추론(widening)되곤 했습니다. const 제네릭은 이를 더 강하게 “리터럴 그대로” 보존하려는 의도를 갖습니다.
// 일반 제네릭
function id<T>(x: T) {
return x;
}
// const 제네릭
function idConst<const T>(x: T) {
return x;
}
const a = id("hello");
// a: string (상황에 따라 widening)
const b = idConst("hello");
// b: "hello" (리터럴 유지)
이건 분명 장점이지만, 리터럴 유지가 곧 정답은 아닙니다. 특히 라이브러리/공용 유틸 함수에서는 “너무 좁아져서” 오히려 사용성이 떨어질 수 있습니다.
함정 1: 문자열/숫자 리터럴이 과도하게 고정되어 재사용성이 떨어짐
예를 들어 이벤트 이름, 라우트 키, 설정 키 같은 값들을 받을 때, 리터럴을 유지하는 게 항상 이득은 아닙니다.
function on<const E extends string>(event: E, handler: () => void) {
// ...
}
const ev = "click";
on(ev, () => {});
// ev가 "click" 리터럴로 추론되면 좋아 보이지만,
// ev를 나중에 다른 문자열로 바꿀 수 있는 변수로 쓰고 싶을 때 문제가 됩니다.
안전한 패턴
- 외부 API라면
const를 쓰기 전에 “호출자가 리터럴 고정을 원하나?”를 먼저 판단합니다. - 필요하면 두 버전을 제공합니다.
function onWide<E extends string>(event: E, handler: () => void) {}
function onNarrow<const E extends string>(event: E, handler: () => void) {}
함정 2: 튜플이 너무 강하게 고정되어 push/concat이 막힘
const 제네릭은 배열 인자를 튜플로 추론하는 경향이 강해집니다. 그 결과 “수정 가능한 배열”로 쓰려던 코드가 막힙니다.
function makeList<const T extends readonly unknown[]>(xs: T) {
return xs;
}
const list = makeList([1, 2, 3]);
// list: readonly [1, 2, 3]
// list.push(4);
// 오류: readonly라서 변경 불가
안전한 패턴
반환 타입을 의도적으로 “mutable”로 바꾸거나, 입력은 readonly로 받고 출력은 새 배열로 복사합니다.
function makeMutableList<const T extends readonly unknown[]>(xs: T) {
return [...xs] as unknown[];
}
const m = makeMutableList([1, 2, 3]);
m.push(4);
함정 3: readonly가 전파되어 예상치 못한 불변성이 생김
const 제네릭을 readonly 경계와 함께 쓰면, 호출자가 일반 배열/객체를 넘겨도 결과가 readonly로 굳어지는 경우가 많습니다.
function wrap<const T extends readonly string[]>(xs: T) {
return { xs };
}
const r = wrap(["a", "b"]);
// r.xs: readonly ["a", "b"]
안전한 패턴
- “입력은 readonly로 받되, 내부에서만” readonly를 유지하고 싶다면 반환 타입에서 풀어주는 전략을 씁니다.
type Mutable<T> = T extends readonly (infer U)[] ? U[] : T;
function wrapMutable<const T extends readonly string[]>(xs: T) {
return { xs: xs as Mutable<T> };
}
함정 4: 유니온이 ‘분해’되지 않고 특정 리터럴로 굳어 분기 로직이 깨짐
const 제네릭은 “호출 시점의 값”을 강하게 붙잡습니다. 그 결과 원래는 유니온으로 받아 분기하려던 API가, 특정 리터럴로만 굳어버려 분기 테스트가 약해질 수 있습니다.
type Mode = "dev" | "prod";
function run<const M extends Mode>(mode: M) {
if (mode === "dev") {
// ...
}
}
const mode: Mode = Math.random() > 0.5 ? "dev" : "prod";
run(mode);
// mode가 Mode로 유지되면 좋지만,
// 실제 코드 흐름/초기화 방식에 따라 특정 리터럴로 좁아져 버리면 테스트가 편향될 수 있습니다.
안전한 패턴
- “유니온을 유지해야 하는 입력”은
const제네릭을 쓰지 않는 쪽이 낫습니다. - 또는 호출부에서 widening을 강제합니다.
const mode2 = (Math.random() > 0.5 ? "dev" : "prod") as Mode;
run(mode2);
함정 5: 객체 리터럴이 과도하게 좁아져 인덱싱/확장이 어려움
설정 객체를 받아 내부에서 키를 순회하거나, 다른 설정과 merge하려는 유틸에서 자주 터집니다.
function defineConfig<const C extends Record<string, unknown>>(c: C) {
return c;
}
const cfg = defineConfig({
env: "prod",
retries: 3,
});
// cfg.env: "prod"
// cfg.retries: 3
// 이후 cfg를 일반 설정 객체로 합치려면
// 리터럴 고정 때문에 타입이 지나치게 구체적이어서 불편해질 수 있습니다.
안전한 패턴: satisfies로 “검증만” 하고 추론은 적당히
type AppConfig = {
env: "dev" | "prod";
retries: number;
};
const cfg2 = {
env: "prod",
retries: 3,
} satisfies AppConfig;
// cfg2.env는 "prod"로 유지되지만,
// 구조 검증을 통과하면서도 AppConfig 요구사항을 만족
satisfies는 “형태 검증 + 과도한 강제 캐스팅 방지”에 특히 강합니다. 자세한 패턴은 위 내부 링크 글을 참고하세요.
함정 6: map/pick/omit 류 유틸에서 결과 타입이 너무 구체적이거나 너무 복잡해짐
키 배열을 받는 유틸에서 const 제네릭을 쓰면 키가 튜플로 고정되어 결과 타입이 정밀해지지만, 때로는 컴파일 성능/가독성이 급격히 나빠집니다.
function pick<const T extends object, const K extends readonly (keyof T)[]>(
obj: T,
keys: K
) {
const out: Partial<T> = {};
for (const k of keys) out[k] = obj[k];
return out as Pick<T, K[number]>;
}
const user = { id: 1, name: "a", email: "a@a.com" };
const picked = pick(user, ["id", "email"]);
// picked: { id: number; email: string }
정밀함은 좋지만, 이런 유틸이 중첩 호출되면 Pick/인덱스 접근 타입이 겹겹이 쌓여 IDE 표시가 난해해지고, 타입 체크가 느려질 수 있습니다.
안전한 패턴
- 라이브러리 레벨에서는 “정밀 모드”와 “간단 모드”를 분리합니다.
- 또는 반환 타입을 적절히 단순화하는
Simplify헬퍼를 둡니다.
type Simplify<T> = { [K in keyof T]: T[K] } & {};
function pickSimple<const T extends object, const K extends readonly (keyof T)[]>(
obj: T,
keys: K
) {
const out: Partial<T> = {};
for (const k of keys) out[k] = obj[k];
return out as Simplify<Pick<T, K[number]>>;
}
함정 7: 오버로드/조건부 타입과 결합 시 “의도와 다른 시그니처”로 고정됨
const 제네릭은 가장 구체적인 타입을 잡아내려다 보니, 오버로드 선택이나 조건부 타입 분기에서 “너무 이른 결정”을 유발할 수 있습니다.
type Input =
| { kind: "text"; value: string }
| { kind: "num"; value: number };
function parse<const T extends Input>(x: T): T["value"] {
return x.value;
}
const v1 = parse({ kind: "text", value: "a" });
// v1: string (OK)
const v2 = parse({ kind: "num", value: 123 });
// v2: number (OK)
// 하지만 입력을 Input으로 받아서 value를 "string | number"로 유지하고 싶을 때도 있습니다.
const x: Input = Math.random() > 0.5
? { kind: "text", value: "a" }
: { kind: "num", value: 123 };
const v3 = parse(x);
// v3: string | number (이게 의도일 수도 있고)
// 상황에 따라 const 제네릭이 오히려 분기 추론을 복잡하게 만들 수 있습니다.
안전한 패턴
- “입력의 구체성을 유지”하는 함수와 “유니온을 유지”하는 함수를 분리합니다.
function parseNarrow<const T extends Input>(x: T): T["value"] {
return x.value;
}
function parseWide(x: Input): Input["value"] {
return x.value;
}
실무 체크리스트: 언제 const 제네릭을 쓰고, 언제 피하나
- 좋은 후보
- 키 배열 기반 유틸(
pick, 라우트 정의, i18n 키 등)에서 “정밀한 결과 타입”이 큰 가치일 때 - 튜플/리터럴을 그대로 보존해야 하는 DSL/스키마 정의 함수
- 키 배열 기반 유틸(
- 주의 후보
- 설정/옵션 객체를 받아 “일반적인 객체”로 합성/머지/확장해야 하는 API
- 반환값을 mutable하게 다뤄야 하는 컬렉션 유틸
- 오버로드/조건부 타입이 복잡한 공용 함수
그리고 const 제네릭을 도입할 때는, 아래 2가지를 항상 같이 고려하세요.
호출부에서 결과 타입이 지나치게 구체적이면, 사용자는 결국
as캐스팅을 늘립니다. 그 순간 타입 안정성은 오히려 후퇴합니다.복잡한 타입 연산이 중첩되면 IDE 성능과 컴파일 시간이 체감될 수 있습니다. 간단 모드(
pickSimple같은)도 함께 제공하는 게 실무적으로 유리합니다.
마무리
TS 5.5+의 const 제네릭은 “리터럴/튜플 보존”을 훨씬 쉽게 만들어 주지만, 그만큼 과도한 좁힘이 새로운 실패 지점이 됩니다. 특히 공용 유틸/라이브러리 API에서는 “정밀함”과 “사용성” 사이의 균형이 중요합니다.
정리하면,
- 리터럴 보존이 핵심 가치인 DSL/정의 함수에는 적극적으로,
- 합성/확장/머지 중심의 설정 API에는 신중하게,
- 애매하면
satisfies로 검증만 하고 추론은 자연스럽게
이 3가지만 지켜도 const 제네릭 도입 후 발생하는 타입 추론 함정의 대부분을 피할 수 있습니다.
추가로 TS 5.5에서 타입 체크 옵션 강화로 빌드가 깨지는 케이스도 함께 관리해야 한다면, TS 5.5 noImplicitOverride 에러 해결법도 같이 점검해 두면 업그레이드 작업이 훨씬 수월해집니다.