TypeScript Generic Inference Uses Arguments, Context, and Defaults
Control generic inference with constraints, contextual typing, NoInfer, defaults, and explicit type arguments when evidence conflicts.
TypeScript infers generic type arguments from the value arguments and contextual types available at a call. A constraint limits which result is valid, but it does not usually replace the inferred type. A default applies only when inference has no candidate. If several positions provide unwanted or conflicting evidence, change the API, block one inference site with NoInfer, or supply type arguments explicitly.
The practical question is not simply “what type does this value have?” It is “which occurrences of the type parameter are allowed to choose the type argument?”
Arguments supply candidates and callbacks receive context
Consider a small version of Array.prototype.map:
function map<Input, Output>(
items: readonly Input[],
transform: (item: Input) => Output,
): Output[] {
return items.map(transform);
}
const lengths = map(['red', 'green'], color => color.length);
// Input is string, Output is number, lengths is number[]
The array supplies evidence for Input. That choice contextually types the callback parameter as string. The callback return then supplies evidence for Output. TypeScript’s type inference documentation calls the flow from a value’s location into the expression “contextual typing.”
Annotations can replace part of that inference:
const labels = map(
[1, 2, 3],
(value: number): `${number}px` => `${value}px`,
);
This is useful when a callback’s body alone would infer a broader return type than the API contract needs. An assertion inside the callback would also influence the result, but an explicit return annotation checks the whole callback against the stated promise.
A constraint is a requirement, not the chosen result
An extends clause tells the generic body what members it may use and rejects candidates outside the allowed set.
function retain<Type extends { id: string }>(value: Type): Type {
console.log(value.id);
return value;
}
const user = retain({ id: 'u-1', name: 'Ada' });
// { id: string; name: string }
Type remains the full object type. It does not become { id: string }. The generics handbook uses constraints for the same reason: the implementation may rely on required members without discarding caller information.
A constraint can still widen literals through contextual typing. If preserving exact literals matters, a const type parameter may be the right API:
function route<const Path extends string>(path: Path): Path {
return path;
}
const account = route('/account');
// '/account'
Const type parameters arrived in TypeScript 5.0. They request const-like inference for expressions passed at the call site. They do not freeze runtime values and do not recover literal detail already lost in a variable.
Conflicting candidates do not promise a union
A single type parameter can appear in several argument positions:
function pair<Type>(left: Type, right: Type): [Type, Type] {
return [left, right];
}
pair(1, 2); // [number, number]
pair(1, 'two'); // error
Do not assume TypeScript will always combine disagreements into a union. Candidate combination depends on inference position and context. If a mixed pair is part of the contract, say so:
const mixed = pair<number | string>(1, 'two');
Another option is to model separate positions with separate parameters:
function tuple<Left, Right>(left: Left, right: Right): [Left, Right] {
return [left, right];
}
That version preserves the fact that the two elements may differ instead of inventing a shared element type.
NoInfer stops one position from choosing
TypeScript 5.4 added the intrinsic NoInfer<Type> utility. It keeps a position checked against the final type while preventing that position from contributing inference candidates.
function chooseTheme<Name extends string>(
choices: readonly Name[],
initial: NoInfer<Name>,
): Name {
return initial;
}
chooseTheme(['light', 'dark'] as const, 'light'); // valid
chooseTheme(['light', 'dark'] as const, 'blue'); // error
Without NoInfer, 'blue' can help infer a wider Name, defeating the intended rule that choices defines the allowed set. The official NoInfer documentation notes that it otherwise behaves like the wrapped type.
Use it only after deciding which argument owns the choice. If neither argument clearly owns it, separate type parameters or a different data shape may describe the relationship better.
Defaults are fallbacks, not preferred candidates
A type parameter default makes that parameter optional when callers write explicit type arguments, and it provides a result when inference finds no candidate.
function empty<Type = string>(): Type[] {
return [];
}
const names = empty(); // string[]
const ids = empty<number>(); // number[]
If a value argument supplies a candidate, that candidate wins over the default:
function wrap<Type = string>(value: Type): Type[] {
return [value];
}
const values = wrap(42); // number[]
The generic parameter default rules also require a default to satisfy its constraint. Required type parameters cannot follow optional ones.
Explicit type arguments are an API pressure gauge
Callers can bypass inference with angle-bracket arguments:
const hex = map<number, string>([10, 15], value => value.toString(16));
TypeScript does not support leaving a hole such as map<number, ?>. Once a caller starts supplying type arguments, every remaining required parameter needs an argument. Defaults on trailing parameters can reduce that burden.
An occasional explicit argument makes intent clear. Requiring one at most calls often means the inference sites do not match the relationship the API is trying to express. First decide which values should choose each type parameter. Then place that parameter where the evidence naturally enters, constrain it only as tightly as the implementation needs, and use a default only for calls that truly provide no evidence.