Published on

TS 5.5+ const 제네릭 추론 함정 7가지

Authors

서론부터 결론까지 한 번에 읽히도록, 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가지를 항상 같이 고려하세요.

  1. 호출부에서 결과 타입이 지나치게 구체적이면, 사용자는 결국 as 캐스팅을 늘립니다. 그 순간 타입 안정성은 오히려 후퇴합니다.

  2. 복잡한 타입 연산이 중첩되면 IDE 성능과 컴파일 시간이 체감될 수 있습니다. 간단 모드(pickSimple 같은)도 함께 제공하는 게 실무적으로 유리합니다.

마무리

TS 5.5+의 const 제네릭은 “리터럴/튜플 보존”을 훨씬 쉽게 만들어 주지만, 그만큼 과도한 좁힘이 새로운 실패 지점이 됩니다. 특히 공용 유틸/라이브러리 API에서는 “정밀함”과 “사용성” 사이의 균형이 중요합니다.

정리하면,

  • 리터럴 보존이 핵심 가치인 DSL/정의 함수에는 적극적으로,
  • 합성/확장/머지 중심의 설정 API에는 신중하게,
  • 애매하면 satisfies로 검증만 하고 추론은 자연스럽게

이 3가지만 지켜도 const 제네릭 도입 후 발생하는 타입 추론 함정의 대부분을 피할 수 있습니다.

추가로 TS 5.5에서 타입 체크 옵션 강화로 빌드가 깨지는 케이스도 함께 관리해야 한다면, TS 5.5 noImplicitOverride 에러 해결법도 같이 점검해 두면 업그레이드 작업이 훨씬 수월해집니다.