Published on

TS 5.5+ const 타입 파라미터로 추론 강화

Authors

서버와 프런트가 같은 타입을 공유하는 시대에, 타입 추론이 조금만 흐트러져도 API 계약이 느슨해지고 런타임 버그가 숨어듭니다. 특히 함수 인자로 객체나 배열을 넘길 때 리터럴이 string 같은 넓은 타입으로 widening 되면서, 우리가 기대한 정밀한 타입 정보가 사라지는 경우가 많습니다.

TypeScript 5.5+에서 도입된 const 타입 파라미터는 이런 문제를 해결하는 데 매우 실용적입니다. 핵심은 제네릭 타입 파라미터에 const를 붙여서, 함수 인자로 들어온 값의 리터럴 정보를 더 강하게 보존하도록 유도하는 것입니다.

이 글에서는 const 타입 파라미터가 무엇을 바꾸는지, 언제 효과가 큰지, 그리고 팀 코드베이스에서 어떤 스타일로 적용하면 좋은지 예제로 정리합니다.

왜 리터럴 추론이 자주 무너질까

TypeScript는 기본적으로 “안전하게” 추론하려고 합니다. 그래서 함수 인자로 전달된 값이 다음과 같이 넓은 타입으로 추론되는 일이 있습니다.

  • 문자열 리터럴이 string으로 넓어짐
  • 배열이 튜플이 아니라 string[] 같은 배열로 넓어짐
  • 객체 프로퍼티가 리터럴이 아니라 string 또는 number로 넓어짐

이 widening은 때로는 의도된 동작이지만, 다음 같은 상황에서는 손해가 큽니다.

  • 라우팅 테이블, 이벤트 이름, 액션 타입처럼 “허용된 값의 집합”을 정확히 유지하고 싶을 때
  • keyof 기반으로 안전한 접근을 만들고 싶을 때
  • API 스키마를 코드에서 만들고 그 스키마로부터 타입을 뽑아 쓰고 싶을 때

기존에는 이런 문제를 as const로 해결하는 경우가 많았습니다. 하지만 as const는 호출자에게 부담을 주고, 값 전체를 깊게 readonly로 만들어서 사용성을 떨어뜨릴 수 있습니다.

const 타입 파라미터는 이 부담을 함수 설계 쪽으로 옮겨옵니다.

const 타입 파라미터란

기존 제네릭은 보통 이렇게 씁니다.

function pick<T, K extends keyof T>(obj: T, keys: K[]) {
  return keys.map((k) => obj[k]);
}

여기서 keysK[]라서, 호출 시점에 배열이 튜플로 유지되지 않으면 정밀한 타입을 얻기 어렵습니다.

TypeScript 5.5+에서는 타입 파라미터에 const를 붙일 수 있습니다.

function pick<const T, const K extends readonly (keyof T)[]>(obj: T, keys: K) {
  return keys.map((k) => obj[k]);
}

포인트는 두 가지입니다.

  • const K로 인해 keys의 리터럴 요소가 더 잘 보존됩니다.
  • readonly ...[] 형태로 받아서 튜플로 추론될 여지를 키웁니다.

이제 호출자가 as const를 붙이지 않아도, 많은 경우 튜플 리터럴이 유지됩니다.

예제 1: 라우트/이벤트 이름을 안전하게 고정하기

이벤트 이름을 문자열로 받는 API를 만든다고 가정해봅시다.

type Handler = (payload: unknown) => void;

function createEmitter<T extends Record<string, Handler>>(handlers: T) {
  return {
    emit(event: keyof T, payload: Parameters<T[keyof T]>[0]) {
      handlers[event](payload);
    },
  };
}

문제는 handlers를 넘길 때 프로퍼티 키가 리터럴로 유지되어야 keyof T가 정확해진다는 점입니다. 여기서 const 타입 파라미터를 적용하면 호출자 경험이 좋아집니다.

type Handler<P> = (payload: P) => void;

type Handlers = Record<string, Handler<any>>;

function createEmitter<const T extends Handlers>(handlers: T) {
  return {
    emit<K extends keyof T>(event: K, payload: Parameters<T[K]>[0]) {
      handlers[event](payload);
    },
  };
}

const emitter = createEmitter({
  userCreated: (p: { id: string }) => {
    p.id;
  },
  userDeleted: (p: { id: string; hard: boolean }) => {
    p.hard;
  },
});

emitter.emit("userCreated", { id: "1" });
// emitter.emit("userCreated", { id: 1 }); // 타입 에러
// emitter.emit("unknown", {}); // 타입 에러

const T 덕분에 객체 리터럴의 키가 더 안정적으로 보존되고, emitevent 파라미터가 정확한 유니온으로 좁혀집니다.

예제 2: 배열 인자에서 튜플 추론 유지하기

허용된 필드 목록을 넘기고, 그 목록만 반환하도록 만들고 싶을 때가 많습니다.

function selectKeys<T, K extends keyof T>(obj: T, keys: K[]) {
  const out = {} as Pick<T, K>;
  for (const k of keys) out[k] = obj[k];
  return out;
}

const user = { id: "1", name: "Ada", age: 42 };
const picked = selectKeys(user, ["id", "name"]);

위 코드는 얼핏 맞아 보이지만, keys("id" | "name")[]로만 추론되어도 결과 타입은 괜찮습니다. 그러나 실전에서는 keys가 다른 변수에서 넘어오거나, 유틸을 한 번 더 감싸면 widening이 쉽게 발생합니다.

const 타입 파라미터와 readonly 튜플을 함께 쓰면 더 강해집니다.

function selectKeys<const T, const K extends readonly (keyof T)[]>(obj: T, keys: K) {
  type Key = K[number];
  const out = {} as Pick<T, Key>;
  for (const k of keys) out[k] = obj[k];
  return out;
}

const user = { id: "1", name: "Ada", age: 42 };

const picked1 = selectKeys(user, ["id", "name"]);
// picked1: { id: string; name: string }

const picked2 = selectKeys(user, ["age"]);
// picked2: { age: number }

호출자 입장에서 as const를 붙이지 않아도 되는 경우가 늘어나고, 결과 타입도 K[number] 기반으로 정확해집니다.

예제 3: 스키마 DSL에서 추론 손실 줄이기

팀에서 “간단한 스키마 DSL”을 만들어 쓰는 경우가 많습니다. 예를 들어 필드 정의를 배열로 받고, 그 배열로부터 타입을 만들고 싶다고 합시다.

type Field =
  | { name: string; type: "string" }
  | { name: string; type: "number" };

type InferField<F extends Field> =
  F["type"] extends "string" ? string :
  F["type"] extends "number" ? number :
  never;

type InferSchema<S extends readonly Field[]> = {
  [K in S[number] as K["name"]]: InferField<K>
};

function defineSchema<const S extends readonly Field[]>(schema: S) {
  return schema;
}

const schema = defineSchema([
  { name: "id", type: "string" },
  { name: "age", type: "number" },
]);

type Model = InferSchema<typeof schema>;
// Model: { id: string; age: number }

여기서 defineSchemaconst S를 받지 않으면 typestring으로 넓어지거나, 배열이 단순 Field[]로 추론되어 InferSchema가 원하는 대로 동작하지 않는 상황이 생깁니다.

const 타입 파라미터는 이런 DSL 패턴에서 특히 체감이 큽니다.

const 타입 파라미터 vs as const

둘 다 “리터럴 정보를 보존”하려는 목적은 같지만, 책임과 부작용이 다릅니다.

  • as const
    • 장점: 호출 시점에서 매우 강력하게 고정됨
    • 단점: 호출자에게 문법 부담, 값이 deep readonly가 되어 이후 조작이 불편
  • const 타입 파라미터
    • 장점: API 설계 측에서 추론을 강화, 호출자는 일반 리터럴을 넘겨도 되는 경우가 많음
    • 단점: 모든 경우를 마법처럼 해결하진 않음. 특히 값이 한 번 변수에 담기며 이미 widening된 뒤라면 한계가 있음

실무 팁은 간단합니다.

  • 라이브러리/공용 유틸 함수는 const 타입 파라미터를 우선 고려
  • 정말 “불변 데이터 선언”이 목적이면 as const가 여전히 적절

적용 시 주의점과 권장 패턴

1) readonly와 같이 써야 효과가 커진다

배열 인자를 튜플로 추론시키려면 보통 readonly가 필요합니다.

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

여기서 x: T를 그냥 string[]로 받으면 튜플 이점이 줄어듭니다.

2) 이미 widening된 변수는 되돌리기 어렵다

다음처럼 중간 변수에서 타입이 넓어지면, const 타입 파라미터도 구제하지 못할 수 있습니다.

const keys = ["id", "name"]; // 보통 string[]로 widening 가능
// selectKeys(user, keys) // 이 시점엔 튜플 정보가 사라졌을 수 있음

이 경우는 여전히 as const 또는 명시적 타입(예: const keys: readonly ["id", "name"] = ...)이 필요합니다.

3) 반환 타입을 T 그대로 노출할지, 매핑 타입으로 줄일지 결정

const 타입 파라미터로 입력이 정밀해지면, 반환 타입도 과하게 복잡해질 수 있습니다. 팀 규칙을 정해두면 좋습니다.

  • 공용 API는 반환 타입을 적당히 단순화
  • 내부 DSL은 정밀 타입을 적극 활용

어떤 코드에 먼저 적용하면 ROI가 큰가

const 타입 파라미터는 “값을 받아서 타입을 만들어내는 함수”에서 투자 대비 효과가 큽니다.

  • 라우팅/권한/피처 플래그 테이블
  • 이벤트 emitter, action creator, reducer 유틸
  • 스키마 정의 함수, 쿼리 빌더 DSL
  • pick, omit, groupBy 같은 컬렉션 유틸

특히 TS 5.5에서 타입 체크가 더 엄격해지며 경고를 줄이는 과정에서, 추론을 강화해 “애초에 안전한 형태로 설계”하는 것이 장기적으로 유지보수 비용을 낮춥니다. 관련해서는 TS 5.5에서 Object is possibly undefined 줄이기 글도 함께 보면, 경고를 억지로 잠재우기보다 타입 모델을 개선하는 접근이 도움이 됩니다.

또한 타입 시스템을 활용한 메타프로그래밍을 한다면, 데코레이터나 메타데이터와 결합될 때 추론이 흔들릴 수 있는데, 그 함정은 ES2024 데코레이터 - TS 타입추론·메타데이터 함정에서 더 깊게 다룹니다.

마이그레이션 체크리스트

기존 유틸 함수에 무작정 const를 붙이기보다, 아래 순서로 적용하면 안전합니다.

  1. 호출부에서 as const가 반복되는 유틸을 찾는다
  2. 그 유틸의 제네릭 타입 파라미터 중 “입력 리터럴을 보존해야 하는 것”에만 const를 붙인다
  3. 배열/튜플 인자는 readonly로 받도록 시그니처를 조정한다
  4. 반환 타입이 과도하게 복잡해지면 Simplify 같은 유틸 타입으로 정리한다

예를 들어 반환 타입 단순화는 다음처럼 할 수 있습니다.

type Simplify<T> = { [K in keyof T]: T[K] } & {};

function selectKeys<const T, const K extends readonly (keyof T)[]>(obj: T, keys: K) {
  type Key = K[number];
  const out = {} as Pick<T, Key>;
  for (const k of keys) out[k] = obj[k];
  return out as Simplify<typeof out>;
}

정리

TypeScript 5.5+의 const 타입 파라미터는 “호출자가 as const를 남발하지 않아도 되는” 방향으로 API를 설계하게 해줍니다. 리터럴과 튜플 추론이 보존되면 다음이 좋아집니다.

  • 허용된 값 집합이 타입으로 정확히 유지됨
  • keyof, 인덱스 접근, 매핑 타입 기반 유틸이 더 안정적으로 동작
  • 런타임 검증 코드와 타입 정의 사이의 간극이 줄어듦

결국 목표는 타입을 더 똑똑하게 만드는 게 아니라, 팀이 실수하기 어려운 인터페이스를 만드는 것입니다. const 타입 파라미터는 그 목적에 잘 맞는, 비용 대비 효과가 큰 도구입니다.