Defaults and inference
Default type parameters fill the tail when inference finds nothing, but inference always wins where it can. `NoInfer<T>` (TS 5.4) blocks inference at a position so one argument can decide T for the rest.
A createStore<State, Action = AnyAction> signature ships happily. Then someone calls createStore(reducer) and gets a confusing error about Action, while another calls createStore<MyState>(reducer) and the action type is silently AnyAction instead of being inferred from the reducer. The default did its job — and that is exactly the problem: it papered over an inference that should have happened.
What a default does
Why add a default at all? Because sometimes there is genuinely nothing in the call to infer from — and forcing the user to write a type argument every time makes an API painful. A default type parameter supplies a fallback type, used when the checker can neither infer nor be told the type argument:
function emptyArray<T = string>(): T[] {
return [];
}
const a = emptyArray();
// ^? const a: string[] — default kicked in (nothing to infer)
const b = emptyArray<number>();
// ^? const b: number[] — explicit argument overrides the defaultDefaults look like value-parameter defaults but live in type space. They are most valuable on types/classes where there is genuinely nothing to infer from (more on classes next lesson).
Inference beats the default
The first rule: inference always wins. A default only fills in when inference has no information for that parameter.
function box<T = string>(value: T): { value: T } {
return { value };
}
const x = box(42);
// ^? const x: { value: number } — inferred from the argument, NOT string
const y = box();
// Error: Expected 1 arguments, but got 0. (value is required)T is inferred as number from the argument; the = string default is irrelevant because inference succeeded. A default never overrides a usable inference — it only backstops the absence of one.
Partial explicit arguments: all-or-nothing, then defaults fill the tail
Explicit type arguments are positional and all-or-nothing for the leading parameters: you cannot supply the second while inferring the first. But defaults let you stop early — once a parameter has a default, you may omit it and everything after it.
function make<A, B = A>(a: A, b: B): [A, B] {
return [a, b];
}
const m = make("x", 9);
// ^? const m: [string, number] — both inferred
const n = make<string>("x", 9);
// Error: Expected 2 type arguments, but got 1? No — this is allowed:
// B has a default (= A), so a single explicit argument is legal.
// ^? const n: [string, number] — A explicit, B still inferred to numberThe rule: you may omit trailing type arguments that have defaults, but you cannot skip a leading one to specify a later one. Defaults fill the tail; inference can still refine a defaulted parameter if a value supplies it.
Ergonomic generics: constraint + default together
The most usable library generics pair a constraint (the requirement) with a default (the common case):
type EventMap = Record<string, unknown>;
class Emitter<Events extends EventMap = Record<string, never>> {
emit<K extends keyof Events>(key: K, payload: Events[K]): void {
// ...
}
}
// Common case: no events declared — default makes emit(...) reject any key.
const bare = new Emitter();
// Rich case: declare the map and get fully typed emit.
const typed = new Emitter<{ login: { userId: string } }>();
typed.emit("login", { userId: "u1" }); // ok
// @ts-expect-error "logout" is not a key of the declared events
typed.emit("logout", { userId: "u1" });extends EventMap requires whatever you pass to be an event map; = Record<string, never> makes the zero-config case behave sensibly.
NoInfer: blocking inference at a position (TS 5.4)
Sometimes you want one argument to decide T and the others to merely be checked against it — not to participate in inferring T. Before TS 5.4 this leaked:
// One default, value must be one of the allowed strings.
function createState<T>(initial: T, allowed: T[]): T {
return initial;
}
// BUG (pre-NoInfer): T is inferred from BOTH arguments, so T widens.
const s = createState("seattle", ["seattle", "denver"]);
// ^? const s: string — fine here, but the typo guard is gone:
createState("seatle", ["seattle", "denver"]); // no error! T = stringBecause allowed: T[] also feeds inference, a typo in initial just widens T to string and the “must be in the list” guarantee evaporates. NoInfer<T> tells the checker to not infer T from that position:
function createState<T>(initial: T, allowed: NoInfer<T>[]): T {
return initial;
}
const ok = createState("seattle", ["seattle", "denver"]);
// ^? const ok: "seattle" — T fixed by `initial` alone
createState("seatle", ["seattle", "denver"]);
// T is pinned to the literal "seatle" by `initial`; the array must now be
// "seatle"[], so each real element is rejected:
// Error: Type '"seattle"' is not assignable to type '"seatle"'.Now T is inferred solely from initial as the literal "seatle", and allowed is checked against T rather than feeding it — so the mismatched array elements flag the typo. (Fix the typo to "seattle" and T becomes "seattle", the array matches, and it compiles.)
The numbers behind a masked inference
The cost of a default that papers over inference is not abstract — it is counted in call sites that have to compensate. Picture a createStore<State, Action = AnyAction> exported from a shared package and called in, say, 70 places across an app. The Action = AnyAction default means none of those calls error when the action type fails to flow from the reducer; instead each one silently resolves Action to AnyAction, and the breakage surfaces downstream as any-typed actions in reducers and middleware. When the team finally tightens the type (drops the default, or fixes inference), every call site that was leaning on the fallback now needs attention — commonly dozens of explicit createStore<MyState, MyAction>(...) annotations added by hand, the exact work the default was hiding. That is the migration tax of a convenient default: paid late, all at once, across the call graph rather than at the one signature.
NoInfer<T> arrived in TypeScript 5.4 (March 2024); before that, the same effect required hand-rolled tricks like an intersection with a phantom type or a second type parameter, each adding instantiations and obscuring the signature. On 5.4+ it is a single wrapper with no runtime cost and no extra type parameter — the cheapest available fix for “one argument should decide T, the rest are checked.” If your tsconfig targets an older TypeScript, you simply don’t have it, which is itself a reason to keep the toolchain current.
▸Why this works
Why is a “helpful” default dangerous? A default makes a parameter optional in inference’s eyes. If you put a default on a parameter that should have been inferred from a value — like Action = AnyAction on a store whose actions are fully derivable from its reducer — then a call that fails to infer doesn’t error; it silently resolves to the default. The bug surfaces far away, as AnyAction flowing where a precise union was expected. Reach for a default only when there is genuinely nothing to infer from (often a class with no constructor argument carrying the type), not as a cover for an inference you couldn’t get to work.
The tradeoff is ergonomics now versus a silent failure mode later. A default genuinely smooths the zero-config case — new Emitter() with no events is pleasant to write. But that same convenience converts a would-be compile error into a quiet widening to the fallback type, and the senior question is always “if inference here failed, do I want a loud error or a silent AnyAction?” When you do want the loud error, omit the default and let the missing inference surface; when the fallback truly is the right answer for the no-information case, keep it but reach for NoInfer on any parameter that should check against T rather than feed it. Defaults for genuine no-information slots; never as a substitute for inference you couldn’t make work.
For `function box<T = string>(value: T): { value: T }`, what is the type of `box(42)`?
In `createState<T>(initial: T, allowed: NoInfer<T>[])`, why does wrapping the second parameter in `NoInfer` matter?
Order the precedence the checker uses to resolve a single type parameter T:
- 1 If the caller wrote an explicit type argument for T, use it
- 2 Otherwise, try to infer T from the call arguments that mention it
- 3 Apply the constraint as an upper bound to validate the chosen T
- 4 Only if no explicit argument and no inference exist, fall back to the default
- 5 If none of the above produce a type, an unconstrained T resolves to unknown
Fill in the blank: between inference and a default, _______ always wins; the default is only a fallback for when there is nothing to infer.
- 01State the precedence between explicit type arguments, inference, and defaults, and explain the all-or-nothing rule for explicit arguments.
- 02Describe the bug NoInfer fixes, using the `createState(initial, allowed)` example.
Default type parameters are fallbacks, not overrides: the checker prefers an explicit type argument, then inference, and only reaches the default when both are absent — so a default placed on a parameter that should have been inferred quietly masks the failure. Defaults fill trailing parameters and the all-or-nothing rule governs leading explicit arguments. Pairing a constraint with a default gives ergonomic library generics, and NoInfer<T> (TS 5.4) lets you exclude a position from inference so one argument decides T while the rest are merely checked. Next we move generics into classes and interfaces, where instance-scoped type parameters and the static-side restriction introduce a new family of pitfalls. Now when you see a generic with a default and something feels off downstream, check whether inference was supposed to fire — a silent AnyAction is often a masked inference, not a deliberate choice.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.