EduFlow

Section 2 · Generics in Depth

How Inference Works, and const Type Parameters

20 min3 min readCode playground

Lesson at a glance

Predict what TypeScript infers for a type parameter, keep literal types with constraints and const type parameters, and order callback arguments so inference flows the right way.

  1. 1Inference sites and candidates
  2. 2When literals widen
  3. 3const type parameters
  4. 4Callbacks are inferred last

No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.

In the last lesson you wrote sortByTotal(orders) and TypeScript worked out that T was DetailedOrder. Most of the time inference does what you want, which makes the times it does not feel random. They are not. TypeScript follows a small set of rules, and once you know them you can design signatures that infer exactly the type you intend, with no <…> at the call site.

Inference sites and candidates

Every place a type parameter appears inside a parameter’s type is an inference site. At a call, TypeScript matches each argument against its parameter type and collects a candidate for the type parameter from each site. Then it picks one type from the candidates.

TypeScript
function same<T>(a: T, b: T): T[] {
  return [a, b];
}

same('processing', 'shipped'); // T = string, so string[]
same(1, 'a');
// Error: Argument of type 'string' is not assignable to parameter of type 'number'.

With two string literals, the candidates 'processing' and 'shipped' are combined and then widened to string, because T is only returned inside an array (the next section explains when literals survive). With 1 and 'a', TypeScript does not invent number | string; it picks the first candidate, number, and then the second argument fails against it. That is deliberate. Silently widening to a union would hide the bugs generics are meant to catch. If you really want mixed values, say so: same<number | string>(1, 'a').

Inference pipeline. Two arguments at the call site each produce a candidate for T through their inference sites. The candidates are combined and widened by rules into one chosen T, which is then substituted into the return type. a: 'processing' b: 'shipped' candidates combine, widen? T chosen string return: T[]
From arguments to a chosen type to the return type

When literals widen

The second rule explains most “why did I get string?” moments. A literal candidate like 'processing' is kept or widened depending on the signature:

TypeScript
function identity<T>(value: T): T { return value; }
function box<T>(value: T): { value: T } { return { value }; }
function boxStatus<T extends string>(value: T): { value: T } { return { value }; }

const a = identity('processing');  // 'processing'
const b = box('processing');       // { value: string }
const c = boxStatus('processing'); // { value: 'processing' }

The literal survives when T is returned directly at the top level (identity), or when T’s constraint includes a primitive type such as string (boxStatus). Otherwise it widens, on the theory that a value tucked into an object will probably be reassigned later.

Objects follow the same logic, and here a constraint has a second benefit: it gives the argument a contextual type.

TypeScript
type OrderStatus = 'processing' | 'shipped' | 'delivered' | 'cancelled' | 'returned';

function draft<T>(order: T): T { return order; }
function draftOrder<T extends { status: OrderStatus }>(order: T): T { return order; }

draft({ status: 'processing' });      // { status: string }
draftOrder({ status: 'processing' }); // { status: 'processing' }

Because the constraint says status is an OrderStatus, TypeScript checks the literal against that union while inferring, and the literal type sticks. A good constraint both documents the function and improves what it infers.

const type parameters

Sometimes you want the caller’s argument exactly as written: tuple order, literal values, nested objects. Before TypeScript 5.0 that meant asking every caller to write as const. Now the function can ask for it:

TypeScript
function defineFlow<const T extends readonly OrderStatus[]>(steps: T): T {
  return steps;
}

const happyPath = defineFlow(['processing', 'shipped', 'delivered']);
// readonly ['processing', 'shipped', 'delivered']

The const modifier tells TypeScript to infer the argument as if it had as const: literals stay literal, arrays become readonly tuples, object properties become readonly. Pair it with a readonly array constraint, since as const produces readonly arrays. It only affects literals written directly in the call; a variable you pass in keeps whatever type it already had.

Callbacks are inferred last

Arguments that are functions with unannotated parameters, such as (d) => d.totalCents, are context-sensitive: their parameter types come from the signature, which may still depend on T. TypeScript infers from everything else first, then types these callbacks. Inside a single object literal, it works through context-sensitive properties from left to right:

TypeScript
function createStep<T>(config: { load: (orderId: number) => T; render: (data: T) => string }) {}

createStep({
  load: (orderId) => ({ orderId, totalCents: 4500 }),
  render: (d) => d.totalCents.toFixed(), // d: { orderId: number; totalCents: number }
});

createStep({
  render: (d) => d.totalCents.toFixed(), // Error: 'd' is of type 'unknown'.
  load: (orderId) => ({ orderId, totalCents: 4500 }),
});

When render comes first, T has no candidate yet. You can fix it at the call site by swapping the properties or annotating orderId: number, which makes load no longer context-sensitive. Better, fix it in the API: put the producer before the consumer in your parameter lists and documented examples, so the natural way to call it is the way that infers.

In the exercise you will make three Cartwheel helpers keep the literals their callers pass. Next, you will learn to stop inference from happening where it should not.

Key takeaways

  • Every place a type parameter appears in a parameter type is an inference site; TypeScript collects a candidate from each argument and picks one type.
  • Inferred literals widen unless the type parameter’s constraint includes a primitive type or the parameter is declared const.
  • A const type parameter infers arguments as if they were written with as const, keeping literals and readonly tuples without asking the caller.
  • Callbacks with unannotated parameters are inferred after other arguments, and inside one object literal TypeScript works left to right.
  • Explicit type arguments are all or nothing; specify one and TypeScript stops inferring the rest.

Further reading

Check your understanding

4 questions

Question 1 of 4

What is the type of b?

TypeScript
function box<T>(value: T): { value: T } { return { value }; }
const b = box('processing');
Question 2 of 4

Which declaration makes defineFlow(['processing', 'shipped']) return the type readonly ['processing', 'shipped'] without the caller writing as const?

Question 3 of 4

Why is d typed as unknown here?

TypeScript
createStep({
  render: (d) => d.totalCents.toFixed(),
  load: (orderId) => ({ orderId, totalCents: 4500 }),
});
Question 4 of 4

function convert<From, To>(value: From, to: (v: From) => To): To is called as convert<string>('12', Number). What happens?

Practice

Code playground

Keep the caller's literals

Three definition helpers in Cartwheel’s checkout lose the precise types their callers write. Make the type tests pass without changing the calls (no as const at the call sites):

  • defineFlow(steps) returns the steps as a readonly tuple of literal statuses, and still rejects unknown statuses.
  • oneOf(values) returns a type guard: after if (isChannel(raw)), raw is narrowed to the literal values passed in.
  • defineFlags(flags) keeps each flag’s exact shape (including the countries tuple) while still checking every flag against Flag.

All three return their argument unchanged at runtime (oneOf returns a checking function).

type OrderStatus = 'processing' | 'shipped' | 'delivered' | 'cancelled' | 'returned';
type Flag = { rollout: number; countries: readonly string[] };

function defineFlow(steps: OrderStatus[]) {
  return steps;
}

function oneOf(values: readonly string[]) {
  return (input: string): boolean => values.includes(input);
}

function defineFlags(flags: Record<string, Flag>) {
  return flags;
}

// ---- Type tests (do not edit) ----
const happyPath = defineFlow(['processing', 'shipped', 'delivered']);
type T1 = Expect<Equal<typeof happyPath, readonly ['processing', 'shipped', 'delivered']>>;

// @ts-expect-error: 'refunded' is not an order status
defineFlow(['processing', 'refunded']);

const isChannel = oneOf(['web', 'mobile_app', 'marketplace']);
const raw: string = 'mobile_app';
if (isChannel(raw)) {
  type T2 = Expect<Equal<typeof raw, 'web' | 'mobile_app' | 'marketplace'>>;
}

const flags = defineFlags({
  expressShipping: { rollout: 0.5, countries: ['EG', 'AE'] },
  giftWrap: { rollout: 1, countries: [] },
});
type T3 = Expect<Equal<typeof flags.expressShipping.countries, readonly ['EG', 'AE']>>;

// @ts-expect-error: every flag needs a rollout
defineFlags({ applePay: { countries: ['US'] } });

console.log(happyPath, isChannel('web'), isChannel('fax'), flags.expressShipping.countries);
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
  • oneOf returns a function that accepts only the listed values
  • defineFlow and defineFlags return their argument unchanged