EduFlow

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.

  1. 1NoInfer: check here, but do not infer from here
  2. 2Defaults for type parameters
  3. 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:

TypeScript
function applyCoupon<C extends string>(available: C[], code: C) {
  // …
}

applyCoupon(['WELCOME10', 'LOYAL5'], 'WELCOM10'); // compiles

The 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:

TypeScript
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.

TypeScript
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' }); // error

Before 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:

TypeScript
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:

TypeScript
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 against T but 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.

Further reading

Check your understanding

4 questions

Question 1 of 4

Why does this call compile even though 'WELCOM10' is a typo?

TypeScript
function applyCoupon<C extends string>(available: C[], code: C) {}
applyCoupon(['WELCOME10', 'LOYAL5'], 'WELCOM10');
Question 2 of 4

function request<TRes = unknown, TBody = undefined>(path: string, body?: TBody): Promise<TRes>. What does request<Order>('/orders/1') do?

Question 3 of 4

With these overloads, what happens on the last line?

TypeScript
function getOrder(id: number): Promise<Order>;
function getOrder(ids: number[]): Promise<Order[]>;
function getOrder(idOrIds: number | number[]): Promise<Order | Order[]> { /* … */ }
declare const input: number | number[];
getOrder(input);
Question 4 of 4

You are designing a lookup that returns one order for an id and a list for a customer. Which API is easiest for callers and maintainers?

Practice

Code playground

Stop the typos, type the overloads

Fix three inference problems in Cartwheel’s checkout. Do not change the calls under the type tests.

  1. applyCoupon(available, code) must reject a code that is not in available, instead of adding it to the union.
  2. createMachine({ states, initial }) must reject an initial state that is not in states.
  3. formatPrice must return string for a number and string[] for an array of numbers.
function applyCoupon<C extends string>(available: C[], code: C): string {
  return available.includes(code) ? `Applied ${code}` : `Unknown coupon ${code}`;
}

function createMachine<S extends string>(config: { states: S[]; initial: S }) {
  return { states: config.states, initial: config.initial };
}

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)}`;
}

// ---- Type tests (do not edit) ----
applyCoupon(['WELCOME10', 'LOYAL5'], 'LOYAL5');
// @ts-expect-error: 'WELCOM10' is a typo, not an available coupon
applyCoupon(['WELCOME10', 'LOYAL5'], 'WELCOM10');

const machine = createMachine({ states: ['cart', 'shipping', 'payment'], initial: 'cart' });
type T1 = Expect<Equal<typeof machine.initial, 'cart' | 'shipping' | 'payment'>>;
// @ts-expect-error: 'checkout' is not one of the declared states
createMachine({ states: ['cart', 'shipping', 'payment'], initial: 'checkout' });

const one = formatPrice(4500);
const many = formatPrice([4500, 1299]);
type T2 = Expect<Equal<typeof one, string>>;
type T3 = Expect<Equal<typeof many, string[]>>;

console.log(applyCoupon(['WELCOME10'], 'WELCOME10'), machine.initial, one, many);
Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.

Console

(no output)

Checks

  • The file type-checks with zero errors, including all type tests
  • formatPrice formats a single price and a list of prices
  • applyCoupon still applies a valid coupon