Generic Constraints with extends in TypeScript


Why Constraints Exist
Unconstrained generics are powerful but limited. When T can be literally anything, TypeScript can't let you do much with it — because "anything" includes types that don't support the operation you need.
function getLength<T>(value: T): number {
return value.length; // Error: Property 'length' does not exist on type 'T'
}TypeScript rejects this because T might be a number, a boolean, or a custom object with no length property. The fix isn't to give up on generics — it's to constrain T so TypeScript knows it has a length property:
function getLength<T extends { length: number }>(value: T): number {
return value.length; // OK — T is guaranteed to have length
}
getLength("hello"); // 5 — string has length
getLength([1, 2, 3]); // 3 — array has length
getLength({ length: 10 }); // 10 — custom object with length
getLength(42); // Error: number has no 'length' propertyThis is the core idea: T extends SomeType means "T must be assignable to SomeType." You keep the flexibility of generics while unlocking specific capabilities.
extends Here Is Not Inheritance
In T extends SomeType, extends means structural compatibility — T must have at least the shape of SomeType. It is not the same as class inheritance. A plain object { length: 10 } satisfies T extends { length: number } without inheriting from anything. TypeScript's type system is structural, not nominal.
Basic Constraints with extends
The simplest form of a constraint is extending a primitive union — restricting T to a specific set of primitive types:
function clamp<T extends number>(value: T, min: T, max: T): T {
if (value < min) return min;
if (value > max) return max;
return value;
}
clamp(5, 1, 10); // 5
clamp(0, 1, 10); // 1
clamp(15, 1, 10); // 10
clamp("a", "b", "c"); // Error: 'string' does not satisfy 'number'You can also constrain to a union of types, giving you more flexibility than a single type but more safety than unconstrained T:
function stringify<T extends string | number | boolean>(value: T): string {
return String(value);
}
stringify("hello"); // OK
stringify(42); // OK
stringify(true); // OK
stringify([1, 2]); // Error: array does not satisfy the constraint
stringify(null); // Error: null does not satisfy the constraintConstraining to a union is useful when you have a function that genuinely works for a limited set of types — narrower than "anything," but broader than one specific type.
Constraining to an Object Shape
The most common constraint pattern is restricting T to an object shape. This lets you access specific properties safely while preserving the full type of whatever was passed in:
interface HasId {
id: number;
}
function findById<T extends HasId>(items: T[], id: number): T | undefined {
return items.find((item) => item.id === id);
}
interface User {
id: number;
name: string;
email: string;
}
interface Product {
id: number;
name: string;
price: number;
}
const users: User[] = [
{ id: 1, name: "Alice", email: "[email protected]" },
{ id: 2, name: "Bob", email: "[email protected]" },
];
const user = findById(users, 1);
// user is User | undefined — not HasId | undefined
// TypeScript preserves the full User type through the constraint
console.log(user?.email); // OK — TypeScript knows it's a UserThis is the key benefit of constraining to a shape rather than using the interface directly: the return type is T — the full type of what was passed in — not just HasId. The constraint opens the door to item.id; the type parameter preserves the rest.
// Without generics — you lose the specific type
function findByIdBasic(items: HasId[], id: number): HasId | undefined {
return items.find((item) => item.id === id);
}
const result = findByIdBasic(users, 1);
result?.email; // Error: Property 'email' does not exist on type 'HasId'Constraint vs Parameter Type
When you write items: HasId[], the return type can only be HasId — you've erased the specific type. When you write <T extends HasId>(items: T[]), the return type can be T — you've preserved the specific type while still requiring the id property. This distinction matters every time a caller needs the full type back.
The keyof Constraint
One of the most powerful constraint patterns combines a type parameter with keyof — the operator that produces a union of an object's keys. This lets you write type-safe property accessor functions:
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { id: 1, name: "Alice", email: "[email protected]" };
getProperty(user, "name"); // "Alice" — return type is string
getProperty(user, "id"); // 1 — return type is number
getProperty(user, "email"); // "[email protected]" — return type is string
getProperty(user, "age"); // Error: "age" is not a key of the user objectTwo type parameters work together here: T is the object type, K extends keyof T constrains the key to valid keys of T, and T[K] is the indexed access type — the type of the value at that key. TypeScript resolves T[K] to the exact type of each property.
This pattern is the foundation for utility functions that need to be key-safe:
function pick<T, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> {
const result = {} as Pick<T, K>;
keys.forEach((key) => {
result[key] = obj[key];
});
return result;
}
const user = {
id: 1,
name: "Alice",
email: "[email protected]",
role: "admin",
};
const summary = pick(user, ["id", "name"]);
// summary: { id: number; name: string }
// TypeScript knows exactly which properties are in the result
pick(user, ["id", "unknown"]); // Error: "unknown" is not a key of userkeyof and Indexed Access Types
keyof T produces a union of T's keys: for User, it's "id" | "name" | "email". T[K] (an indexed access type) resolves to the value type at key K:
User["id"] is number, User["name"] is string. Together, they're how
TypeScript tracks property types through generic functions. A full post on
indexed access types is coming later in the series.
Multiple Constraints
A type parameter can only have one extends clause, but you can express multiple constraints by intersecting them:
interface HasId {
id: number;
}
interface HasTimestamps {
createdAt: Date;
updatedAt: Date;
}
// T must have both id AND timestamps
function logEntity<T extends HasId & HasTimestamps>(entity: T): void {
console.log(
`Entity ${entity.id} — created: ${entity.createdAt.toISOString()}`
);
}
interface Post {
id: number;
title: string;
createdAt: Date;
updatedAt: Date;
}
logEntity({
id: 1,
title: "Hello",
createdAt: new Date(),
updatedAt: new Date(),
}); // OK
logEntity({ id: 1, title: "Hello" }); // Error: missing createdAt, updatedAtYou can also have multiple type parameters with independent constraints:
function mergeObjects<T extends object, U extends object>(
target: T,
source: U
): T & U {
return { ...target, ...source };
}
const base = { id: 1, name: "Alice" };
const extra = { role: "admin", active: true };
const merged = mergeObjects(base, extra);
// merged: { id: number; name: string } & { role: string; active: boolean }
merged.id; // number
merged.role; // string
merged.active; // booleanEach type parameter is independently constrained. T extends object and U extends object both prevent primitive types from being passed, while keeping T and U independent of each other.
Real-World Examples
Type-safe deep get:
function deepGet<T extends Record<string, unknown>, K extends keyof T>(
obj: T,
key: K
): T[K] {
return obj[key];
}Generic sorting with a comparable constraint:
interface Comparable<T> {
compareTo(other: T): number;
}
function sort<T extends Comparable<T>>(items: T[]): T[] {
return [...items].sort((a, b) => a.compareTo(b));
}
class Temperature implements Comparable<Temperature> {
constructor(public celsius: number) {}
compareTo(other: Temperature): number {
return this.celsius - other.celsius;
}
}
const temps = [new Temperature(30), new Temperature(10), new Temperature(20)];
sort(temps); // [Temperature(10), Temperature(20), Temperature(30)]Generic event handler registry:
type Handler<T> = (event: T) => void;
function createRegistry<TEvent extends { type: string }>() {
const handlers = new Map<string, Handler<TEvent>>();
return {
register<K extends TEvent["type"]>(
type: K,
handler: Handler<Extract<TEvent, { type: K }>>
): void {
handlers.set(type, handler as Handler<TEvent>);
},
dispatch(event: TEvent): void {
handlers.get(event.type)?.(event);
},
};
}Merging with a required base shape:
function withDefaults<T extends Partial<Config>, Config>(
partial: T,
defaults: Config
): Config & T {
return { ...defaults, ...partial };
}
interface ServerConfig {
host: string;
port: number;
timeout: number;
}
const config = withDefaults(
{ port: 8080 },
{ host: "localhost", port: 3000, timeout: 5000 }
);
// config: ServerConfig & { port: number }
// config.host — "localhost" (from defaults)
// config.port — 8080 (overridden)Constraints Enable Precision
Unconstrained generics are a blank cheque — flexible but not specific enough to be useful. Constraints are what let you cash it: you tell TypeScript exactly what T must have, and in return TypeScript gives you full type safety when you use those properties. The more precise your constraint, the more TypeScript can help you.
Best Practices
Prefer narrow constraints. Constraining to { id: number } instead of object makes your intent clear and gives TypeScript more to work with when resolving types.
Avoid over-constraining. If your function only needs length, constrain to { length: number } — not to Array<unknown> or string. Over-constraining excludes valid callers unnecessarily.
Name your constraint interfaces. Inline constraints like T extends { id: number; name: string; createdAt: Date } work but are hard to read. A named interface makes the intent explicit:
// Harder to read
function process<T extends { id: number; name: string; createdAt: Date }>(
item: T
): void {}
// Clearer
interface Entity {
id: number;
name: string;
createdAt: Date;
}
function process<T extends Entity>(item: T): void {}Use keyof constraints for property accessor patterns. Any time you're writing a function that takes an object and a property name, reach for <T, K extends keyof T> instead of accepting string keys. You get autocomplete and type safety in one move.
Constraints Are Not Default Values
T extends string means "T must be a string or a subtype of string" — it does
not mean "T defaults to string if nothing is provided." Default type
parameters (T = string) are a separate feature covered in the next post. A
constraint restricts what you can pass; a default fills in the blank when you
don't pass anything.
Key Takeaways
T extends SomeTypeconstrains what typesTcan accept — T must be structurally compatible with SomeTypeextendshere means structural compatibility (assignability), not class inheritance- Constraining to an object shape (
T extends { id: number }) gives TypeScript access to specific properties while preserving the full type through the return value K extends keyof Tcombined withT[K]as the return type is the foundation for type-safe property accessor functions- Multiple constraints use intersection:
T extends HasId & HasTimestamps - Multiple type parameters can each have their own independent constraint
Up Next: Default Type Parameters
You've mastered constraining what types are allowed. The next post covers default generic parameters — a way to make type arguments optional by providing a fallback when the caller doesn't specify one. Used well, defaults make generic types much more ergonomic without sacrificing any of the safety you just learned.