Library generics: APIs that infer beautifully (and the ones that don't)
Designing generic library APIs that infer well: order type params so inference flows, use constraints and defaults, block inference with `NoInfer`, and return precise types not the constraint. The failure mode: an API that forces callers to write type args.
Two typed event-emitter libraries do the same job. With the first you write emitter.emit("login", { userId: 1 }) and the argument is checked against the "login" event’s payload — no type arguments, full autocomplete. With the second you must write emitter.emit<"login", { userId: number }>("login", { userId: 1 }), restating what the compiler should have known, and one typo desyncs the two. Same feature, opposite ergonomics. The difference is entirely in how the generic signatures were designed to drive inference. Library authors don’t just write types — they design where inference happens.
The goal: the caller writes values, the compiler writes types
The north star of generic API design: a caller should pass ordinary values and get back precise types without writing a single type argument. Every <T> the caller is forced to type is a design failure — it’s a type the API should have inferred. Inference happens at inference sites: positions in the signature where a type parameter appears in a parameter’s type, letting the compiler read it off the argument.
// forces the caller to specify K — bad
function getBad<T, K extends keyof T>(obj: T, key: K): T[K] { return obj[key]; }
getBad<{ a: number }, "a">({ a: 1 }, "a"); // ugh
// infers both from the arguments — good
function get<T, K extends keyof T>(obj: T, key: K): T[K] { return obj[key]; }
get({ a: 1, b: "x" }, "b"); // ^? string — T and K both inferred, no type argsSame signature, but the second call site supplies values at both inference sites (obj fixes T, key fixes K), so nothing must be written by hand. The return T[K] is then precise — string, not the constraint keyof-something.
Return the precise type, never the constraint
A classic leak: constraining a parameter and then returning the constraint instead of the inferred type. The caller loses all the specificity they passed in.
// returns the constraint — caller gets the wide type back
function firstWide<T extends unknown[]>(arr: T): unknown { return arr[0]; }
const a = firstWide([1, 2, 3]); // ^? unknown — specificity thrown away
// returns the precise element type via inference
function first<T>(arr: readonly T[]): T | undefined { return arr[0]; }
const b = first([1, 2, 3]); // ^? number | undefinedThe fix is to make T the element type (so it’s inferred precisely) rather than constraining T to be the whole array and returning a widened piece. Returning the constraint is the single most common way a library “loses types.”
Constraints + defaults for ergonomics
What if you want a generic that infers its primary type parameter from the argument but still lets advanced callers override a secondary one? That is exactly the gap constraints and defaults fill.
Constraints (T extends ...) keep callers honest and unlock member access inside the body; defaults (T = ...) make a parameter optional when inference can’t supply it (e.g. a config object never passed). Used together they let one signature serve the common case with zero ceremony and the advanced case with an explicit override:
function createStore<State, Action = { type: string }>(initial: State) {
return { state: initial } as { state: State; dispatch: (a: Action) => void };
}
const s = createStore({ count: 0 }); // State inferred {count:number}, Action defaultedNoInfer: block an unwanted inference site
Sometimes a type parameter appears in two positions and you only want one of them to drive inference. Pre-5.4 you needed an intersection hack; TS 5.4 added the built-in NoInfer<T>:
// without NoInfer: TS widens C from BOTH the array and the default,
// so a typo'd default still "works" by widening the union
function pick<C extends string>(choices: C[], fallback: C): C { /* ... */ return fallback; }
pick(["a", "b"], "c"); // C inferred as "a" | "b" | "c" — the bug "c" is accepted!
// with NoInfer: only `choices` is an inference site; fallback must match it
function pickSafe<C extends string>(choices: C[], fallback: NoInfer<C>): C { return fallback; }
pickSafe(["a", "b"], "c"); // Error: "c" is not assignable to "a" | "b"NoInfer<C> tells the compiler “don’t read C off this argument” — so C is fixed by choices alone, and fallback is checked against it rather than widening it. This is the precise tool for “this parameter must conform to a type already determined elsewhere.”
▸Why this works
Why did the un-NoInfer’d version silently accept the bug? Both choices and fallback were inference sites for C, so TypeScript found the common type that satisfies both — the union "a" | "b" | "c". Inference’s job is to find a type that fits all sites, and widening to a union does fit. NoInfer removes a site so the remaining ones pin the type down, turning a silent widening into a real error.
Builders that accumulate types
Fluent builders (the pattern behind zod, the tRPC router, query builders) thread an evolving type through each chained call by returning a new generic instance:
class Query<Cols extends string = never> {
select<C extends string>(col: C): Query<Cols | C> { return this as any; }
build(): Cols[] { return [] as Cols[]; }
}
const cols = new Query().select("id").select("name").build();
// ^? ("id" | "name")[] — each .select widened the accumulated Cols unionEach select returns Query<Cols | C>, so the type grows along the chain and build() reports exactly the columns selected. This is how a query builder knows its result row shape, and how the tRPC router (lesson 3) accumulates its procedure map — each .procedure(...) call adds to the router’s type.
A user must call your API as `parse<MySchema>(input)`, restating the type the value already implies. What is the design problem?
Order the design steps to make get(obj, key) infer beautifully and return a precise type:
- 1 Put the type parameters in parameter positions so each has an inference site (obj: T, key: K)
- 2 Constrain where needed for ergonomics and safety (K extends keyof T)
- 3 Return the precise inferred type, not the constraint (T[K], not unknown)
- 4 Use NoInfer on any second occurrence that should be checked, not drive inference
- 5 Verify at a real call site that no type arguments are needed and the result type is exact
- 01What is an 'inference site' and why is a caller forced to write `<T>` a design failure?
- 02Why is 'returning the constraint instead of the inferred type' the most common way a library loses types, and what is the fix?
- 03What does NoInfer<T> do, when do you need it, and how do builders accumulate types?
You can now design a generic API so the caller writes values and the compiler writes precise types: place type parameters at inference sites, return the inferred type rather than the constraint, use constraints and defaults for ergonomics, block stray inference with NoInfer, and accumulate types through builder chains. These are the tools library authors use to make get, zod, and tRPC feel effortless. The flip side is everything that goes wrong in real codebases — the casts, the any leaks, the structural surprises, the over-clever signatures that emit unreadable errors. The final lesson is the senior pitfalls roundup: for each trap, the symptom, why it compiles, and the fix. Now when you write a generic and find yourself telling a caller “just pass <YourType> explicitly”, that’s your signal: you have a missing inference site, and you know how to find it.
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.