open atlas
↑ Back to track
TypeScript type system, deep TS · 08 · 04

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.

TS Senior ◷ 15 min
Level
FoundationsJuniorMiddleSenior

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 args

Same 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 precisestring, 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 | undefined

The 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 defaulted

NoInfer: 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 union

Each 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.

Quiz

A user must call your API as `parse<MySchema>(input)`, restating the type the value already implies. What is the design problem?

Order the steps

Order the design steps to make get(obj, key) infer beautifully and return a precise type:

  1. 1 Put the type parameters in parameter positions so each has an inference site (obj: T, key: K)
  2. 2 Constrain where needed for ergonomics and safety (K extends keyof T)
  3. 3 Return the precise inferred type, not the constraint (T[K], not unknown)
  4. 4 Use NoInfer on any second occurrence that should be checked, not drive inference
  5. 5 Verify at a real call site that no type arguments are needed and the result type is exact
Recall before you leave
  1. 01
    What is an 'inference site' and why is a caller forced to write `<T>` a design failure?
  2. 02
    Why is 'returning the constraint instead of the inferred type' the most common way a library loses types, and what is the fix?
  3. 03
    What does NoInfer<T> do, when do you need it, and how do builders accumulate types?
Recap

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.

recallapplystretch0 of 6 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.