open atlas
↑ Back to track
TypeScript type system, deep TS · 03 · 02

Generic constraints

A constraint `T extends C` is a two-way contract — it restricts which arguments callers may pass, and in exchange lets the body touch the members C guarantees. The classic trap is returning C instead of T and erasing specificity.

TS Middle ◷ 14 min
Level
FoundationsJuniorMiddleSenior

You write function longest<T>(a: T, b: T) { return a.length > b.length ? a : b } and the editor lights up red: Property 'length' does not exist on type 'T'. The fix everyone reaches for is T extends { length: number }. But few stop to ask what that line bought them — it did two things at once, and missing either one is how generics quietly lose their type information.

The error and the contract

Inside a generic body, T is opaque — the checker knows nothing about it, so a.length is rejected. A constraint tells the checker a lower bound: every T will be at least this shape.

function longest<T extends { length: number }>(a: T, b: T): T {
  return a.length > b.length ? a : b;
}

const s = longest("alpha", "beta");
//    ^? const s: string
const arr = longest([1, 2], [3, 4, 5]);
//    ^? const arr: number[]
longest(10, 20);
// Error: Argument of type 'number' is not assignable to
//        parameter of type '{ length: number }'.

The constraint did two jobs simultaneously:

  1. It restricts callers. Only arguments assignable to { length: number } are accepted — number is rejected at the call site.
  2. It unlocks members inside. Because every T is now guaranteed to have length: number, the body may read a.length.

That is the contract: callers give up freedom; the body gains capability. Lose sight of either side and you either over-restrict the API or can’t use the value you constrained.

Constraining to a key: keyof

The canonical constrained generic is reading a property by key. We need the key to be one of the object’s own keys, not any string:

function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const user = { id: 1, name: "Ada", admin: true };

const name = getProperty(user, "name");
//    ^? const name: string
const admin = getProperty(user, "admin");
//    ^? const admin: boolean
getProperty(user, "email");
// Error: Argument of type '"email"' is not assignable to
//        parameter of type '"id" | "name" | "admin"'.

K extends keyof T constrains K to the union of T’s keys. The checker infers T from obj, then keyof T becomes "id" | "name" | "admin", and key must be one of those literals. The return type T[K] is the indexed access — the exact type of that property. Pass a key that doesn’t exist and the error names the legal set.

Constraining to a shape or a union

A constraint can be any type, not just an object literal:

// To a shape with an id — useful for "entity" helpers.
function withId<T extends { id: string }>(items: T[]): Map<string, T> {
  const m = new Map<string, T>();
  for (const it of items) m.set(it.id, it);
  return m;
}

// To a union — restrict to a known set of tags.
type Tag = "info" | "warn" | "error";
function level<T extends Tag>(t: T): T {
  return t;
}
const l = level("warn");
//    ^? const l: "warn"   — narrow literal preserved

The specificity trap: returning C instead of T

Here is the bug that bites in code review. You constrain T extends C, then carelessly type the return as C. The function still compiles, but it erases the caller’s specific type back down to the constraint.

// BUG: returns the constraint, not the type variable.
function identityBad<T extends { id: string }>(x: T): { id: string } {
  return x;
}
const r1 = identityBad({ id: "u1", role: "admin" as const });
//    ^? const r1: { id: string }   — lost `role`! widened to the constraint

// FIX: return T, preserving everything the caller passed.
function identityGood<T extends { id: string }>(x: T): T {
  return x;
}
const r2 = identityGood({ id: "u1", role: "admin" as const });
//    ^? const r2: { id: string; role: "admin" }   — full type kept

When you see T in a return type, ask yourself: is this the type variable or the constraint? Returning the constraint throws the caller’s precise type away, and you’d have been better off with a plain parameter typed { id: string }. The generic became decorative.

The cost of tightening a constraint

A constraint is also an API decision with a measurable blast radius. Tighten T extends { id: string } to T extends { id: string; tenantId: string } and every caller that used to pass a bare { id } now errors — on a service of a few hundred call sites, that is the difference between a green build and a PR touching forty files. The error is loud and at the right place (the call site), which is the point of the constraint, but the senior reflex is to ask whether the requirement belongs in the signature at all or whether a narrower internal helper would localize the churn. Loosen the constraint instead (drop a required member) and the opposite happens silently: callers that relied on the member keep compiling, and the body that reads it now needs a fallback you didn’t write.

There is a checker cost too. A constraint like K extends keyof T makes T[K] an indexed access the checker resolves per call site; over a wide object — a config type with, say, 120 keys — keyof T is a 120-member union and each getProperty call instantiates and relates against it. One such helper is nothing; a layer of them stacked over large mapped types is where you watch the Instantiations count in --extendedDiagnostics jump and tsc go from snappy to a noticeable pause on every keystroke in the editor.

Why this works

Constraint vs default is a common confusion: <T extends string> and <T = string> look similar but do opposite things. extends is an upper bound — a requirement the argument must meet, enforced at the call site. = is a fallback — a value used only when inference finds nothing, never enforced. You can have both: <T extends string = "id"> means “must be a string subtype, and if you don’t supply or infer one, use the literal "id".” Defaults are the next lesson; here, just don’t read extends as “defaults to.”

The tradeoff in constraint tightness: a looser constraint accepts more callers but unlocks fewer members inside, pushing the body toward casts or extra runtime checks; a tighter constraint unlocks more but rejects callers and ripples into every call site on the next tightening. The senior calibration is to constrain to exactly the members the body actually touches — no looser (or you can’t write the body), no tighter (or you’ve coupled callers to requirements they don’t need). When a constraint starts naming members the body never reads, that is the signal it has drifted into over-restriction.

Quiz

In `getProperty<T, K extends keyof T>(obj: T, key: K): T[K]`, what does the constraint `K extends keyof T` accomplish?

Quiz

Why is `function f<T extends { id: string }>(x: T): { id: string }` a mistake?

Order the steps

Order how the checker types `getProperty(user, 'name')` where `user: { id: number; name: string }`:

  1. 1 Infer T from `user` as { id: number; name: string }
  2. 2 Compute the constraint `keyof T` as the union 'id' | 'name'
  3. 3 Check the argument 'name' is assignable to that union, then infer K = 'name'
  4. 4 Resolve the indexed access T[K] = T['name']
  5. 5 Report the result type as string
Complete the analogy

Fill in the blank: a generic constraint is a _______ contract — it costs callers some freedom and pays the body back with guaranteed members.

Recall before you leave
  1. 01
    Explain the two effects of writing `T extends { length: number }`, and what error you get without the constraint.
  2. 02
    What is the specificity trap with constraints, and how do you avoid it in `getProperty`-style helpers?
Recap

A constraint T extends C is the contract at the heart of useful generics: it restricts which arguments callers may pass (anything assignable to C) and, in exchange, lets the body use the members C guarantees. Constraining K extends keyof T is the canonical pattern for safe property access, returning the precise indexed type T[K]. The recurring code-review bug is returning the constraint instead of T, which erases the caller’s specific type and renders the generic decorative — and don’t confuse extends (a requirement) with = (a fallback). Next we add defaults and study exactly how they interact with inference, including the NoInfer utility for blocking inference at a position. Now when you review a generic signature, check the return type first: if it says C where you expected T, the type information is already gone.

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.

Apply this

Put this lesson to work on a real build.

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

Trademarks belong to their respective owners. Editorial reference only.