- Published on
TS 5.5 NoInfer로 제네릭 추론 꼬임 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 인자에서 같은 제네릭 T를 추론하게 만들면, TypeScript는 “둘 다 만족하는” 방향으로 T를 맞추려다 의도치 않게 타입을 넓히거나(예: string | number), 반대로 너무 좁혀 버리거나(예: 리터럴로 고정), 혹은 제약 조건 때문에 추론이 꼬여서 DX가 급격히 나빠지는 일이 자주 생깁니다.
TypeScript 5.5의 NoInfer는 이런 상황에서 **“이 위치에서는 추론에 참여하지 마”**라고 힌트를 주어, 어떤 인자(또는 반환/옵션)가 T의 결정권을 가지는지 명확히 나누게 해줍니다. 결과적으로 API 설계가 더 예측 가능해지고, 호출부에서 불필요한 타입 인자 명시나 캐스팅을 줄일 수 있습니다.
이 글에서는 NoInfer가 필요한 대표 패턴과, 실제 라이브러리/앱 코드에서 적용할 만한 실전 예제를 중심으로 정리합니다. (참고로 TS 5.x에서 타입 좁히기 관련으로 satisfies를 활용하는 패턴도 함께 알아두면 API 설계가 훨씬 깔끔해집니다: TS 5.x satisfies로 타입 좁히기 실패 해결법)
제네릭 추론이 “꼬인다”는 게 정확히 뭘까?
TypeScript의 제네릭 추론은 대략 다음처럼 동작합니다.
- 함수의 여러 파라미터/컨텍스트에서
T에 대한 후보를 수집한다. - 후보들을 통합(unify)해서 하나의
T를 결정한다. - 이 과정에서 유니언으로 넓어지거나, 리터럴로 고정되거나, 제약 조건과 충돌할 수 있다.
문제는 “내가 원한 결정권”이 항상 동일하지 않다는 점입니다.
- 어떤 API는
value로T를 결정하고,schema/validator는 그T를 검증만 하길 원한다. - 반대로
schema가T를 결정하고,value는 그T에 맞는지만 체크하길 원한다. - 또는 두 인자 모두에서 추론하되, 특정 위치(예: 콜백 파라미터)에서는 추론에 참여하면 안 되는 경우가 있다.
TS 5.5의 NoInfer<T>는 이 “결정권 분리”를 타입 시스템 차원에서 표현하게 해줍니다.
TS 5.5 NoInfer란?
NoInfer<T>는 해당 위치의 타입은 T로 취급하되, 그 위치로부터 T를 추론하지는 않게 만드는 유틸리티 타입입니다.
즉,
arg: T→arg가T추론에 참여arg: NoInfer<T>→arg는T로 검사되지만T추론에는 불참
이게 왜 중요하냐면, “여러 인자에서 동시에 T를 추론하려다 발생하는 충돌”을 원천적으로 줄여주기 때문입니다.
> 주의: NoInfer는 타입 안전성을 무너뜨리는 캐스팅이 아니라, 추론 경로만 제어합니다. 검사는 여전히 T 기준으로 이뤄집니다.
예제 1) 값(value)로만 T를 결정하고, 옵션은 따라오게 만들기
아래처럼 value와 fallback을 받는 유틸을 생각해보겠습니다.
- 의도:
value로T를 결정 fallback은T여야 하지만,fallback이T를 흔들면 안 됨
NoInfer 없이 생기는 문제
function withFallback<T>(value: T, fallback: T): T {
return value ?? fallback;
}
const x = withFallback("hello", 123);
// T가 string | number 로 추론되어 버릴 수 있음
// 결과: x: string | number (의도와 다르게 넓어짐)
여기서 많은 사람의 기대는 “두 번째 인자가 틀렸으니 에러”입니다. 하지만 T를 양쪽에서 추론하면, TS는 T = string | number라는 타협안을 만들 여지가 생깁니다.
NoInfer로 해결
function withFallback<T>(value: T, fallback: NoInfer<T>): T {
return (value ?? fallback) as T;
}
withFallback("hello", "world"); // OK
withFallback("hello", 123);
// ~~~~~ ~~~
// error: 'number' is not assignable to 'string'
핵심은 fallback이 T 추론에 참여하지 못하게 막아서, T가 오직 value에서만 결정되도록 만든 것입니다.
예제 2) 스키마(schema)로만 T를 결정하고, 값은 검증만 하게 만들기
런타임 검증 라이브러리(예: zod/yup 같은)나 내부 스키마 시스템을 만들 때 흔한 패턴입니다.
schema가T를 결정data는T에 맞는지 검사만
type Schema<T> = {
parse(input: unknown): T;
};
function parseWithSchema<T>(schema: Schema<T>, input: NoInfer<T>): T {
// input은 T로 "검사"되지만, T는 schema에서만 결정되길 원함
return schema.parse(input);
}
const numberSchema: Schema<number> = {
parse(x) {
if (typeof x !== "number") throw new Error("not a number");
return x;
},
};
parseWithSchema(numberSchema, 123); // OK
parseWithSchema(numberSchema, "123");
// ~~~~~
// error: 'string' is not assignable to 'number'
여기서 input을 unknown으로 두고 런타임 검증만 하는 설계도 가능하지만, 호출부에서 실수(예: 이미 타입이 잘못된 값)를 빨리 잡고 싶다면 NoInfer<T>가 유용합니다.
예제 3) 이벤트/핸들러 맵에서 과도한 유니언 추론 막기
이벤트 이름과 페이로드 타입이 연결된 시스템에서 “이벤트 이름”으로 타입을 결정하고, 핸들러는 그 타입을 따라가게 하고 싶을 때가 많습니다.
type Events = {
login: { userId: string };
logout: { reason?: string };
};
function emit<K extends keyof Events>(
event: K,
payload: NoInfer<Events[K]>
) {
// ...
}
emit("login", { userId: "u1" }); // OK
emit("login", { reason: "bye" });
// ~~~~~~~~~~~~~~~~~
// error: Object literal may only specify known properties
만약 payload: Events[K]로만 두고 다른 위치(예: 오버로드/콜백)에서 K 추론이 섞이면, K가 "login" | "logout"로 넓어져 payload가 애매해지는 케이스가 나옵니다. NoInfer는 이런 “추론 경로 섞임”을 줄이는 데 도움 됩니다.
예제 4) 콜백에서 T가 역으로 추론되는 문제(특히 builder 패턴)
builder/pipe/compose 류 API에서 자주 보는 형태입니다.
function mapValue<T, R>(value: T, fn: (v: NoInfer<T>) => R): R {
return fn(value);
}
mapValue("hello", (v) => v.toUpperCase()); // OK
mapValue("hello", (v: "hello") => v);
// 콜백 파라미터를 리터럴로 좁혀도, value 쪽의 T를 흔들지 않음
콜백 파라미터 타입을 사용자가 명시했을 때, 그 타입이 T 추론에 영향을 주면(특히 복잡한 제네릭에서) 전체 추론이 예측 불가능해집니다. NoInfer<T>를 콜백 파라미터 쪽에 적용하면 “value가 결정한 T”를 콜백이 따르게 만들 수 있습니다.
NoInfer가 특히 효과적인 패턴 5가지
1) “기준 인자(anchor)” 하나로만 T를 결정하고 싶을 때
value로 결정,default는 검증만schema로 결정,input은 검증만
2) 오버로드/유니언과 결합되어 추론이 넓어질 때
T가A | B | C로 도망가면서 호출부 에러가 사라지는 현상
3) 콜백이 역으로 T를 결정해 버릴 때
- builder/pipe/compose
onChange,subscribe같은 API
4) “동일 타입이어야 한다”를 강제하고 싶은데 추론이 타협할 때
- 두 인자는 반드시 같은
T - 그런데 TS가 유니언으로 합쳐서 통과시켜버림
5) 라이브러리 공개 API에서 추론 안정성을 올리고 싶을 때
- 내부 구현은 바뀌어도, 사용자 경험은 예측 가능해야 함
NoInfer vs satisfies vs 타입 인자 명시: 언제 뭘 쓰나?
NoInfer: 추론 경로 제어가 목적. “누가 T를 결정하나?”를 설계할 때.satisfies: 값의 타입을 유지한 채로 특정 타입을 만족하는지 검사. 객체 리터럴/상수 정의에 특히 강함. 관련 내용은 TS 5.x satisfies로 타입 좁히기 실패 해결법 참고.- 타입 인자 명시(
fn<Type>(...)): 호출부가 의도를 강제로 박아 넣는 방식. 빠르지만 반복되면 API 사용성이 떨어짐.
실무에서는 보통 다음 우선순위가 깔끔합니다.
- 라이브러리/유틸 함수 시그니처를
NoInfer로 먼저 안정화 - 객체/설정 값은
satisfies로 안전하게 선언 - 정말 복잡한 케이스에서만 호출부 타입 인자 명시
마이그레이션/적용 팁
TS 버전과 린트/빌드 체인 확인
NoInfer는 TS 5.5에서 제공되는 내장 유틸입니다.- 모노레포라면 패키지별 TS 버전 불일치로 타입이 깨질 수 있으니, 루트/패키지
typescript버전을 정렬하세요.
“에러가 사라지던 코드”가 다시 에러가 날 수 있음
NoInfer는 추론 타협을 막기 때문에, 이전에 우연히 통과하던 호출이 올바르게 실패할 수 있습니다. 이건 대부분 좋은 신호입니다.
어디에 붙일지 감이 안 오면, 먼저 ‘결정권자’를 정하라
- 이 함수에서
T는 누가 결정해야 하는가?value?schema?key?config?
- 결정권자가 아닌 위치에
NoInfer<T>를 붙이면 됩니다.
정리
TS 5.5의 NoInfer는 “제네릭 추론이 여러 입력에서 서로 잡아당기며 꼬이는 문제”를 해결하기 위한, 꽤 오랫동안 커뮤니티가 원하던 도구입니다. 핵심은 단순합니다.
T로 검사하되,T를 추론하지는 말아라
이를 통해
- 유니언으로 도망가는 추론을 막고
- API의 타입 결정권을 명확히 하고
- 호출부에서 타입 인자 명시/캐스팅을 줄이며
- 라이브러리 품질(예측 가능성)을 크게 올릴 수 있습니다.
제네릭 API를 설계하거나, “왜 이게 string | number가 되지?” 같은 추론 이슈를 자주 만난다면, TS 5.5 업그레이드 후 NoInfer를 가장 먼저 적용해볼 만한 지점은 ‘기준 인자(anchor)’와 ‘검증 인자(validator)’가 동시에 존재하는 함수입니다.