Command Palette

Search for a command to run...

Hectal
PHASE 7Intermediate ~27 min· topic 6 of 7

Topic 7.6

Controlling Inference

In one line

Inference is usually right, and sometimes too wide, too narrow or from the wrong place. const type parameters keep exact literals, NoInfer stops an argument from steering the type, and you can read where inference gives up.

Think of it like this

A school photographer lines students up by height. Most of the time the photographer guesses right from a quick glance. But sometimes two children are the same height, or a child stands on tiptoe, and the photographer needs a rule: "measure this child, ignore that one". Inference control gives the compiler the rule: read this argument for the type, don't read that one, keep the exact value, or keep it wide.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Candidate
A possible value for a type parameter taken from one argument. The compiler combines or chooses between candidates.
const type parameter
A type parameter declared <const T>: arguments are typed as if they ended with as const, keeping exact values.
NoInfer
A built-in helper type, NoInfer<T>, that stops its position from adding candidates for T.
Contextual type
The type that the place where a value is used expects. It can guide inference.
Widening
Turning an exact type like "red" into the general string when a value could change.
Literal
A value written directly in the code: "red", 42, [1, 2].
Context-sensitive function
An arrow function with unannotated parameters. The compiler types it after the arguments that carry type information.
Fallback type
The type used when inference finds nothing: the parameter's constraint, or unknown.

Step by step

01Candidates that disagree

same<T>(a: T, b: T) mentions T twice, so each argument adds a candidate. With 1 and 2 the types are related and combine. With "a" and 1 they are not, so the compiler stops at the first and blames the second.

same.tswhole filets
function same<T>(a: T, b: T): T { return a; }
same(1, 2);      // T is 1 | 2
same("a", 1);    // error: Argument of type '1' is not assignable to parameter of type '"a"'
same<string | number>("a", 1); // fine: you said what you mean

02const T keeps exact values

Without const, tupleLoose(["GET", "/users"]) widens to string[]. With <const T> the call is typed as readonly ["GET", "/users"]. The same works for objects: defineRoutes({ home: "/" }) keeps the literal "/" and makes the property readonly.

const-t.tswhole filets
function tupleExact<const T extends readonly unknown[]>(items: T): T {
  return items;
}
const exact = tupleExact(["GET", "/users"]);
// readonly ["GET", "/users"]

03NoInfer: check here, but don't learn from here

In paint(colors, fallback), you want colors to define C. NoInfer<C> on fallback makes the compiler use the already-chosen C to check it, and not add "blue" as a candidate.

NoInfer: check here, but don't learn from herediagram
Rendering diagram…

04Reading backwards from the return type

const list: string[] = make() hands the compiler an expected type. make<T>(): T[] mentions T only in its return type, so the expected string[] becomes the candidate: T is string. The same trick makes const s: Set<number> = new Set() work.

05Order of work: values first, arrows later

apply<T>(fn: (x: T) => void, value: T). The arrow function is first in the call, but it has an untyped parameter x, so the compiler waits. It reads value (42), decides T is number, and then types x as number. If both were untyped arrows with nothing else to read, T would be unknown.

Try it yourself

  1. 1

    Take const away

    In the first example delete const from tupleExact<const T ...>. Predict exact before you look. It becomes string[], the same as loose.

  2. 2

    Fix the typo in the right place

    In the NoInfer example, change "blue" to "green" on line 9. The error disappears. Or leave the typo and add "blue" to the colors list of the paint call: now it is a valid colour and there is no error.

  3. 3

    Store the array first

    Put const colors = ["red", "green"]; above the calls and pass colors. What is C now? (string.) The array was already widened when you stored it; NoInfer and const T can only act on what is written in the call.

Code & diagrams

const type parameters: keep the exact values TypeScript 5.0+

The same array gives string[] without const and a readonly tuple of exact texts with it. The object keeps the exact paths and becomes readonly. At runtime nothing changed: const only affects the types.

Console

(nothing was printed)

Expected console output

[ 'GET', '/users' ] [ 'GET', '/users' ] /user/:id
NoInfer: the wrong argument must not steer T TypeScript 5.4+

paintLoose lets the fallback add a candidate, so C quietly becomes red | green | blue and the typo "blue" passes. With NoInfer only colors defines C, so "blue" is reported and "green" is fine.

Console

(nothing was printed)

Expected console output

red green
Contextual types, conflicting candidates and explicit arguments

The expected types on the left feed inference for make() and new Set(). In same, the unrelated candidates "a" and 1 give an error on the second argument; writing the union by hand fixes it.

Console

(nothing was printed)

Expected console output

[] Set(0) {} 1 a [ 1, 2 ]
Values first, arrow functions later, and nothing to read

In both calls the arrow's parameter x is typed number, although apply lists the arrow first: the compiler reads the value 42 first. noHint has no argument at all, so T falls back to unknown. The cast inside noHint is exactly why this is a function to avoid.

Console

(nothing was printed)

Expected console output

42
42.0
undefined undefined

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Unrelated candidates

In the third example, line 12 reads same("a", 1).

terminal
$ Types panel
── what you'll see ──
index.ts(12,22): error TS2345: Argument of type '1' is not assignable to parameter of type '"a"'.

Break #2

Let the fallback steer the type

Look at the call on line 7, paintLoose(["red", "green"], "blue"). Its signature has fallback: C, with no NoInfer.

terminal
$ Types panel
── what you'll see ──
index.ts(7,7): const a: "blue" | "green" | "red"

Myth vs fact

Myth

as const and const T are the same thing.

Fact

as const is written by the caller on a value. <const T> is written by the function author and applies the same treatment to every literal argument, so callers need nothing. It doesn't help for values that were widened earlier in a variable.

Myth

NoInfer changes which values are accepted.

Fact

It changes where the type is learned from, which can reject values that were wrongly accepted. It is erased and has no effect at runtime.

Myth

TypeScript infers a union when arguments disagree.

Fact

Only in some positions. For a type parameter used in two parameters like (a: T, b: T), unrelated candidates are an error. Unions come from arrays and object literals with mixed values, not from separate arguments.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    NoInfer<T> is an intrinsic type: the checker treats the position as T for assignability but skips it when collecting inference candidates. Outside inference it behaves like T and it is erased at runtime.

  • ▸

    Priority matters in inference: candidates from a return-type context have lower priority than candidates from arguments. So const x: string[] = pickFrom(numbers) ignores the string[] and reports an error on the assignment, instead of bending T.

  • ▸

    Because const types the argument as an exact value, prefer **readonly constraints** (readonly string[]) for parameters you only read. The mutable form (string[]) works in TypeScript 6.0 and 7.0, but the readonly form says what you mean and also accepts arrays that are declared readonly. Run // ^? on a call to see which form you actually get.

Remember this

  1. 1

    The problem this solves: When the compiler guesses a generic's type, it can pick one that is too wide, too narrow or surprising, and the error appears far from the cause. Knowing its rules lets you steer it. First, how the compiler decides (recall from Topic 7.2: it collects candidates). For every type parameter it gathers candidates from the arguments that mention it. In function same<T>(a: T, b: T), same(1, 2) gives candidates 1 and 2; they combine into 1 | 2. For same("a", 1) the candidates are "a" and 1. They are not related, so TypeScript fixes T from the first one ("a") and reports the second argument as the error: Argument of type '1' is not assignable to parameter of type '"a"'. The cure is to say what you mean: same<string | number>("a", 1).

  2. 2

    Too wide: when exact values are lost. A normal inferred type parameter keeps the types of what was passed but not their *exactness*: an array literal ["GET", "/users"] becomes string[], and an object { home: "/" } becomes { home: string }. If you wanted the tuple of exact texts (a route table, a list of options), you used to write as const at every call. A **const type parameter** (function f<const T>(x: T), TypeScript 5.0) does it for the caller: the argument is typed as if it ended with as const. The exact values and readonly come automatically.

  3. 3

    Two limits of const type parameters. They only affect literals written in the call: a value stored in a variable first has already been widened (const arr = ["a", "b"] is string[], and f(arr) stays string[]). And the constraint decides the container: with T extends readonly unknown[] you get a readonly tuple, with a mutable T extends unknown[] you get a mutable tuple (checked on TypeScript 6.0 and 7.0: tupleMut(["GET", "/users"]) with <const T extends unknown[]> is typed ["GET", "/users"]). Use readonly constraints for config tables: they document that the function does not change its input.

  4. 4

    Too eager: when the wrong argument decides the type. function paint<C extends string>(colors: C[], fallback: C). The call paint(["red", "green"], "blue") compiles, because fallback is also a candidate: C becomes "red" | "green" | "blue", which is not what you meant. You wanted colors to decide the type and fallback only to be checked. **NoInfer<T>** (TypeScript 5.4) marks a position as "use the type here, but don't look at this argument to choose it". With fallback: NoInfer<C>, C comes only from colors, and "blue" is an error.

  5. 5

    Inference from the return type. Information can also flow backwards. When a call is used where a type is expected (const list: string[] = make(), const s: Set<number> = new Set()) the expected type is a contextual type, and the compiler uses it as a candidate for type parameters that appear in the return type. Contextual typing is why new Set() assigned to Set<number> works without writing <number>. It is a hint only used when arguments give no candidates.

  6. 6

    Where inference gives up. (1) Nothing to read: empty() gives unknown (the type parameter's constraint, or unknown). (2) Conflicting candidates: the first one wins and the later ones are errors. (3) A callback whose parameter types depend on a type parameter that is only decided later: the compiler types arguments that carry type information first, then the plain arrow functions (this is why apply((x) => ..., 42) works but you may need to reorder in your own signatures, see below). (4) Very deep or recursive types (Topic 9.5) can hit the compiler's limits and report an error such as "Type instantiation is excessively deep".

  7. 7

    The tools in order of preference: (1) restructure the signature so every type parameter appears in a parameter that carries information; (2) add a const or NoInfer modifier; (3) write the type argument yourself; (4) use an assertion as a last resort. A good generic function needs the fewest tools at its call sites.

Explain it without notes

01

When two arguments share a type parameter and have unrelated types, what does TypeScript do?

02

What problem do const type parameters solve, and what are their limits?

03

What does NoInfer do? Give an example.

04

Where does inference give up, and what are your options?

Practice

01

Write function pickOne<const T extends readonly string[]>(options: T): T[number] that returns the first option. What does pickOne(["low", "high"]) return as a type?

02

Write function setDefault<T extends string>(allowed: T[], value: NoInfer<T>): T, then show a typo being rejected.

03

Write pair<A, B>(a: A, b: B) and call it as pair("x", 1). Then write sameType<T>(a: T, b: T) and explain how to call it with a text and a number.

Trade-offs

  • ↔

    const type parameters and NoInfer make APIs precise, but each modifier is one more thing for a reader to learn. Use them in library-style helpers (route tables, config builders, option lists) where call sites benefit, not in everyday app code.

  • ↔

    Explicit type arguments always work but must be kept in sync. A signature that infers correctly is worth more than one that needs <...> at every call.

Done when you can

  • I can explain what happens when two arguments give unrelated candidates for T.

  • I can use a const type parameter and say when it has no effect.

  • I can use NoInfer to stop an argument from steering T.

  • I can explain how an expected type can supply T for make().

  • I can list the places where inference gives up and what to do about each.