Published on

TS 5.5+ NoInfer로 타입추론 폭주 막기

Authors

서로 다른 제네릭 인자들이 한 번의 호출에서 동시에 추론되면, TypeScript는 가능한 한 “맞춰보려”는 방향으로 타입을 확장합니다. 이 과정이 지나치게 공격적으로 작동하면 다음 문제가 생깁니다.

  • 추론 결과가 너무 넓어져(예: 불필요한 유니온) 이후 타입 체크가 약해짐
  • 반대로 추론이 꼬여서 never나 말도 안 되는 타입으로 붕괴
  • 오버로드/조건부 타입/분산 조건부 타입이 얽히며 컴파일 시간이 급증
  • API 사용자가 인자를 조금만 바꿔도 타입이 확 달라져 DX가 나빠짐

TS 5.5+에서 제공되는 NoInfer는 이런 상황에서 “이 타입 파라미터는 이 자리에서 추론에 쓰지 마”라고 강제할 수 있는 도구입니다. 즉, 추론의 방향을 제어해 타입이 의도치 않게 폭주하는 걸 막는 브레이크입니다.

이 글에서는 NoInfer가 필요한 전형적인 패턴, 실제로 어떻게 적용하는지, 그리고 as const/satisfies/오버로드와 비교해 언제 무엇을 써야 하는지까지 실전 중심으로 정리합니다.

TS 5.x의 타입 안정성 패턴 전반은 TS 5.x satisfies로 타입 오류 줄이는 실전도 함께 보면 연결이 잘 됩니다.

NoInfer가 해결하는 “추론 폭주”의 전형

1) 두 인자에서 같은 제네릭을 동시에 추론할 때

가장 흔한 케이스는 “키”와 “기본값/대체값”을 같이 받는 함수입니다.

// 의도: key는 T의 키, fallback은 그 키의 값 타입이어야 함
function getOr<T, K extends keyof T>(obj: T, key: K, fallback: T[K]) {
  const v = obj[key];
  return (v ?? fallback) as T[K];
}

겉보기엔 괜찮아 보이지만, TKobjkeyfallback에서 동시에 추론되면서, 어떤 호출에서는 fallback 쪽이 추론에 끼어들어 K 또는 T[K]를 넓혀버리는 일이 생길 수 있습니다.

예를 들어 fallback을 넓은 타입으로 주면(혹은 리터럴이 아니라 변수로 주면) T[K]가 그 넓은 타입에 끌려가면서 타입 안정성이 약해집니다.

2) “입력으로부터 추론”과 “제약으로부터 추론”이 충돌할 때

조건부 타입이나 분산 조건부 타입이 들어가면, TypeScript는 추론 후보를 여러 개 만들고 합치려 합니다. 이때 특정 인자 위치가 추론을 주도하면 결과가 폭주합니다.

대표적으로 다음과 같은 “스키마 기반 API”가 그렇습니다.

  • 라우터/핸들러 매칭
  • 이벤트 버스 on/emit
  • 쿼리 빌더 select/where
  • 폼 검증 register/setValue

여기서 NoInfer는 “이 인자는 타입을 확인만 하고, 타입을 만들어내는(추론하는) 데는 참여하지 마”라는 역할을 합니다.

TS 5.5+ NoInfer의 개념: 추론 참여를 차단하기

NoInfer<T>T와 동일한 타입이지만, 그 위치에서는 타입 추론의 소스로 사용되지 않도록 하는 마커입니다.

핵심은 다음 두 문장으로 요약됩니다.

  • NoInfer를 붙인 위치의 인자는 타입 검증은 한다
  • 하지만 그 인자가 제네릭 타입 파라미터를 “결정”하는 데는 기여하지 않는다

즉, “추론은 다른 인자에서 하고, 여기서는 그 결과에 맞는지만 확인해라”가 됩니다.

주의: 문서/버전에 따라 NoInfer를 별도 유틸로 선언해 쓰던 시절도 있었지만, 이 글은 **TS 5.5+ 내장 NoInfer**를 기준으로 설명합니다.

실전 1: 이벤트 버스에서 payload 추론 폭주 막기

이벤트 버스는 emit에서 이벤트 이름과 payload를 같이 받습니다. 이때 payload가 추론을 흔들면, 이벤트 이름이 유니온으로 넓어지거나 payload가 지나치게 넓어지는 문제가 생깁니다.

문제 버전

type Events = {
  userCreated: { id: string; email: string };
  userDeleted: { id: string };
};

function emit<E extends Record<string, any>, K extends keyof E>(
  event: K,
  payload: E[K]
) {
  // ...
}

declare const events: Events;

// E를 직접 넘기지 못하니 보통은 emit을 팩토리로 감쌉니다.
function createEmitter<E extends Record<string, any>>() {
  return function <K extends keyof E>(event: K, payload: E[K]) {
    // ...
  };
}

const emitApp = createEmitter<Events>();

여기서 호출이 복잡해지면 payload 쪽이 K 추론에 영향을 주는 케이스가 생깁니다(특히 payload를 변수로 전달하거나, 유니온이 섞인 타입을 전달할 때).

NoInfer 적용: payload는 검증만, 추론은 event에서

function createEmitter<E extends Record<string, any>>() {
  return function <K extends keyof E>(
    event: K,
    payload: NoInfer<E[K]>
  ) {
    // payload는 E[K]에 맞는지 검사만 하고
    // K를 결정하는 데는 참여하지 않음
  };
}

const emitApp = createEmitter<Events>();

emitApp("userCreated", { id: "1", email: "a@b.com" });
// emitApp("userCreated", { id: "1" }); // 에러: email 누락

const p = { id: "1" };
// emitApp("userCreated", p); // 에러 유지: payload가 추론을 흔들지 않음

이 패턴의 장점은 명확합니다.

  • 이벤트 이름 event가 추론의 “주도권”을 가짐
  • payload는 그 결과 타입에 맞는지만 확인
  • payload가 넓은 타입(변수)이라도 K가 넓어지지 않음

실전 2: 오브젝트 키 기반 API에서 fallback이 타입을 망치는 문제

getOr 같은 유틸은 자주 쓰이지만, fallback 때문에 타입이 넓어지는 상황이 잦습니다.

개선 버전: fallback에 NoInfer

function getOr<T, K extends keyof T>(
  obj: T,
  key: K,
  fallback: NoInfer<T[K]>
): T[K] {
  const v = obj[key];
  return (v ?? fallback) as T[K];
}

const user = {
  id: "u1",
  age: 20,
  flags: ["a", "b"] as const,
};

const age = getOr(user, "age", 0); // number
// const bad = getOr(user, "age", "0"); // 에러

여기서 중요한 점은 fallback이 추론을 돕지 못하게 막았기 때문에, key가 결정한 T[K]에 fallback이 맞는지 검증만 수행한다는 것입니다.

이런 함수는 팀 내에서 한 번 만들어두면, “타입이 왔다 갔다 하는” 미묘한 버그를 크게 줄입니다.

실전 3: 라우터 핸들러에서 req/res 타입이 유니온으로 폭주할 때

라우팅은 보통 다음처럼 “경로 문자열”에서 타입을 뽑고, 핸들러 시그니처를 강제합니다.

type Routes = {
  "/users": { req: { q?: string }; res: { items: string[] } };
  "/users/:id": { req: { id: string }; res: { item: string } };
};

function route<P extends keyof Routes>(
  path: P,
  handler: (req: Routes[P]["req"]) => Routes[P]["res"]
) {
  // ...
}

여기서 handler가 복잡해지면(특히 함수가 별도 변수로 분리되거나, 제네릭 함수가 들어가거나, any/unknown이 섞일 때) handler 쪽이 P 추론에 영향을 주며 P가 넓어지는 문제가 생길 수 있습니다.

NoInfer로 path가 추론을 주도하게 만들기

function route<P extends keyof Routes>(
  path: P,
  handler: NoInfer<(req: Routes[P]["req"]) => Routes[P]["res"]>
) {
  // handler는 P를 결정하지 못하고, P에 맞는지만 검사
}

route("/users", (req) => {
  return { items: [req.q ?? ""] };
});

// route("/users", (req) => ({ item: "x" })); // 에러: res 타입 불일치

이렇게 하면 API 사용자가 핸들러 구현을 어떻게 하든, 경로 path가 타입의 기준점이 됩니다. 라우터/핸들러 기반 라이브러리에서 특히 유용합니다.

NoInfer를 어디에 붙여야 하나: 판단 기준 3가지

1) “이 인자는 타입을 결정하면 안 된다”면 붙인다

  • payload
  • fallback/default value
  • options 객체(특히 부분 옵션)
  • handler 함수(경로/키/이벤트가 기준이어야 할 때)

2) 리터럴이 아닌 값(변수)이 자주 들어오는 자리면 붙인다

리터럴은 대체로 정확하지만, 변수는 넓은 타입으로 승격되어 추론을 흔듭니다. 호출부에서 변수를 많이 넘기는 자리라면 NoInfer가 안정성을 줍니다.

3) “추론이 양방향으로 걸려 있는” 제네릭이면 붙인다

T가 A에서도 추론되고 B에서도 추론되는 구조가 문제의 시작입니다. 그중 한쪽을 NoInfer로 막아 단방향 추론으로 만들어 주세요.

NoInfer vs satisfies vs as const vs 오버로드

satisfies

satisfies는 “값의 타입을 좁히지 않으면서도 요구사항을 만족하는지 검사”하는 용도입니다. 반면 NoInfer함수 호출에서 제네릭 추론 경로를 제어합니다.

  • 설정 객체/상수 선언: satisfies가 더 적합
  • 함수 API에서 추론 폭주 제어: NoInfer가 더 적합

관련해서는 TS 5.x satisfies로 타입 오류 줄이는 실전을 참고하면, 두 도구를 같이 쓰는 패턴(상수는 satisfies, 호출은 NoInfer)이 정리됩니다.

as const

as const는 리터럴을 최대한 좁히는 도구라서, 오히려 추론을 “정확하게” 만드는 데 도움이 됩니다. 하지만 변수를 넘기는 상황까지 해결하진 못합니다. NoInfer는 그 다음 단계의 제어 장치입니다.

오버로드

오버로드는 호출 시그니처를 분리해 추론을 단순화할 수 있지만, 경우의 수가 많아지면 유지보수가 어렵습니다. NoInfer하나의 시그니처를 유지하면서 추론만 제어할 수 있어, 라이브러리/공용 유틸에서 특히 비용 대비 효과가 좋습니다.

컴파일 성능 관점: 타입 폭주를 “설계로” 막기

타입 추론 폭주는 단지 타입이 넓어지는 문제만이 아닙니다. 조건부 타입/유니온/분산이 섞이면 타입 체커가 탐색해야 하는 후보가 늘어나 컴파일 성능에도 영향을 줍니다.

NoInfer로 추론 경로를 줄이면 다음 효과를 기대할 수 있습니다.

  • 추론 후보 수 감소
  • 유니온 확장 억제
  • 오버로드 선택 비용 감소

즉, 타입 시스템에서의 “과한 자동화”를 줄여 빌드/에디터 반응성을 지키는 전략이 됩니다. 이런 관점은 런타임에서 과도한 재시도/폭주를 제어하는 패턴(예: 지수 백오프)과도 유사합니다. 관심 있다면 OpenAI API 429 폭탄 대응 실전 가이드 지수 백오프 큐잉 토큰 버짓으로 비용과 지연을 함께 줄이기처럼 “폭주를 제어하는 설계” 글과도 사고방식이 연결됩니다.

팀 적용 체크리스트

  • 공용 유틸/SDK 함수 중, 제네릭이 2개 이상이고 인자 2곳 이상에서 추론되는지 찾기
  • 그중 “기준점”이 되는 인자(키/이벤트/경로/모드)를 하나 정하기
  • 기준점이 아닌 인자(옵션/payload/fallback/handler)에 NoInfer를 붙여 추론 단방향화
  • 호출부에서 타입이 갑자기 넓어지던 케이스(변수 전달, 유니온 전달)를 회귀 테스트로 추가

마무리

TS 5.5+의 NoInfer는 타입을 더 복잡하게 만드는 기능이 아니라, 복잡해진 타입 추론을 다시 통제 가능한 방향으로 되돌리는 기능입니다. 특히 라이브러리/사내 공용 SDK처럼 “다양한 호출 패턴을 받아야 하는 API”에서, 추론 폭주를 막아 타입 안정성과 컴파일 성능, DX를 동시에 개선할 수 있습니다.

정리하면 다음 한 줄입니다.

  • “이 인자는 타입을 결정하면 안 된다” 싶은 자리에는 NoInfer를 붙여라.