Published on

TS 5.5+ const 타입 파라미터 추론 함정 7가지

Authors

서로 다른 호출부에서 같은 유틸 함수를 쓰는데, 어떤 곳에서는 문자열 리터럴이 잘 보존되고 어떤 곳에서는 갑자기 string[] 으로 넓어지거나, 튜플이 배열로 붕괴되거나, 제네릭이 unknown 으로 떨어지는 경험을 한 적이 있을 겁니다. TS 5.5+에서 const 타입 파라미터 추론이 보강되면서 이런 현상은 더 “일관되게” 나타나지만, 그 일관성이 우리가 기대한 방향이 아닐 때가 있습니다.

이 글은 const 타입 파라미터 추론을 “리터럴을 최대한 보존하는 기능” 정도로만 이해하고 적용했을 때 생기는 대표적인 함정 7가지를 정리합니다. 각 섹션마다 재현 코드, 왜 그런지(추론 규칙 관점), 그리고 실무 대응 패턴을 함께 제공합니다.

관련해서 타입 추론과 메타데이터/데코레이터가 섞였을 때의 함정도 함께 보면 감이 더 빨리 옵니다: ES2024 데코레이터 - TS 타입추론·메타데이터 함정

준비: const 타입 파라미터란

일반적인 제네릭 함수는 인자에서 타입을 추론할 때, 상황에 따라 리터럴이 넓어지거나("a"string 으로), 배열이 일반 배열로 추론되거나(readonly ["a", "b"] 대신 string[]), 객체 프로퍼티가 넓어지는 일이 있습니다. 이를 보완하기 위해 TS는 as const 를 제공해 왔고, TS 5.x에서는 함수 시그니처 레벨에서 리터럴 보존을 유도하는 const 타입 파라미터를 지원합니다.

// 리터럴을 가능한 한 보존하려는 의도를 시그니처에 명시
function pick<const K extends readonly string[]>(keys: K) {
  return keys;
}

const r1 = pick(["id", "name"]);
// 기대: readonly ["id", "name"] 같은 튜플 리터럴 보존

하지만 “가능한 한” 보존한다는 말은, 곧 특정 상황에서는 보존이 깨진다는 뜻입니다.

함정 1) 배열 리터럴이 여전히 string[] 으로 넓어지는 경우

const 타입 파라미터가 있다고 해서 모든 배열 리터럴이 항상 튜플로 고정되는 건 아닙니다. 특히 아래처럼 컨텍스트 타입(contextual typing)이 먼저 걸리면, 그 컨텍스트에 맞춰 넓어진 타입이 들어가 버립니다.

function useKeys<const K extends readonly string[]>(keys: K) {
  return keys;
}

const ctx: string[] = ["id", "name"]; // 여기서 이미 string[] 로 넓어짐
const r = useKeys(ctx);
// r: string[] (혹은 readonly string[]) 수준으로만 남음

왜 이런가

추론은 “인자 표현식”만 보는 게 아니라, 그 인자가 이미 어떤 타입으로 굳어졌는지를 강하게 따릅니다. const 타입 파라미터는 “리터럴 표현식에서의 보존”에 강하지만, 이미 string[] 인 값에서 다시 리터럴 튜플을 복원해 주진 않습니다.

대응

  • 가능한 호출부에서 리터럴을 직접 전달하거나
  • 호출부에서 as const 로 고정하거나
  • 컨텍스트 타입을 readonly 튜플로 선언합니다.
const ctx2 = ["id", "name"] as const;
const r2 = useKeys(ctx2);
// r2: readonly ["id", "name"]

함정 2) satisfies 를 쓰면 안전할 것 같지만, 오히려 튜플이 깨질 수 있음

satisfies 는 “값의 타입을 바꾸지 않고 검증만” 한다고 알려져 있지만, 어떤 타입을 만족시키느냐에 따라 추론 결과가 달라질 수 있습니다.

function useKeys<const K extends readonly string[]>(keys: K) {
  return keys;
}

const keys = ["id", "name"] satisfies string[];
// keys의 타입은 보통 string[] 쪽으로 가기 쉬움(만족 대상이 넓음)

const r = useKeys(keys);
// r: readonly string[] 정도로만 남을 수 있음

대응

satisfies 를 쓰더라도 만족 대상 타입을 너무 넓게 주지 말고, 리터럴 보존이 필요한 경우에는 readonly 튜플/리터럴 쪽으로 유도합니다.

const keys2 = ["id", "name"] as const satisfies readonly string[];
const r2 = useKeys(keys2);
// r2: readonly ["id", "name"]

함정 3) 객체에서 프로퍼티가 “깊게” 고정될 거라 기대하면 안 됨

const 타입 파라미터는 리터럴 보존에 강하지만, 객체의 중첩 구조에서 “깊은 불변”까지 자동으로 보장하는 만능 DeepReadonly 같은 기능은 아닙니다.

function defineRoute<const R extends { path: string; method: string }>(r: R) {
  return r;
}

const route = defineRoute({
  path: "/users",
  method: "GET",
});

// route.path는 "/users" 리터럴로 잡힐 수 있지만,
// 중첩이 들어가면 기대와 다르게 넓어지기도 함

중첩을 추가해 보면 더 명확합니다.

function defineConfig<const C extends { headers: Record<string, string> }>(c: C) {
  return c;
}

const cfg = defineConfig({
  headers: {
    "x-env": "prod",
  },
});

// cfg.headers["x-env"] 가 "prod" 로 고정되길 기대하지만
// Record<string, string> 제약이 강하면 string 으로 넓어질 수 있음

왜 이런가

제약 조건(extends ...)이 넓은 인덱스 시그니처(예: Record<string, string>)를 포함하면, 내부 프로퍼티 리터럴은 그 제약에 맞춰 넓어지기 쉽습니다.

대응

  • 제약을 더 구체적으로 만들거나
  • 중첩 객체는 별도의 as const 를 고려하거나
  • 인덱스 시그니처 대신 구체 키 유니온을 사용합니다.
type HeaderKey = "x-env" | "x-trace";

function defineConfig2<const C extends { headers: Partial<Record<HeaderKey, string>> }>(c: C) {
  return c;
}

const cfg2 = defineConfig2({ headers: { "x-env": "prod" } } as const);

함정 4) const 타입 파라미터가 유니온을 “원하는 방식으로” 분배해주지 않는다

유니온 입력을 넣으면 결과가 자연스럽게 분배(distribute)될 거라 기대하는데, 실제로는 제네릭 위치/조건부 타입 위치에 따라 전혀 다르게 동작합니다.

type ToArray<T> = T extends any ? T[] : never;

function wrap<const T>(x: T) {
  type R = ToArray<T>;
  return x;
}

const v = Math.random() > 0.5 ? "a" : "b";
const r = wrap(v);
// T는 "a" | "b" 로 잡힐 수 있지만
// 분배를 기대한 형태의 결과를 얻지 못하는 경우가 많음

대응

“유니온 분배”가 필요하면, 값을 받는 함수 레벨에서 해결하려 하기보다 조건부 타입을 적용하는 지점을 분리하거나, 오버로드/헬퍼 타입으로 의도를 명확히 합니다.

type Distribute<T> = T extends unknown ? { value: T } : never;

function tag<const T>(x: T): Distribute<T> {
  return { value: x } as any;
}

const t = tag(Math.random() > 0.5 ? "a" : "b");
// t: { value: "a" } | { value: "b" }

함정 5) readonly 와 가변 배열 사이에서 추론이 흔들리며 API 사용성이 나빠진다

const 타입 파라미터를 쓰면 보통 readonly 튜플로 잡히는 일이 많습니다. 문제는 그 다음 단계에서, 사용자가 그 값을 가변 배열을 받는 API에 넘길 때 타입 에러가 발생한다는 점입니다.

function keys<const K extends readonly string[]>(k: K) {
  return k;
}

function needsMutable(xs: string[]) {
  xs.push("x");
}

const k = keys(["id", "name"]);
needsMutable(k);
// 에러: readonly string[] 는 string[] 에 할당 불가

대응

  • 가변이 필요한 API는 입력을 readonly string[] 로 받도록 바꾸는 게 최선입니다.
  • 바꿀 수 없다면 복사해서 넘깁니다.
function needsReadonly(xs: readonly string[]) {
  // 읽기 전용으로만 사용
}

needsReadonly(k);
needsMutable([...k]);

이 함정은 “타입 안정성은 좋아졌는데 호출부가 불편해졌다”로 체감되기 쉬워서, 팀 내 컨벤션(입력은 readonly 우선)을 정해두는 편이 좋습니다.

함정 6) const 타입 파라미터를 남발하면 오히려 타입이 과도하게 구체화되어 깨진다

리터럴이 지나치게 고정되면, 재사용 가능한 함수가 “특정 리터럴 조합에서만” 동작하는 것처럼 보이기도 합니다.

function makeMap<const K extends readonly string[]>(keys: K) {
  type Key = K[number];
  const out: Record<string, number> = {};
  for (const k of keys) out[k] = 0;
  return out as Record<Key, number>;
}

const m1 = makeMap(["id", "name"]);
// m1: Record<"id" | "name", number>

const m2 = makeMap(["id", "email"]);
// m2: Record<"id" | "email", number>

여기까진 좋아 보이지만, 이 결과를 합치거나 공통 처리하려 하면 Record<"id" | "name", number>Record<"id" | "email", number> 사이에서 타입 호환이 예상보다 빡빡해질 수 있습니다.

대응

  • 외부로 노출되는 API는 “너무 구체적인 타입”을 반환하지 않도록 경계합니다.
  • 반환 타입을 한 단계 넓히는 옵션을 제공합니다.
function makeMapWide<const K extends readonly string[]>(keys: K): Record<string, number> {
  const out: Record<string, number> = {};
  for (const k of keys) out[k] = 0;
  return out;
}

실무에서는 “내부 구현은 리터럴을 최대한 활용하되, 모듈 경계에서 타입을 적당히 넓힌다”가 유지보수에 유리합니다.

함정 7) 오버로드와 결합하면 const 추론이 예상과 다른 시그니처로 빨려 들어간다

오버로드가 있는 함수에서 const 타입 파라미터를 섞으면, 호출이 “더 구체적인 오버로드”로 갈 거라 기대했는데 실제로는 반대가 될 수 있습니다. 오버로드 선택은 구현 시그니처가 아니라 “오버로드 목록”과 인자 타입 적합성에 의해 결정되며, 이때 const 추론이 오히려 넓은 시그니처에 더 잘 맞아 버리는 일이 생깁니다.

function f<const T extends readonly string[]>(x: T): T;
function f(x: readonly string[]): readonly string[];
function f(x: readonly string[]) {
  return x;
}

const r = f(["a", "b"]);
// 기대: readonly ["a", "b"]
// 실제: 오버로드 선택/추론 순서에 따라 readonly string[] 로 떨어질 수 있음

대응

  • 오버로드를 정리해 “리터럴 보존용 시그니처”가 항상 우선되게 배치합니다.
  • 가능하면 오버로드 대신 조건부 타입 기반 단일 시그니처를 고려합니다.
type Preserve<T extends readonly string[]> = T;

function f2<const T extends readonly string[]>(x: Preserve<T>) {
  return x;
}

const r2 = f2(["a", "b"]);

오버로드가 많아질수록 추론 결과가 바뀌는 리팩터링이 빈번해지므로, “타입 레벨에서의 분기”와 “런타임 분기”를 분리하는 설계가 안전합니다.

실무 체크리스트

  1. 호출부에서 이미 넓어진 값(string[], Record<string, string> 등)을 넘기면 const 로도 복원이 안 됩니다.
  2. satisfies 는 만능이 아니며, 만족 대상 타입이 넓으면 타입도 넓어집니다.
  3. 인덱스 시그니처 제약은 내부 리터럴을 쉽게 string 으로 넓힙니다.
  4. 유니온 분배는 “조건부 타입이 적용되는 위치”가 핵심입니다.
  5. readonly 추론은 안전하지만, 가변 API와 맞물리면 불편이 생깁니다.
  6. 반환 타입이 과도하게 구체화되면 모듈 경계에서 조합성이 떨어집니다.
  7. 오버로드는 추론을 흔들 수 있으니, 배치/구조를 단순화하세요.

마무리

TS 5.5+의 const 타입 파라미터 추론은 as const 를 호출부에 강요하지 않고도 리터럴을 보존할 수 있게 해주는 강력한 도구입니다. 다만 “추론이 좋아졌다”는 말은 “내 의도대로 고정된다”와 동치가 아닙니다. 컨텍스트 타입, 제약 조건의 넓이, 오버로드, readonly 경계 같은 요소들이 결합되면 결과는 쉽게 달라집니다.

가장 좋은 접근은 다음 두 가지입니다.

  • 리터럴 보존이 필요한 API는 입력 타입을 readonly 중심으로 설계하고, 가변이 필요하면 내부에서 복사합니다.
  • 모듈 경계(외부로 노출되는 반환 타입)에서는 너무 구체적인 리터럴 타입을 그대로 내보내지 말고, 조합 가능한 수준으로 적당히 넓히는 전략을 씁니다.

타입 추론이 복잡해질수록 “편의 기능”이 오히려 함정이 되기도 합니다. 비슷한 맥락의 추론 함정 사례는 다음 글도 참고할 만합니다: ES2024 데코레이터 - TS 타입추론·메타데이터 함정