Lesson 37·M4 Type System Fluency
Generic Constraints
OpenLearning Objectives
By the end of this lesson you should be able to:
- Explain what
T extends SomeTypemeans in one sentence - Write a constraint that lets the body call a method on
Tsafely - Write
K extends keyof Tfor key-safe lookups andT extends stringfor literal types - Diagnose the classic "constraint unsatisfied" error and fix it
- Say why a constrained generic beats
any— the interview follow-up
One-line definition
A constraint is a promise the caller makes: T extends SomeType means "T may be any type, as long as it is assignable to SomeType" — and inside the body, T can assume everything SomeType provides.
The body gets capabilities; the caller keeps freedom. That trade is the whole feature.
Mental model
A bare generic is a blank cheque — useful, but the body can't spend it:
<T> → T could be anything → body can do NOTHING with it
<T extends Length>→ T is "something with .length" → body may use .length
The constraint is a type-level promise: the compiler proves every call site satisfies it, and in return the body is allowed to rely on it. Like a function parameter defaulting to a known interface, but enforced at compile time.
Visual flow
function logLength<T extends { length: number }>(value: T): T {
console.log(value.length); // ✅ allowed — the constraint guarantees it
return value;
}
logLength('hello'); → T = string ✅ string has .length
logLength([1, 2, 3]); → T = number[] ✅ arrays have .length
logLength(42); → ❌ number has no .length — rejected
The call site that fails is the whole point: the constraint turns a runtime undefined bug into a compile-time error.
How it works
T extends SomeType — the general constraint
function logLength<T extends { length: number }>(value: T): T {
console.log(value.length);
return value;
}
const s = logLength('hello'); // s: string — T preserved, not widened
const a = logLength([1, 2, 3]); // a: number[]
// logLength(42); // ❌ 42 has no .length
console.log(s, a);
Output:
5 3 hello [ 1, 2, 3 ]
Without the constraint, value.length is a compile error — a bare T offers no properties. With it, the body is safe and the return still tracks the actual type: logLength('hello') returns string, not { length: number }. Constraining does not flatten.
K extends keyof T — constrain a key to a type's keys
function pick<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { id: 1, name: 'Ali', email: 'ali@example.com' };
console.log(pick(user, 'id')); // number
console.log(pick(user, 'name')); // string
// pick(user, 'age'); // ❌ 'age' is not a key of user
Output:
1 Ali
The two-parameter form is the workhorse: T is the object, K is constrained to be one of its keys, and indexed access (T[K], from Lesson 35) returns exactly that property's type. The compiler checks the key at every call site.
T extends string — the literal-friendly special case
type Route = 'home' | 'about' | 'contact';
function routeTo<T extends Route>(route: T): string {
return `/${route}`;
}
console.log(routeTo('home')); // /home
console.log(routeTo('about')); // /about
// routeTo('help'); // ❌ 'help' is not a Route
Output:
/home
/about
Why not just take a Route parameter? Because the return of routeTo('home') here is a plain string — but if the return depends on the specific literal (a mapped type over routes, Lesson 41 territory), <T extends Route> preserves which route you passed. The constraint says "any route is fine, but you must be one of them".
Real project usage
// a generic that needs real capabilities — the everyday case
function total<T extends { price: number }>(items: T[]): number {
return items.reduce((sum, item) => sum + item.price, 0);
}
const cart = [
{ name: 'shirt', price: 25 },
{ name: 'hat', price: 10 },
];
console.log(total(cart));
Output:
35
// constraining keys — a safe "get or default" helper
function getOrDefault<T, K extends keyof T>(obj: T, key: K, fallback: T[K]): T[K] {
return obj[key] ?? fallback;
}
const settings = { theme: 'dark', retries: 3 };
console.log(getOrDefault(settings, 'theme', 'light'));
console.log(getOrDefault(settings, 'retries', 0));
Output:
dark
3
The capabilities a constraint grants — .price, .length, "is a valid key", "is one of these literals" — are exactly what the body needs to do real work instead of just passing T through.
Interview explanation
A constraint is
T extends SomeType— it says T can be any type as long as it's assignable toSomeType, and then the body can rely on whateverSomeTypeprovides.K extends keyof Tconstrains a key parameter to a type's keys, andT extends stringkeeps literal types. The caller gives up freedom; the body gains capabilities — and the compiler checks every call site.
Senior-level insights
The junior answer knows the syntax. The senior answer knows what constraints are for:
- A constraint is a capability grant, not a restriction for its own sake. The question is always "what does the body need to do with
T?" — that need becomes the constraint.logLengthneeds.length, soT extends { length: number }. - Constraints prevent the "bare
Tcan do nothing" wall. This is the number-one stall point in live coding: someone writes<T>, tries to call a method, and hits an error. The fix is neverany— it's adding the constraint that supplies the method. - Constraining does not widen.
logLength('hello')returnsstring, not the constraint type. A senior preserves the connection between input and output (Lesson 36) while narrowing what's allowed in. keyofconstraints (from Lesson 35) are how you keep the return type exact —T[K]only works becauseK extends keyof Tproves the key exists.
Common mistakes
Mistake 1 — "fixing" the constraint error with any. The error exists because the body needs a capability. any grants it silently and moves the risk to callers:
function badLength<T>(value: T): number {
return (value as any).length; // ❌ compiles, but nothing is checked
}
badLength(42); // no error — returns undefined at runtime
(compiles — `any` hid the missing .length; 42 has no length at runtime)
The constraint version rejects badLength(42) at compile time. That's the entire value.
Mistake 2 — constraining when you don't need to. function id<T extends object>(x: T): T adds nothing the body uses. Constraints are for capabilities, not decoration.
Mistake 3 — over-constraining with a concrete type. <T extends string> where the body only needs any string is fine for literals, but if you genuinely accept any string, just type the parameter string — don't drag a type parameter in.
Best practices
✅ Constrain to the smallest interface the body needs: T extends { length: number }
✅ Use K extends keyof T for key-safe lookups and updates
✅ Use T extends string (or a literal union) to preserve literal types
✅ Keep the return type as T when you want to preserve the exact input type
❌ Don't reach for any/as any to silence a constraint error — fix the constraint
❌ Don't add constraints the body never uses
Interview questions
Q1. What is a generic constraint?
It's
T extends SomeType— a promise thatTis assignable toSomeType, which lets the body useSomeType's members. The caller gives up some freedom, the body gains capabilities, and the compiler checks every call site.function logLength<T extends { length: number }>(v: T)can readv.length;logLength(42)is a compile error.
Q2. What is K extends keyof T?
It constrains a type parameter to be one of another type's keys.
pick<T, K extends keyof T>(obj: T, key: K): T[K]—Kcan only be a real key ofT, and the return is that property's exact type via indexed access. It's the pattern behind safe lookup helpers, and it combines Lesson 35's operators with Lesson 36's generics.
Q3. When would you use T extends string?
When I want to accept only certain literal strings —
T extends 'a' | 'b'— or to preserve which literal was passed, which matters when the return type depends on it. If I don't care about literals, a plainstringparameter is simpler. The constraint is about keeping specificity, not about restricting for fun.
Senior follow-up: Why is a constrained generic better than any?
anydisables checking — it grants every capability silently, so the error moves to runtime or to the caller. A constraint grants exactly the capabilities the body needs, and the compiler proves every call site satisfies them before the code runs.logLength<any>would let42through;T extends { length: number }rejects it at compile time. Same body, opposite safety.
Follow-up questions
Can I constrain to a union?
Yes — and that's the classic trick for literal types.
T extends 'red' | 'green' | 'blue'meansTis one of those three literals, and code that checksTcan narrow on each. This is how you type-safe a finite set of options while keeping which one was chosen.
What's the difference between a constraint and a conditional type?
A constraint is a check that happens at the call site — a filter on what
Tmay be. A conditional type (T extends U ? X : Y, Lesson 40) computes a different type fromT— it's a function that runs on types. Sameextendskeyword, different job: one gates, the other transforms.
Do constraints survive to the emitted JavaScript?
No.
extendsclauses, like all type syntax, are erased at compile time. The emitted function is plain JavaScript with no constraint in sight — the checks all happened before the code ran.
Comparison table
| Constraint | Meaning | Typical body use | Caller gets |
|---|---|---|---|
<T> | anything | pass-through only | full freedom |
<T extends { length: number }> | has .length | read .length | any length-bearing type |
<T, K extends keyof T> | K is a key of T | obj[key] returns T[K] | only valid keys |
<T extends string> | T is a string literal | preserve/return the literal | only the allowed literals |
Code example
A findById over a constrained shape — generic, safe, and typed to the element:
type Entity = { id: number };
function findById<T extends Entity>(items: T[], id: number): T | undefined {
return items.find((item) => item.id === id);
}
const users = [
{ id: 1, name: 'Ali', role: 'admin' },
{ id: 2, name: 'Omar', role: 'editor' },
];
const found = findById(users, 1);
console.log(found?.name, found?.role);
Output:
Ali admin
T extends Entity guarantees .id exists, and findById still returns the full user type — { id, name, role } — not a flattened Entity. Constraint and specificity both preserved.
Performance notes
Constraints are erased at runtime — zero cost, compile-time only. The only performance note is negative: a poorly-constrained generic that compiles to any doesn't cost runtime either, but it moves bugs to runtime, which costs real money in production. A constraint that catches a bad call before deploy is the cheapest safety you can buy. Compile-time cost scales with how many distinct T instantiations the compiler resolves — negligible for normal code.
Debugging scenarios
"Type 'number' does not satisfy the constraint '…'." The argument isn't assignable to what the constraint requires. Either the caller is genuinely wrong (fix the call site), or the constraint is too tight (widen it to what the body actually needs).
"Property 'length' does not exist on type 'T'." A bare T with no constraint — the body is trying to use a capability nobody granted. Add the constraint that supplies .length (or whatever the property is). This is the most common live-coding stall.
"'K' could be instantiated with an arbitrary type which could be unrelated to 'keyof T'." TypeScript can't prove a key constraint is satisfied — usually when the generic flows through another layer. Constrain the outer layer too, or restructure so K is checked where it enters.
"Argument of type 'string' is not assignable to parameter of type '"a" | "b"'." A T extends 'a' | 'b' rejected a broad string. That's the constraint working — pass a literal, or widen the union if it should accept more.
Quick revision notes
T extends SomeType— T must be assignable toSomeType; body gains its membersK extends keyof T— K must be a real key;T[K]is the exact property typeT extends string/ a literal union — keep literals, reject out-of-set strings- Constraints don't widen:
logLength('hi')still returnsstring - Never "fix" a constraint error with
any— the constraint is the point - Constrain to the smallest capability the body needs
- Erased at runtime — zero cost
extendsreappears in Lesson 40 as the conditional-type operator
Cheat sheet
// general capability constraint
function logLength<T extends { length: number }>(v: T): T { … }
// key constraint — combines Lesson 35 + Lesson 36
function pick<T, K extends keyof T>(obj: T, key: K): T[K] { … }
// literal-union constraint
type Route = 'home' | 'about';
function go<T extends Route>(r: T): string { … }
// constraint + multi-param
function keyBy<T, K extends keyof T>(items: T[], key: K): Record<T[K], T> { … }
(no runtime output — these are type-level definitions)Key takeaways
Recap
T extends SomeType= "any type assignable toSomeType", granting the body its members- The number-one live-coding stall is a bare
Twith nothing to use — the fix is a constraint, notany K extends keyof Tkeeps lookups key-safe and returns the exact property typeT extends string/ a literal union preserves literals and rejects out-of-set values- Constraining never widens the return —
Tkeeps the caller's actual type anygrants capabilities silently; constraints grant them with proof- Erased at runtime — zero cost
- Next stop: Lesson 38 applies everything — discriminated unions for loading/error/success states
Check your understanding
Answer each one out loud. Then rate yourself honestly.
0/6What does T extends { length: number } promise, and what does the body gain?
Write pick<T, K extends keyof T> and say what it returns.
Why use T extends string instead of a plain string parameter?
What's the correct fix for "Property 'length' does not exist on type 'T'"? Why is any the wrong one?
Does a constraint widen or preserve the input type? Prove it.
What's the difference between a constraint and a conditional type?
What's Next
Lesson 38 — Discriminated Unions. The single most useful pattern in application TypeScript — loading/error/success states, exhaustive narrowing with never, and why every real codebase is built on it.