Section 2 · Generics in Depth
Controlling Inference: NoInfer, Defaults and Overloads
17 min3 min readCode playground
Lesson at a glance
Block unwanted inference sites with NoInfer, give type parameters sensible defaults, and choose between overloads, unions and separate functions when the return type depends on the input.
- 1NoInfer: check here, but do not infer from here
- 2Defaults for type parameters
- 3When the return type depends on the argument
No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.
Here is a helper from Cartwheel’s checkout. It looks safe, and it has a hole:
function applyCoupon<C extends string>(available: C[], code: C) {
// …
}
applyCoupon(['WELCOME10', 'LOYAL5'], 'WELCOM10'); // compilesThe intent is clear: code must be one of the available coupons. But C appears in both parameters, and as you saw last lesson, every appearance is an inference site. TypeScript collects 'WELCOME10', 'LOYAL5' and 'WELCOM10' as candidates, makes C the union of all three, and everything type-checks. The typo was not checked against the list; it was added to it.
NoInfer: check here, but do not infer from here
TypeScript 5.4 added the intrinsic type NoInfer<T>. It evaluates to plain T, but it marks that position as off-limits for inference:
function applyCoupon<C extends string>(available: C[], code: NoInfer<C>) {
// …
}
applyCoupon(['WELCOME10', 'LOYAL5'], 'WELCOM10');
// Error: Argument of type '"WELCOM10"' is not assignable to parameter of type '"WELCOME10" | "LOYAL5"'.Now C is inferred only from available, and code is checked against the result. The pattern appears whenever one argument defines a set and another argument must pick from it: an initial state that must be one of the declared states, a default value that must match the options, a sort key that must be one of the listed columns.
function createMachine<S extends string>(config: { states: S[]; initial: NoInfer<S> }) {
return config;
}
createMachine({ states: ['cart', 'shipping', 'payment'], initial: 'cart' }); // ok
createMachine({ states: ['cart', 'shipping', 'payment'], initial: 'checkout' }); // errorBefore 5.4, the workaround was a second type parameter constrained by the first, <C extends string, D extends C>(available: C[], code: D). It works, and you will see it in older libraries, but it adds a parameter that exists only to dodge inference. Use NoInfer in new code. It also documents intent: a reader who sees initial: NoInfer<S> knows at a glance which argument is the source of truth and which one is only being checked, something the two-parameter trick never made obvious.
Defaults for type parameters
A type parameter can have a default, used when there is nothing to infer from:
type ApiResponse<T = unknown> = { data: T; requestId: string };
async function request<TRes = unknown, TBody = undefined>(path: string, body?: TBody): Promise<TRes> {
const res = await fetch(path, { method: body === undefined ? 'GET' : 'POST', body: JSON.stringify(body) });
return res.json();
}Two things make defaults useful. First, ApiResponse on its own now means ApiResponse<unknown>, which is a safe default: callers must narrow data before using it. Second, defaults make partial explicit type arguments legal. Without them, request<Order>('/orders/1042') would fail with “Expected 2 type arguments”; with them, TBody falls back to undefined.
Choose defaults that are safe rather than convenient. unknown is a good default for data that comes from outside; any is not, because it silently switches off checking for everyone who forgets the type argument. (And notice that request<Order> is still a cast in disguise: the response body is not checked. You will fix that properly in the next lesson and in Parsing Untrusted Data at the Boundary.)
When the return type depends on the argument
Sometimes the kind of argument decides the kind of result. Overloads describe that directly:
function formatPrice(cents: number): string;
function formatPrice(cents: number[]): string[];
function formatPrice(cents: number | number[]): string | string[] {
return Array.isArray(cents) ? cents.map(toDollars) : toDollars(cents);
}
function toDollars(cents: number): string {
return `${(cents / 100).toFixed(2)}`;
}
const one = formatPrice(4500); // string
const many = formatPrice([4500, 1299]); // string[]The first two lines are the overload signatures callers see. The third is the implementation signature, which must be compatible with all of them and is invisible from outside. TypeScript tries the overloads top to bottom and uses the first that matches, so list the most specific first.
Overloads have one sharp edge: a caller holding a number | number[] matches neither overload, and gets “No overload matches this call”. You would need a third overload that accepts the union. That edge is a hint about the design. If callers often have the union, the function probably wants a single signature; if they rarely do, two separate functions with clear names (formatPrice, formatPrices) usually beat one overloaded one.
In the exercise you will fix three of these inference problems in Cartwheel’s checkout. The next lesson puts everything from this section together in one typed API client.
Key takeaways
- Every appearance of a type parameter is an inference site, so a typo in one argument can widen the type instead of being rejected.
NoInfer<T>(TypeScript 5.4+) marks a position as check-only: it is validated againstTbut never contributes a candidate.- Type parameter defaults apply when there is nothing to infer from, and they make partial explicit type arguments possible.
- Overloads let the return type depend on the argument type, but a union argument matches none of them; two well-named functions are often clearer.