Phase 4 · Generics · Lesson 4.2
IntermediateGeneric constraints and defaults
Limit what a type parameter can be with `extends`, type-safe property access with `keyof`, default type arguments, and the famous 'could be instantiated with a different subtype' error explained.
20 min
An unconstrained T can be anything, so inside the function TypeScript lets you do almost nothing with it. You can't read .length, can't call a method, can't access a key. Constraints are how you say "T can be many types, but all of them have at least this shape", which unlocks exactly the operations you need and nothing more.
The problem: T knows nothing
function longest<T>(a: T, b: T): T {
// @ts-expect-error -- Property 'length' does not exist on type 'T'.
return a.length >= b.length ? a : b;
}TypeScript is right to refuse. longest(1, 2) would be allowed by this signature, and numbers have no length.
extends: an upper bound on T
function longest<T extends { length: number }>(a: T, b: T): T {
return a.length >= b.length ? a : b;
}
const s = longest("ab", "abc");
// ^? const s: "ab" | "abc"
const arr = longest([1, 2], [3]);
// ^? const arr: number[]
// @ts-expect-error -- Argument of type 'number' is not assignable to parameter of type '{ length: number; }'.
longest(10, 20);T extends { length: number } reads as "T must be assignable to { length: number }". Strings, arrays and any object with a numeric length qualify. Inside the function, T is treated as at least its constraint, so a.length is fine.
Two details in that output:
sis"ab" | "abc", notstring. Two literals of the same primitive can share a union, so the candidates combine. (Numbers and strings can't, as you saw last lesson.)extendshere is not inheritance. It's the same "is assignable to" check used everywhere else in the type system.
Why not just type the parameter as the shape?
function longestLoose(a: { length: number }, b: { length: number }) {
return a.length >= b.length ? a : b;
}
const r = longestLoose("ab", "abc");
// ^? const r: { length: number; }It compiles, but the caller gets back { length: number } and loses the fact that it passed strings. The constraint version returns T, the caller's own type. That's the whole reason to use a constrained generic: keep the specific type while requiring a minimum shape.
K extends keyof T: type-safe property access
The most common constraint in real code. keyof T is the union of T's keys, and T[K] is the type at that key:
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { name: "Ada", age: 36 };
const age = getProperty(user, "age");
// ^? const age: number
const name = getProperty(user, "name");
// ^? const name: string
// @ts-expect-error -- Argument of type '"email"' is not assignable to parameter of type '"name" | "age"'.
getProperty(user, "email");Here one type parameter's constraint references another: K is limited by whatever T turns out to be. Inference fills T from obj first, then checks key against keyof T. You get autocomplete on the key and an exact return type for free.
Quick check
What is the type of v?
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const flags = { id: "x1", active: true };
const v = getProperty(flags, "active");Constraints that reference other type parameters
K extends keyof T is one example. Any constraint can mention earlier (or later) type parameters in the same list:
function setField<T, K extends keyof T>(obj: T, key: K, value: T[K]): T {
return { ...obj, [key]: value };
}
const user = { name: "Ada", age: 36 };
setField(user, "age", 37);
// @ts-expect-error -- Argument of type 'string' is not assignable to parameter of type 'number'.
setField(user, "age", "thirty-seven");value must match the type at that specific key. A second example: "the patch must be a subset of the target".
function applyPatch<T extends object, P extends Partial<T>>(target: T, patch: P): T {
return { ...target, ...patch };
}
const config = { host: "localhost", port: 8080 };
applyPatch(config, { port: 9090 });
// @ts-expect-error -- Type 'string' is not assignable to type 'number'.
applyPatch(config, { port: "9090" });Constraints with unions
A constraint can be a union. T then must be one of the members (or a subtype of one, like a literal):
function toId<T extends string | number>(value: T): T {
return value;
}
const a = toId("u-1");
// ^? const a: "u-1"
const b = toId(42);
// ^? const b: 42
// @ts-expect-error -- Argument of type 'boolean' is not assignable to parameter of type 'string | number'.
toId(true);Because the constraint contains primitives, literals are preserved: a is "u-1", not string. That's often what you want for IDs and tags.
Narrowing a value of type T works as you'd hope: inside the branch, value has the string methods.
function describe<T extends string | number>(value: T): string {
if (typeof value === "string") {
return value.toUpperCase(); // value is treated as a string here
}
return value.toFixed(2); // and as a number here
}The catch: narrowing changes what TypeScript knows about the value, never what it knows about T. T might still be the literal "a", so a new string isn't a valid T:
function shout<T extends string | number>(value: T): T {
if (typeof value === "string") {
// @ts-expect-error -- Type 'string' is not assignable to type 'T'.
return value.toUpperCase();
}
return value;
}That message ends with the most confusing sentence in generics. Time to take it apart.
"could be instantiated with a different subtype"
Here is the error every TypeScript developer hits eventually:
interface Entity {
id: number;
}
function makeEntity<T extends Entity>(): T {
// @ts-expect-error -- 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 'Entity'.
return { id: 0 };
}It feels wrong: { id: 0 } is an Entity, and T extends Entity. So why not?
Because the caller picks T, not you. The constraint is only a lower limit on what T can be. Look at what a caller is allowed to write:
interface Entity { id: number }
declare function makeEntity<T extends Entity>(): T; // the function above
interface User extends Entity {
name: string;
}
const u = makeEntity<User>(); // T = User
u.name.toUpperCase(); // the type says string, the value has no name: crashT = User satisfies the constraint, so the signature promises a User. Your function returns { id: 0 }, which has no name. TypeScript can't prove your object is a valid T for every T a caller might choose, so it rejects it. The error message says exactly this: your value fits the constraint, but T could be a more specific subtype of it.
The same thing happens with primitives:
function defaultLabel<T extends string>(): T {
// @ts-expect-error -- Type 'string' is not assignable to type 'T'.
return "untitled";
}A caller can write defaultLabel<"draft" | "final">(), and "untitled" is neither.
How to fix it
There's no single fix, because the error usually means the signature is lying. Pick the one that tells the truth:
interface Entity {
id: number;
}
// 1. Don't promise T if you can't produce one. Return the constraint.
function makeEntity(): Entity {
return { id: 0 };
}
// 2. Build the result FROM a T the caller gave you.
function resetId<T extends Entity>(entity: T): T {
return { ...entity, id: 0 }; // spreading T gives T & { id: number }
}
// 3. Let the caller supply the construction.
function create<T extends Entity>(factory: () => T): T {
return factory();
}Fix 2 compiles because spreading a generic produces an intersection (T & { id: number }), which is assignable to T. It's slightly unsound: if a caller's T had id: 1 as a literal type, the result's id is really 0. That's the accepted trade-off in practice.
Casting (return { id: 0 } as T) makes the error go away and brings the crash back. Only do it when you can genuinely prove the value is a T.
Default type parameters
A default is the type used when the caller doesn't provide one and inference has nothing to go on. Syntax: <T = Something>.
interface ApiResponse<T = unknown> {
status: number;
data: T;
}
const raw: ApiResponse = { status: 200, data: "anything" };
// ^? const raw: ApiResponse<unknown>
const typed: ApiResponse<{ id: number }> = { status: 200, data: { id: 1 } };With a default, ApiResponse alone is valid; without one it would be Generic type 'ApiResponse' requires 1 type argument(s).
Constraint vs default: they answer different questions
Constraint T extends X | Default T = X | |
|---|---|---|
| Question it answers | What is T allowed to be? | What is T when nobody says? |
| Enforced on callers? | Yes, violations are errors | No, it's just a fallback |
| Makes the type argument optional? | No | Yes |
Inside the function, T is treated as | at least X | still an unknown T |
You often combine them. The default must satisfy the constraint:
interface Paged<T extends object = Record<string, unknown>> {
items: T[];
page: number;
}
const page: Paged = { items: [{ any: "thing" }], page: 1 };
// @ts-expect-error -- Type 'number' does not satisfy the constraint 'string'.
type Broken<T extends string = number> = T;Like optional function parameters, type parameters with defaults must come last: <A = string, B> is an error (Required type parameters may not follow optional type parameters).
Gotcha: explicit arguments switch off inference for the rest
Defaults let you pass some type arguments. But then the omitted ones take their default, not an inferred type:
function request<TBody, TMeta = { requestId: string }>(body: TBody, meta?: TMeta): [TBody, TMeta | undefined] {
return [body, meta];
}
const inferred = request({ q: 1 }, { traceId: "t1" });
// ^? const inferred: [{ q: number; }, { traceId: string; } | undefined]
// @ts-expect-error -- Object literal may only specify known properties, and 'traceId' does not exist in type '{ requestId: string; }'.
request<{ q: number }>({ q: 1 }, { traceId: "t1" });Once you write any type argument explicitly, inference is off for the whole call. TMeta becomes its default { requestId: string } and the object literal is checked against that.
const type parameters (TS 5.0+)
Sometimes you want the narrowest type from the argument, as if the caller wrote as const. Add the const modifier:
function routes<const T extends readonly string[]>(paths: T): T {
return paths;
}
const r = routes(["/home", "/about"]);
// ^? const r: readonly ["/home", "/about"]Without const, r would be string[]. The modifier only affects literals written at the call site; passing an existing string[] variable still gives string[]. Use a readonly array constraint, otherwise the const inference falls back to a mutable, less specific type.
Spot the error
A helper that returns an empty array "of the same kind" as its input. It doesn't compile. Why, and what should the signature be?
function emptyLike<T extends unknown[]>(items: T): T {
return [];
}Show the answer
Type 'never[]' is not assignable to type 'T'. 'never[]' is assignable to the constraint of type 'T', but 'T' could be instantiated with a different subtype of constraint 'unknown[]'.
The caller can pick a tuple: emptyLike([1, "a"] as [number, string]) makes T = [number, string], and an empty array is not a two-element tuple. The signature promises "same type as the input", which an empty array can't keep. Return what you actually produce, an array of the same element type:
function emptyLike<T extends unknown[]>(items: T): T[number][] {
return [];
}
const e = emptyLike([1, 2, 3]);
// ^? const e: number[]Try it
function pluckAll<T, K extends keyof T>(items: T[], ...keys: K[]): Pick<T, K>[] {
return items.map((item) => {
const picked = {} as Pick<T, K>;
for (const key of keys) picked[key] = item[key];
return picked;
});
}
const people = [
{ id: 1, name: "Ada", email: "ada@example.com" },
{ id: 2, name: "Linus", email: "linus@example.com" },
];
const slim = pluckAll(people, "id", "name"); // { id: number; name: string }[]
// Try: add "phone" as a key and read the error.
console.log(slim);▶ Try it in the TypeScript Playground
Quick check
Which line is a compile error?
interface Tagged<T extends string | number = number> {
tag: T;
}
const a: Tagged = { tag: 1 }; // Line A
const b: Tagged<string> = { tag: "s" }; // Line B
const c: Tagged<"x"> = { tag: "x" }; // Line C
const d: Tagged<boolean> = { tag: true }; // Line DRecap
T extends XmeansTmust be assignable toX; inside the functionThas at leastX's members.- A constrained generic keeps the caller's specific type; a plain parameter of type
Xloses it. <T, K extends keyof T>(obj: T, key: K): T[K]is the standard type-safe property accessor.- Constraints can reference other type parameters (
K extends keyof T,P extends Partial<T>). - Literal types are preserved when the constraint includes primitives (
T extends string,keyof T). - Narrowing a
Tvalue (typeof value === "string") unlocks methods, but never changesT: a new string still isn't a validT. - "Could be instantiated with a different subtype": the caller picks
T, so you can't return a value that only fits the constraint. - Defaults (
<T = X>) are fallbacks, not limits; they must satisfy the constraint and come last. - Passing any explicit type argument turns off inference for the rest; omitted ones use their defaults.
const T(TS 5.0) infers literal, readonly types from call-site literals.
Interview cards
1 / 7