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 withas const, keeping exact values. - NoInfer
- A built-in helper type,
NoInfer<T>, that stops its position from adding candidates forT. - 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 generalstringwhen 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.
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 mean02const 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.
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.
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
Take const away
In the first example delete
constfromtupleExact<const T ...>. Predictexactbefore you look. It becomesstring[], the same asloose. - 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 thepaintcall: now it is a valid colour and there is no error. - 3
Store the array first
Put
const colors = ["red", "green"];above the calls and passcolors. What isCnow? (string.) The array was already widened when you stored it;NoInferandconst Tcan only act on what is written in the call.
Code & diagrams
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/:idpaintLoose 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 greenThe 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 ]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 undefinedBreak 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).
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.
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 asTfor assignability but skips it when collecting inference candidates. Outside inference it behaves likeTand 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 thestring[]and reports an error on the assignment, instead of bendingT. - ▸
Because
consttypes the argument as an exact value, prefer **readonlyconstraints** (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 declaredreadonly. Run// ^?on a call to see which form you actually get.
Remember this
- 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 candidates1and2; they combine into1 | 2. Forsame("a", 1)the candidates are"a"and1. They are not related, so TypeScript fixesTfrom 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
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"]becomesstring[], 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 writeas constat every call. A **consttype parameter** (function f<const T>(x: T), TypeScript 5.0) does it for the caller: the argument is typed as if it ended withas const. The exact values andreadonlycome automatically. - 3
Two limits of
consttype parameters. They only affect literals written in the call: a value stored in a variable first has already been widened (const arr = ["a", "b"]isstring[], andf(arr)staysstring[]). And the constraint decides the container: withT extends readonly unknown[]you get areadonlytuple, with a mutableT 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"]). Usereadonlyconstraints for config tables: they document that the function does not change its input. - 4
Too eager: when the wrong argument decides the type.
function paint<C extends string>(colors: C[], fallback: C). The callpaint(["red", "green"], "blue")compiles, becausefallbackis also a candidate:Cbecomes"red" | "green" | "blue", which is not what you meant. You wantedcolorsto decide the type andfallbackonly 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". Withfallback: NoInfer<C>,Ccomes only fromcolors, and"blue"is an error. - 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 whynew Set()assigned toSet<number>works without writing<number>. It is a hint only used when arguments give no candidates. - 6
Where inference gives up. (1) Nothing to read:
empty()givesunknown(the type parameter's constraint, orunknown). (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 whyapply((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
The tools in order of preference: (1) restructure the signature so every type parameter appears in a parameter that carries information; (2) add a
constorNoInfermodifier; (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
When two arguments share a type parameter and have unrelated types, what does TypeScript do?
What problem do const type parameters solve, and what are their limits?
What does NoInfer do? Give an example.
Where does inference give up, and what are your options?
Practice
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?
Write function setDefault<T extends string>(allowed: T[], value: NoInfer<T>): T, then show a typo being rejected.
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
- ↔
consttype parameters andNoInfermake 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
consttype parameter and say when it has no effect.I can use
NoInferto stop an argument from steeringT.I can explain how an expected type can supply
Tformake().I can list the places where inference gives up and what to do about each.