Generic constraints
Limit a TypeScript type parameter with extends so you can use its length or properties safely, allow only real keys with keyof, and avoid over-constraining.
- 01Add a constraintWrite
T extends { length: number }so the function may use.length, and read the error when an argument doesn't fit. - 02Allow only real keysUse
K extends keyof Tso a function accepts only property names the object has, and gives back that property's type. - 03Ask for just enoughSpot a constraint that turns away good values or loses a type, and replace it with one that asks only for what the function uses.
Telling TypeScript what T must have
When a shopper signs in, a shop page may find two carts: the one saved on this device and the one saved in their account. It keeps the longer one. A generic longest should work for any two lists. Here is a first try, with a plain type parameter T (the Generic functions lesson covers type parameters):
function longest<T>(a: T, b: T): T { return a.length >= b.length ? a : b;} console.log(longest(["Tea"], ["Tea", "Cake"]));Line 1 says a and b are both T, and longest gives back a T. Line 2 compares their lengths and gives back the longer one. TypeScript marks both .length reads on line 2, on purpose:
Property 'length' does not exist on type 'T'means "Tcould be any type, and not every type has a length". A caller could pass two numbers, and numbers have nolength.
Line 5 passes two carts, so the code prints [ 'Tea', 'Cake' ] when it runs. But TypeScript checks the function for every possible T, not just this call. You need a way to say "T may be any type, as long as it has a length". That is a constraint:
function longest<T extends { length: number }>(a: T, b: T): T { return a.length >= b.length ? a : b;} const savedCart = ["Tea"];const newCart = ["Tea", "Cake"];const cart = longest(savedCart, newCart);// ^?console.log(cart);The only change is on line 1: T extends { length: number }. It says T must be a type with a length that is a number. Now every T has a length, so line 2 is allowed. Lines 5 and 6 make the two carts, and line 7 keeps the longer. The query on line 8 shows const cart: string[]: T is still exactly the type that went in, not just "something with a length". Line 9 prints [ 'Tea', 'Cake' ].
<T>, TypeScript knows nothing about what's inside, so a.length is an error. With T extends { length: number }, every T has a length, so line 2 may read it.Before you board a plane, your cabin bag must fit a metal frame at the gate. Any bag that fits is allowed: a backpack, a small suitcase, a tote. The frame doesn't swap your bag for a standard one. You carry your own bag on board.
- In real life: The frame at the gate says what size a bag must be
- In TypeScript: The constraint
{ length: number }says whatTmust have - In real life: A backpack or a small suitcase: any bag that fits
- In TypeScript: A string, an array, or any object with a
length - In real life: A bag too big for the frame is turned away
- In TypeScript:
longest(10, 20)gets an error: numbers have nolength - In real life: You still carry your own bag on board
- In TypeScript:
longestgives back astring[], not just "something with a length"
A generic constraint, written T extends SomeType, limits which types T may be. Every T must have at least what SomeType has, so inside the function you may use it. Each call still fills in T with the exact type of its arguments.
Constraints with extends
In angle brackets, extends means "must fit". T extends { length: number } lets T be any type that has at least a length that is a number. Extra properties are fine. TypeScript compares shapes, not names (the Structural typing lesson explains this). Press the buttons to pass different arguments on line 5:
function longest<T extends { length: number }>(a: T, b: T): T { return a.length >= b.length ? a : b;} const result = longest(["Tea"], ["Tea", "Cake"]);// ^?console.log(result);string[]No errors[ 'Tea', 'Cake' ]Arrays have a length, so both carts fit the constraint. T is string[], and result keeps that type: you could call result.push("Pie"). It prints the longer cart.
Two carts fit, and result is string[]. Two numbers get Argument of type 'number' is not assignable to parameter of type '{ length: number; }': in plain words, "a number doesn't have the length this function needs". Two objects with a length fit, though they're plain objects. A cart and a number shows that every argument typed T is checked.
length fit. 10 has no length, which is the error on line 5 when you press Two numbers.A constraint can be any type, including one you named with type. Strings have a length too:
type HasLength = { length: number }; function longest<T extends HasLength>(a: T, b: T): T { return a.length >= b.length ? a : b;} const tags = longest(["sale"], ["sale", "new"]);// ^?console.log(tags.join(", "), longest("Tea", "Cake"));Line 1 gives the shape a name, HasLength, and line 3 uses it: T extends HasLength. Line 7 passes two lists of tags, and line 8 shows const tags: string[]. Line 9 prints sale, new, then calls longest("Tea", "Cake"), which prints Cake: four letters beat three.
Why not skip T and type the parameters as { length: number }? Because then nothing connects what goes in with what comes out:
function longest(a: { length: number }, b: { length: number }) { return a.length >= b.length ? a : b;} const cart = longest(["Tea"], ["Tea", "Cake"]);// ^?cart.push("Pie");Line 1 types both parameters as "something with a length". Line 5 passes two carts, but the query on line 6 shows { length: number; }: that's all TypeScript knows about the result. Line 7 tries to add to the cart and gets Property 'push' does not exist on type '{ length: number; }'. The type parameter in the overview kept string[].
A constraint is the least T has, not all it has. So a function that promises to give back a T can't give back a new object that only matches the constraint:
function atLeast<T extends { length: number }>(value: T, minimum: number): T { if (value.length >= minimum) { return value; } return { length: minimum };}Line 1 promises to give back a T. Line 3 gives back value, which is a T, so that's fine. Line 5 gives back a new object { length: minimum }. TypeScript marks it with a message in two lines:
Type '{ length: number; }' is not assignable to type 'T': this object isn't necessarily aT.'T' could be instantiated with a different subtype of constraint '{ length: number; }': "a caller could fill inTwith a more specific type". If they pass a cart,Tisstring[], and they expect a cart back, withpushandmap, not a plain object.
Only real property names with keyof
A settings page reads one setting at a time by its name. Names are text, but not any text: only the names the settings object has. TypeScript has a word for that list. keyof before an object type gives the names of its properties:
type Settings = { theme: string; fontSize: number; showTips: boolean }; type SettingName = keyof Settings;const picked: SettingName = "fontSize";const typo: SettingName = "colour";Line 1 describes the settings. Line 3 makes SettingName the type keyof Settings, which means "theme" | "fontSize" | "showTips": one of those three names, as text. Line 4 picks "fontSize", which is fine. Line 5 tries "colour" and gets an error on purpose: Type '"colour"' is not assignable to type 'keyof Settings', "Settings has no property called colour". (The keyof & typeof lesson covers keyof in depth.)
You can type a parameter with keyof directly. That stops typos, but the result is vague:
type Settings = { theme: string; fontSize: number; showTips: boolean }; const settings: Settings = { theme: "dark", fontSize: 16, showTips: true }; function getSetting(key: keyof Settings) { return settings[key];} const size = getSetting("fontSize");// ^?console.log(size);Line 5 says key is one of the setting names. Line 9 asks for "fontSize", but the query on line 10 shows const size: string | number | boolean. The function doesn't know which name you passed, so the result could be any setting's type. Line 11 prints 16.
Make the key a type parameter instead: K extends keyof Settings. Now K is the exact name you pass, and the return type Settings[K] means "the type of property K in Settings". Type a name on line 9, and watch the error list and the answer after ^?:
type Settings = { theme: string; fontSize: number; showTips: boolean }; const settings: Settings = { theme: "dark", fontSize: 16, showTips: true }; function getSetting<K extends keyof Settings>(key: K): Settings[K] { return settings[key];} const value = getSetting("fontSize");// ^?console.log(value);"fontSize"numberNo errorsprints 16"fontSize" is one of the names in keyof Settings, so K is "fontSize". Settings[K] is the type of that property, number, and the code prints 16.
With "fontSize", value is a number. With "theme", it's a string, and with "showTips", a boolean. "colour" gets Argument of type '"colour"' is not assignable to parameter of type 'keyof Settings'. theme without quotes gets Cannot find name 'theme', because a name without quotes means a variable.
keyof Settings is the list of property names, "theme" | "fontSize" | "showTips". getSetting("fontSize") picks one, and Settings[K] gives back that property's type, number.Constraints that ask for too much
A constraint should ask for what the function uses, and no more. Here an order search asks for a name it never reads:
type Order = { id: number; total: number }; function findById<T extends { id: number; name: string }>(items: T[], id: number) { return items.find((item) => item.id === id);} const orders: Order[] = [{ id: 7, total: 9 }];const order = findById(orders, 7);Line 1 says an Order has an id and a total. Line 3 asks every item for an id and a name, but line 4 only reads id. Line 8 passes the orders, and TypeScript turns them away: Argument of type 'Order[]' is not assignable to parameter of type '{ id: number; name: string; }[]', followed by the reason, Property 'name' is missing in type 'Order'. Ask only for the id:
type Order = { id: number; total: number }; function findById<T extends { id: number }>(items: T[], id: number) { return items.find((item) => item.id === id);} const orders: Order[] = [{ id: 7, total: 9 }];const order = findById(orders, 7);// ^?console.log(order?.total);Line 3 now asks only for id. Line 8 is fine, and line 9 shows const order: Order | undefined: T is still Order, so order?.total on line 10 is allowed and prints 9.
name that findById never reads turns the order away on line 8. Asking only for an id lets it in, and T stays Order.Another way to ask for too much is to make the type parameter the whole list, when you only need its items:
function firstItem<T>(list: T[]) { return list[0];}function firstOfList<L extends any[]>(list: L) { return list[0];} const price = firstItem([3, 4]);// ^?const lost = firstOfList([3, 4]);// ^?Lines 1 to 3 use T for one item, and list: T[]. Lines 4 to 6 use L extends any[] for the whole list. Both give back list[0]. Line 9 shows const price: number, but line 11 shows const lost: any: TypeScript reads the item through the constraint any[], so the type is gone. Let the type parameter be the simplest thing you need, here one item.
Finally, a function may not need a type parameter at all. If T would appear only once, write the constraint as a plain parameter type:
function isTooLong(value: { length: number }, max: number) { return value.length > max;} console.log(isTooLong("Asha", 3));console.log(isTooLong(["Tea", "Cake"], 3));Line 1 takes anything with a length and gives back a boolean, not the value, so nothing needs connecting. Line 5 prints true: "Asha" has 4 letters, more than 3. Line 6 prints false: the cart has 2 items.
The table below lists the three ways a constraint asks for too much. Read each row as: what you wrote, what goes wrong, and what to write.
| When you write | What goes wrong | Write instead |
|---|---|---|
T extends { id: number; name: string }, but the function only reads id | Orders with no name are turned away, though the function never reads a name. | T extends { id: number }: ask only for what you use. |
L extends any[] for the whole list | list[0] is any, so the item's type is lost. | list: T[], so T is the type of one item. |
T extends { length: number } with T used only once | T connects nothing, so it adds nothing but reading time. | value: { length: number }, a plain parameter type. |
A product table sorted by any column
A product table on an admin page sorts by whichever column header you press. One generic sortBy works for any table, and only accepts real column names:
type Product = { name: string; price: number }; function sortBy<T, K extends keyof T>(rows: T[], key: K): T[] { return [...rows].sort((a, b) => (a[key] < b[key] ? -1 : 1));} const products: Product[] = [ { name: "Tea", price: 3 }, { name: "Cake", price: 4 }, { name: "Bun", price: 2 },];const byPrice = sortBy(products, "price");// ^?console.log(byPrice.map((product) => product.name));sortBy(products, "colour");Line 3 declares T for a row and K extends keyof T for the column. Line 4 copies the rows with [...rows], so the original array stays as it was, and sorts the copy by that column, smaller values first (the Sorting arrays lesson covers sort). Lines 7 to 11 are three products. Line 12 sorts them by price, and line 13 shows const byPrice: Product[]. Line 14 prints [ 'Bun', 'Tea', 'Cake' ]: 2, 3, then 4.
Line 15 is a column header someone misspelled, or a column that was removed. TypeScript marks it on purpose: Argument of type '"colour"' is not assignable to parameter of type 'keyof Product'. If that call ran, every a[key] would be undefined, so the sort would compare nothing. Without K extends keyof T, nothing would tell you.
function longest<T extends { length: number }>(a: T, b: T) { return a.length >= b.length ? a : b; } longest(3, 4);function longest<T extends { length: number }>(a: T, b: T) { return a.length >= b.length ? a : b; } longest([1], [2, 3]);function size<T>(value: T) { return value.length; }function size<T extends string | unknown[]>(value: T) { return value.length; }function get<T, K extends keyof T>(obj: T, key: K) { return obj[key]; } get({ tea: 3 }, "cake");function get<T, K extends keyof T>(obj: T, key: K) { return obj[key]; } get({ tea: 3 }, "tea");function idOf<T extends { id: number }>(item: T) { return item.id; } idOf({ id: 7, total: 9 });function idOf<T extends { id: number }>(item: T) { return item.id; } idOf({ id: "7" });
Sort each line by what TypeScript says about it. Each line is checked on its own.
Common mix-ups
You have now seen extends in three places, and only one of them is a constraint. The table below compares them. Read each row as: what you write, what it means, and whether it makes something new. (A fourth use, T extends U ? X : Y, asks a question about a type; Conditional types covers it later.)
| When you write | What it means | Does it make something new? |
|---|---|---|
class Admin extends User | Admin is a new class that gets everything User has, and can add more. | Yes. A class you can create objects with. |
interface Admin extends User | The type Admin has everything User has, plus what it adds. | Yes. A new type with a name. |
<T extends User> | T may be any type that fits User: it has at least what User has. | No. It only limits what T may be. |
- "
T extends UsermeansTmust be a class that inherits from User." No. Any type that fitsUseris allowed, even a plain object. Nothing is inherited. - "Inside the function,
Tis the constraint type." No. The constraint is the leastThas. A caller can fill in something more specific, likestring[], so a new{ length: 3 }isn't aT. - "A constraint makes the result lose its type." No.
Tstays the exact type of the argument. Plain parameter types likea: { length: number }lose it. - "Asking for more properties is safer." No. Every extra property turns away values that would have worked. Ask for what the function uses.
- "
key: stringis fine for a property name." No. Any text is astring, typos included.K extends keyof Tallows only the object's real names, and tells TypeScript which one you passed. - "Constraints are checked while the code runs." No. Like every type, they're removed before the code runs. They only stop wrong calls while you write them.
Practice exercises
Each exercise is TypeScript. When you change code, press Edit & run on the starter code: the errors and ^? answers update as you type, and the console shows what it prints. Try a guess first, then use the hints.
Three calls to longest. Which line will TypeScript mark?
function longest<T extends { length: number }>(a: T, b: T): T {
return a.length >= b.length ? a : b;
}
const tags = longest(["sale"], ["new"]);
const sizes = longest({ length: 2 }, { length: 3 });
const totals = longest(9, 12);function longest<T extends { length: number }>(a: T, b: T): T { return a.length >= b.length ? a : b;} const tags = longest(["sale"], ["new"]);const sizes = longest({ length: 2 }, { length: 3 });const totals = longest(9, 12); // line 7: numbers have no lengthArrays and the objects on line 6 have a length. The numbers on line 7 don't, so TypeScript says Argument of type 'number' is not assignable to parameter of type '{ length: number; }'.
What type does TypeScript give tips? Guess first. Then point at tips, or press Edit & run and add a // ^? line under it.
type Settings = { theme: string; fontSize: number; showTips: boolean }; const settings: Settings = { theme: "dark", fontSize: 16, showTips: true }; function getSetting<K extends keyof Settings>(key: K): Settings[K] { return settings[key];} const tips = getSetting("showTips");type Settings = { theme: string; fontSize: number; showTips: boolean }; const settings: Settings = { theme: "dark", fontSize: 16, showTips: true }; function getSetting<K extends keyof Settings>(key: K): Settings[K] { return settings[key];} const tips = getSetting("showTips");// ^?K is "showTips", so getSetting gives back Settings["showTips"], which is boolean. The query on line 10 shows const tips: boolean.
shortest gives back the shorter of two lists, but TypeScript marks line 2. Add a constraint so the errors go away. What does the code print?
function shortest<T>(a: T, b: T): T { return a.length <= b.length ? a : b;} console.log(shortest(["Tea", "Cake"], ["Pie"]));function shortest<T extends { length: number }>(a: T, b: T): T { return a.length <= b.length ? a : b;} console.log(shortest(["Tea", "Cake"], ["Pie"]));With T extends { length: number }, every T has a length, so line 2 is allowed. ["Pie"] has 1 item, fewer than 2, so the code prints [ 'Pie' ].
pluck makes a list of one column, like every price. TypeScript marks line 4: any text, as a string, can't be used to look up a property of a T. Fix it with a second type parameter. What does the code print?
type Product = { name: string; price: number }; function pluck<T>(items: T[], key: string) { return items.map((item) => item[key]);} const products: Product[] = [ { name: "Tea", price: 3 }, { name: "Cake", price: 4 },];console.log(pluck(products, "price"));type Product = { name: string; price: number }; function pluck<T, K extends keyof T>(items: T[], key: K) { return items.map((item) => item[key]);} const products: Product[] = [ { name: "Tea", price: 3 }, { name: "Cake", price: 4 },];const prices = pluck(products, "price");// ^?console.log(prices);K extends keyof T allows only a product's real names, so item[key] has a known type. The query shows const prices: number[], and the code prints [ 3, 4 ].
TypeScript turns the orders away on line 8. Change only what the constraint asks for, then print the order's total. What does it print?
type Order = { id: number; total: number }; function findById<T extends { id: number; name: string }>(items: T[], id: number) { return items.find((item) => item.id === id);} const orders: Order[] = [{ id: 7, total: 9 }];const order = findById(orders, 7);type Order = { id: number; total: number }; function findById<T extends { id: number }>(items: T[], id: number) { return items.find((item) => item.id === id);} const orders: Order[] = [{ id: 7, total: 9 }];const order = findById(orders, 7);console.log(order?.total);findById only reads item.id, so it should only ask for an id. Orders then fit, order is Order | undefined, and order?.total prints 9.
TypeScript reports no errors, yet the age is wrong. What does the starter print? Then change value's type so TypeScript catches it, and fix the call.
type ProfileForm = { name: string; age: number; newsletter: boolean }; function updateField<T, K extends keyof T>(form: T, key: K, value: any): T { return { ...form, [key]: value };} const form: ProfileForm = { name: "Asha", age: 30, newsletter: false };const next = updateField(form, "age", "31");console.log(next.age + 1);type ProfileForm = { name: string; age: number; newsletter: boolean }; function updateField<T, K extends keyof T>(form: T, key: K, value: T[K]): T { return { ...form, [key]: value };} const form: ProfileForm = { name: "Asha", age: 30, newsletter: false };const next = updateField(form, "age", Number("31"));console.log(next.age + 1);The starter prints 311: the age became the text "31", and + 1 joined text. With value: T[K], the value must be a number for "age", so TypeScript marks "31". Number("31") turns it into a number, and the code prints 32.
Check your understanding
For each question, ask two things: what must T (or K) have, and what is it for this call? The quiz shows its code without the checker, so the error list doesn't give the answers away.
Question 1 of 8What does
T extends { length: number }mean on a type parameter?Choose an answer to see the explanation.
Question 2 of 8Which line does TypeScript flag in these two calls to longest?
Read the code, then predictfunction longest<T extends { length: number }>(a: T, b: T): T { return a.length >= b.length ? a : b; } const tags = longest(["sale"], ["sale", "new"]); const totals = longest(10, 20);Choose an answer to see the explanation.
Question 3 of 8What type does
ageget from getProperty?Read the code, then predictfunction getProperty<T, K extends keyof T>(obj: T, key: K): T[K] { return obj[key]; } const user = { name: "Asha", age: 30 }; const age = getProperty(user, "age");Choose an answer to see the explanation.
Question 4 of 8What type does
cartget from this version with no type parameter?Read the code, then predictfunction longest(a: { length: number }, b: { length: number }) { return a.length >= b.length ? a : b; } const cart = longest(["Tea"], ["Tea", "Cake"]);Choose an answer to see the explanation.
Question 5 of 8What type does
priceget when the constraint covers the whole list?Read the code, then predictfunction firstOfList<L extends any[]>(list: L) { return list[0]; } const price = firstOfList([3, 4]);Choose an answer to see the explanation.
Question 6 of 8What does this sorted table print?
Read the code, then predictfunction sortBy<T, K extends keyof T>(rows: T[], key: K): T[] { return [...rows].sort((a, b) => (a[key] < b[key] ? -1 : 1)); } const products = [ { name: "Tea", price: 3 }, { name: "Bun", price: 2 }, ]; console.log(sortBy(products, "price").map((product) => product.name).join(", "));Choose an answer to see the explanation.
Question 7 of 8A function only reads
item.id. What should its constraint ask for?Choose an answer to see the explanation.
Question 8 of 8Why can't a function that returns T give back a new { length: minimum } object?
Choose an answer to see the explanation.
Key takeaways
- Without a constraint,
Tcould be any type, so you can't read.lengthor any property on it. T extends { length: number }allows any type with at least a numericlength, andTstays the exact type of the argument.keyof Settingsis the list of a type's property names.K extends keyof Tallows only real names, andT[K]is that property's type.- One type parameter can appear in another's constraint, which ties a key to its object and a value to its key.
- A constraint is a minimum. A function that returns
Tcan't give back something that only matches the constraint. - Ask for what the function uses, keep the type parameter as simple as possible, and skip it when it would appear only once.
Remember the one-liner.
A constraint says what every T has at least, so you can use it, while each call keeps its own exact type.
Coming next: Generic types & interfaces. It puts type parameters on types themselves, like ApiResponse<T>.