Command Palette

Search for a command to run...

Hectal
PHASE 7Intermediate ~27 min· topic 3 of 7

Topic 7.3

Generic Constraints

In one line

A constraint (T extends Something) limits which types may fill a blank, and in return the function body can use everything the constraint promises, while still returning the caller's exact type.

Think of it like this

A ride at a fair with a sign: "You must be at least this tall." Anyone can walk up, but only people who pass the test get on. The ride does not care if you are a child, a teenager or a tall grandparent. It only checks the one thing it relies on. A constraint is that sign. It says the type can be anything, as long as it has the one thing the function needs.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Constraint
A rule on a type parameter that limits which types may fill it, written T extends Something.
extends (in a constraint)
"Is assignable to" or "has at least this shape". It isn't class inheritance here.
keyof T
A union of the property names of T, such as "name" | "age".
Indexed access type
T[K]: the type of property K on type T.
Subtype
A type that has everything another type has, and maybe more. { id: number; label: string } is a subtype of { id: number }.
Preserve
To return the caller's exact type instead of a simpler one, so no information is lost.
Intersection
A & B: a type that has all the members of both.
Implicit constraint
The one used when you write none: unknown. It allows nothing special.

Step by step

01Without a constraint, no .length

T could be a number, a boolean, anything. The compiler can't know that it has a length, so reading it is an error.

unconstrained.tswhole filets
function longest<T>(a: T, b: T): T {
  return a.length >= b.length ? a : b;
  // error TS2339: Property 'length' does not exist on type 'T'.
}

02Add the sign: T extends { length: number }

Now every possible T is promised to have a numeric length. The body type-checks. The callers must prove it: strings, arrays and anything with a length pass, numbers don't.

constrained.tswhole filets
function longest<T extends { length: number }>(a: T, b: T): T {
  return a.length >= b.length ? a : b;
}
longest("apple", "fig");  // string-like
longest([1, 2, 3], [4]);  // number[]
longest(10, 20);          // error: number has no length
Add the sign: T extends { length: number }diagram
Rendering diagram…

03The result keeps its real type

longest returns T, not { length: number }. For two arrays the result is number[], so .push, .map and the rest are available. The constraint only checks the input; the output keeps the caller's type.

04Link two type parameters with keyof

K extends keyof T ties the key to the object. T is the object type, K is one of its key names, and the return type T[K] looks the property up.

A wrong key isn't just a failed lookup: it is rejected before the call, and the error lists the real keys.

get-property.tswhole filets
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}
const user = { name: "Asha", age: 30 };
getProperty(user, "age");   // number
getProperty(user, "email"); // error TS2345

05Constraints narrow like any other type

A constraint of string | number lets you narrow with typeof inside the function, exactly as with a union parameter (Topic 4.3). The difference is the return type: you can keep the caller's exact type.

A function that must produce a new T can't, because only the caller knows what T really is. Spread the value you were given ({ ...item, id: item.id + 1 }) instead of building one from scratch.

Try it yourself

  1. 1

    Pass something with a length

    In the first example add longest({ length: 3, tag: "mine" }, { length: 9, tag: "yours" }) and ask // ^?. The object passes because it has a numeric length, and the result keeps its tag.

  2. 2

    Mix the types

    Try longest("apple", [1, 2]). Predict first: do string and number[] both satisfy the constraint? Yes, but T has to be one type for both arguments. The compiler fixes T from the first argument and complains about the second: Argument of type 'number[]' is not assignable to parameter of type '"apple"'. (Topic 7.6 explains.)

  3. 3

    Misspell a key

    In the second example change "age" to "agee". The message lists the real keys: this is how editors can suggest property names for you in the call.

Code & diagrams

Accept anything with a length, return what you were given

Strings and arrays both have a length. The result for arrays is number[] (not just { length: number }), so array methods are still there. A number is rejected with the constraint in the message.

Console

(nothing was printed)

Expected console output

apple [ 1, 2, 3 ]
K extends keyof T: only real keys, and the right result type

The error lists the allowed keys. For "age" the result type is number, for "name" it is string: T[K] is looked up per call. Try getProperty(user, "adm") and read the message.

Console

(nothing was printed)

Expected console output

Asha 30
Keep the caller's type, do not build a new T

touch works for any shape that has an id and keeps the extra label. withDefaults tries to create a T out of nothing, which the compiler refuses: the caller picks T and could demand more properties than the function can provide.

Console

(nothing was printed)

Expected console output

{ id: 2, label: 'kept' }
Union constraints, intersections and the unconstrained T

Inside describe, typeof narrows T the same way it narrows a union. merge returns the intersection of both inputs. The last two lines are errors: true is not string | number, and an unconstrained T has no length. The code still runs and describe(true) fails at runtime, which is the bug the error predicted.

Console

(nothing was printed)

Expected console output

HI 2.0 { a: 1, b: 'x' }
Uncaught TypeError: value.toFixed is not a function

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Remove the constraint

In the last example, unsafe<T>(x: T) reads x.length. Notice there is no extends.

terminal
$ Types panel
── what you'll see ──
index.ts(12,12): error TS2339: Property 'length' does not exist on type 'T'.

Break #2

Build a T from nothing

In withDefaults, the line const copy: T = { id: 0 }; tries to create a value of the caller's type.

terminal
$ Types panel
── what you'll see ──
index.ts(5,9): error TS2322: Type '{ id: number; }' is not assignable to type 'T'.
'{ id: number; }' is assignable to the constraint of type 'T', but 'T' could be instantiated with a different subtype of constraint 'HasId'.

Myth vs fact

Myth

T extends X means T inherits from class X.

Fact

In a constraint, extends is about shape: "is assignable to". An object literal, an interface, an array or a string can all satisfy T extends { length: number }, with no classes involved.

Myth

A constrained generic returns the constraint type.

Fact

It returns whatever T is at the call site. longest([1, 2], [3]) is number[], not { length: number }. That is the point of a constraint over a plain parameter type.

Myth

K extends keyof T just means K is a string.

Fact

keyof T is the exact union of T's keys, so K is limited to those names and the result type T[K] can be worked out per call. Keys can also be numbers or symbols (Topic 8.1).

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Inside the function, T's base constraint is used for property access and assignment checks. value.length works because the compiler resolves T to { length: number } for that expression, but T itself remains opaque. That is why you can read from a T extends HasId but cannot assign a new object to it.

  • ▸

    Constraints are checked at each call, with the inferred type argument, and the error appears on the argument that caused the failure when the compiler can tell which one it was. If you want a better message, a conditional type (Phase 9) can produce a custom error shape; a constraint alone gives the generic wording.

  • ▸

    The K extends keyof T pattern is the base of the standard library's Pick<T, K extends keyof T>, and Record<K extends keyof any, T> uses the same idea for a key set (Topic 10.1). Writing a pluck yourself first makes those definitions easy to read.

Remember this

  1. 1

    The problem this solves: An unconstrained generic could be anything, so you can't read .length or any property inside it. A constraint states the minimum the type must have. In Topic 7.1 you saw that inside function f<T>(x: T) you can't do anything with x, because T could be anything. That is too strict for real code: a function that returns the longer of two things needs .length. A constraint fixes this: function longest<T extends { length: number }>(a: T, b: T): T. Read extends here as "is assignable to" (a value of that type may be used in that spot, Topic 3.5) or "has at least this shape". Now a.length is allowed, because every T is promised to have one.

  2. 2

    What may fill the blank? Any type that has what the constraint asks for. A string has length. An array has length. An object { length: 3, tag: "x" } has it. A number does not, so longest(10, 20) is rejected with an error that names the constraint. The constraint acts on the type argument, so extra properties on the real type are fine.

  3. 3

    Why not write function longest(a: { length: number }, b: { length: number }): { length: number }? Because the return type would then be just { length: number }: you would get back something that has a length and nothing else. With T extends ... the function returns the same type it received, string for strings, number[] for arrays, and you keep every method and property. That's the difference between "accepts anything with a length" and "accepts anything with a length and remembers what it was".

  4. 4

    The most useful constraint relates two type parameters. K extends keyof T means "K must be one of the property names of T". keyof T is the union of the keys of T (Topic 8.1 studies it fully). With function getProperty<T, K extends keyof T>(obj: T, key: K): T[K], the key must exist on the object, and the return type T[K] is the type of that property: getProperty(user, "age") is number, while "email" is an error. T[K] is an indexed access type (Topic 8.3). This single signature is how safe pluck, get, pick and sortBy helpers are written.

  5. 5

    Constraints can be unions (T extends string | number), object types, interfaces, classes (T extends Animal), another type parameter (U extends T), or a function type. Several constraints combine with & (T extends HasId & HasName). Inside the body, the compiler uses the constraint as the best known type of T, so after narrowing a T extends string | number with typeof value === "string" you can call value.toUpperCase().

  6. 6

    A constraint tells you the least that is known about T, not its exact type. A common surprise: function f<T extends HasId>(): T { return { id: 0 }; } is an error even though { id: 0 } fits HasId. The caller chose T; it might be { id: number; label: string }, and the function can't produce a label. TypeScript says 'T' could be instantiated with a different subtype of constraint 'HasId'. If the function builds a value, return a type that the function itself controls.

  7. 7

    If no constraint is written, T has the implicit constraint unknown, which is why nothing is allowed on it. If you want the opposite, "must not be null or undefined", write T extends {} or use the helper NonNullable<T> (Topic 10.2).

Explain it without notes

01

What does T extends { length: number } do, and how is it different from taking a parameter of type { length: number }?

02

Explain function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] piece by piece.

03

Why is const x: T = { id: 0 } an error when T extends HasId?

04

What is the constraint of an unconstrained type parameter, and what follows from it?

Practice

01

Write pluck<T, K extends keyof T>(items: T[], key: K): T[K][] that returns the values of one property from every item. Show that a bad key is rejected.

02

Write maxBy<T>(items: T[], score: (item: T) => number): T | undefined that returns the item with the highest score. Does it need a constraint? Why or why not?

03

Write a function hasId<T extends { id: number }>(items: T[], id: number): T | undefined that finds an item by id and returns it with its full type.

Trade-offs

  • ↔

    Constraints make a function safer to write and read: the signature says exactly what it needs. Over-tight constraints reduce reuse (asking for a whole User when only id is used). Constrain to the smallest shape the body uses.

  • ↔

    A constraint makes error messages mention your helper types ('HasId'). Name constraint interfaces clearly, so the message teaches the caller what to pass.

Done when you can

  • I can write T extends { length: number } and explain what it allows and rejects.

  • I can explain why the result is T and not the constraint type.

  • I can write a function with K extends keyof T and say what T[K] returns.

  • I can explain why a function can't create a new T from a literal.

  • I can say what the constraint of a plain <T> is.