Skip to main content
Advertisement

5.2 Generic Constraints

Why Constraints Are Needed

Generics are powerful, but without restrictions the type parameter gives you very little to work with inside the function body.

function getLength<T>(value: T): number {
return value.length; // Error: Property 'length' does not exist on type 'T'
}

Because TypeScript does not know what T is, there is no guarantee that .length exists. A constraint lets you declare "T must be a type that has a length property."

function getLength<T extends { length: number }>(value: T): number {
return value.length; // OK
}

getLength("hello"); // 5 (string.length)
getLength([1, 2, 3]); // 3 (array.length)
getLength({ length: 10 }); // 10 (object's length)
getLength(42); // Error: number has no length

extends Constraint Basics

T extends U means "T must be a subtype of U." Every property that U has, T must also have.

// Basic extends constraint
function printName<T extends { name: string }>(item: T): void {
console.log(item.name);
}

printName({ name: "Alice" }); // OK
printName({ name: "Bob", age: 30 }); // OK (extra properties are allowed)
printName({ age: 30 }); // Error: missing name property

// Constraining with an interface
interface Serializable {
serialize(): string;
}

function saveToStorage<T extends Serializable>(item: T, key: string): void {
localStorage.setItem(key, item.serialize());
}

// Constraining with a primitive type
function add<T extends number | string>(a: T, b: T): string {
return `${a} + ${b}`;
}

add(1, 2); // OK
add("a", "b"); // OK
add(1, "b"); // Error: string is not assignable to number (T is inferred as number)

Hierarchical Constraints

// Requiring multiple properties at once
interface Identifiable {
id: number;
}

interface Timestamped {
createdAt: Date;
updatedAt: Date;
}

// Intersection type requiring both interfaces
function updateEntity<T extends Identifiable & Timestamped>(
entity: T,
updates: Partial<Omit<T, "id" | "createdAt">>
): T {
return {
...entity,
...updates,
updatedAt: new Date(),
};
}

interface User extends Identifiable, Timestamped {
name: string;
email: string;
}

const user: User = {
id: 1,
name: "Alice",
email: "alice@example.com",
createdAt: new Date("2024-01-01"),
updatedAt: new Date("2024-01-01"),
};

const updated = updateEntity(user, { name: "Alice Updated" });

keyof Constraint

keyof T returns a union of all keys of type T. K extends keyof T constrains K to "one of T's keys."

// keyof basics
interface Person {
id: number;
name: string;
age: number;
email: string;
}

type PersonKeys = keyof Person; // "id" | "name" | "age" | "email"

// K extends keyof T: safe property access
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}

const person: Person = { id: 1, name: "Alice", age: 30, email: "alice@example.com" };

const name = getProperty(person, "name"); // string
const age = getProperty(person, "age"); // number
const id = getProperty(person, "id"); // number

getProperty(person, "phone"); // Error: 'phone' is not a key of Person

// Type-safe setter
function setProperty<T, K extends keyof T>(obj: T, key: K, value: T[K]): T {
return { ...obj, [key]: value };
}

const updated = setProperty(person, "name", "Bob"); // OK
const invalid = setProperty(person, "name", 42); // Error: number not assignable to string

Combining Mapped Types with keyof

// Transform all values of an object
function mapValues<T extends object, U>(
obj: T,
fn: (value: T[keyof T], key: keyof T) => U
): Record<keyof T, U> {
const result = {} as Record<keyof T, U>;
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
result[key as keyof T] = fn(obj[key as keyof T], key as keyof T);
}
}
return result;
}

const person2 = { name: "Alice", age: 30 };
const stringified = mapValues(person2, (v) => String(v));
// { name: string, age: string }

// Selectively pick keys
function pick<T extends object, 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 subset = pick(person, ["name", "email"]);
// { name: string, email: string }

Combining Conditional Types with Constraints

// Precise type expressions using constraints + conditional types
type StringsOnly<T> = T extends string ? T : never;

type Test1 = StringsOnly<"hello" | 42 | "world" | boolean>;
// "hello" | "world"

// Generic with constraint + conditional type
function processIfString<T>(
value: T
): T extends string ? string : never {
if (typeof value === "string") {
return value.toUpperCase() as T extends string ? string : never;
}
throw new Error("Not a string.");
}

// Type transformation with conditional types
type Unwrap<T> = T extends Array<infer Item> ? Item : T;

type UnwrappedNumber = Unwrap<number[]>; // number
type UnwrappedString = Unwrap<string>; // string (not an array, returned as-is)

Constraint Chaining

You can combine multiple constraints to express a narrower type range.

// object constraint: excludes primitives
function cloneObject<T extends object>(obj: T): T {
return JSON.parse(JSON.stringify(obj)) as T;
}

cloneObject({ a: 1 }); // OK
cloneObject([1, 2, 3]); // OK (arrays are objects)
cloneObject(42); // Error: number is not an object
cloneObject("hello"); // Error: string is a primitive

// Record<string, unknown> constraint: guarantees index signature
function getKeys<T extends Record<string, unknown>>(obj: T): Array<keyof T> {
return Object.keys(obj) as Array<keyof T>;
}

const keys = getKeys({ a: 1, b: 2, c: 3 }); // ("a" | "b" | "c")[]

// Multiple constraints chained with &
interface HasId {
id: number;
}

interface HasName {
name: string;
}

function processItem<T extends HasId & HasName & { createdAt: Date }>(
item: T
): string {
return `[${item.id}] ${item.name} (${item.createdAt.toISOString()})`;
}

// Cross-parameter constraint: one type parameter constrains another
function copyFrom<Target extends Source, Source>(
source: Source,
factory: () => Target
): Target {
return Object.assign(factory(), source);
}

Combining Record with Constraints

// Processing objects with dynamic keys
function transformRecord<
K extends string,
V,
R
>(
record: Record<K, V>,
transform: (value: V, key: K) => R
): Record<K, R> {
const result = {} as Record<K, R>;
for (const key in record) {
if (Object.prototype.hasOwnProperty.call(record, key)) {
result[key] = transform(record[key], key);
}
}
return result;
}

const prices = { apple: 1.5, banana: 0.5, cherry: 3.0 };
const discounted = transformRecord(prices, (price) => price * 0.9);
// { apple: number, banana: number, cherry: number }

extends vs implements: The Difference

In TypeScript generics you can only use extends for constraints. implements is reserved for class declarations.

// Correct constraint syntax
interface Printable {
print(): void;
}

// implements is used only in class declarations
class Document implements Printable {
print(): void {
console.log("Printing document");
}
}

// Generic constraints always use extends (even for interfaces)
function printAll<T extends Printable>(items: T[]): void {
items.forEach((item) => item.print());
}

// T extends Printable means "T is any type that structurally satisfies Printable"
// It doesn't have to be a class instance — structural compatibility is enough
const obj = {
print() { console.log("Anonymous object printing"); }
};

printAll([obj]); // OK — structurally satisfies Printable
printAll([new Document()]); // OK — Document implements Printable
// Abstract class constraint
abstract class Animal {
abstract speak(): string;
move(): void {
console.log(`Moving while ${this.speak()}`);
}
}

// Abstract classes are also constrained with extends
function makeNoise<T extends Animal>(animal: T): string {
return animal.speak();
}

class Dog extends Animal {
speak(): string { return "Woof"; }
}

class Cat extends Animal {
speak(): string { return "Meow"; }
}

makeNoise(new Dog()); // "Woof"
makeNoise(new Cat()); // "Meow"

Practical Examples

getProperty — Type-Safe Property Access

// Safe access for nested objects (one level)
function getProperty<T extends object, K extends keyof T>(
obj: T,
key: K
): T[K] {
return obj[key];
}

// Safe access for nested objects (two levels — includes type inference)
function getNestedProperty<
T extends object,
K1 extends keyof T,
K2 extends keyof T[K1]
>(obj: T, key1: K1, key2: K2): T[K1][K2] {
return (obj[key1] as T[K1])[key2];
}

interface Config {
database: {
host: string;
port: number;
credentials: {
username: string;
password: string;
};
};
server: {
host: string;
port: number;
};
}

const config: Config = {
database: {
host: "localhost",
port: 5432,
credentials: {
username: "admin",
password: "secret",
},
},
server: {
host: "0.0.0.0",
port: 8080,
},
};

const dbHost = getNestedProperty(config, "database", "host"); // string
const serverPort = getNestedProperty(config, "server", "port"); // number

Sortable Array

// Guarantee sortability with a Comparable constraint
interface Comparable<T> {
compareTo(other: T): number;
}

function sortArray<T extends Comparable<T>>(arr: T[]): T[] {
return [...arr].sort((a, b) => a.compareTo(b));
}

class Temperature implements Comparable<Temperature> {
constructor(private celsius: number) {}

compareTo(other: Temperature): number {
return this.celsius - other.celsius;
}

toString(): string {
return `${this.celsius}°C`;
}
}

const temperatures = [
new Temperature(30),
new Temperature(15),
new Temperature(25),
new Temperature(5),
];

const sorted = sortArray(temperatures);
sorted.forEach((t) => console.log(t.toString()));
// 5°C, 15°C, 25°C, 30°C

// Sorting primitives — type-safe with generics + extends
function sortPrimitive<T extends string | number | bigint>(arr: T[]): T[] {
return [...arr].sort((a, b) => {
if (a < b) return -1;
if (a > b) return 1;
return 0;
});
}

sortPrimitive([3, 1, 4, 1, 5, 9]); // number[]
sortPrimitive(["banana", "apple", "cherry"]); // string[]
sortPrimitive([{}, {}]); // Error: object is not string | number | bigint

Deep Object Access (Combined with Optional Chaining)

// Type for safe deep path access
type DeepGet<T, Path extends string> =
Path extends `${infer Key}.${infer Rest}`
? Key extends keyof T
? DeepGet<T[Key], Rest>
: never
: Path extends keyof T
? T[Path]
: never;

// Practical alternative: combining runtime safety with type safety
function safeGet<T extends object>(
obj: T,
path: string,
defaultValue?: unknown
): unknown {
const keys = path.split(".");
let current: unknown = obj;

for (const key of keys) {
if (current === null || current === undefined) {
return defaultValue;
}
if (typeof current !== "object") {
return defaultValue;
}
current = (current as Record<string, unknown>)[key];
}

return current ?? defaultValue;
}

const deepObj = {
user: {
profile: {
name: "Alice",
address: {
city: "New York",
},
},
},
};

console.log(safeGet(deepObj, "user.profile.name")); // "Alice"
console.log(safeGet(deepObj, "user.profile.address.city")); // "New York"
console.log(safeGet(deepObj, "user.missing.key", "N/A")); // "N/A"

Pro Tips

Balancing Constraint Strictness and Reusability

// Too strict — low reusability
function processUser<T extends {
id: number;
name: string;
email: string;
age: number;
role: "admin" | "user";
}>(user: T): string {
return `${user.name} (${user.email})`;
}
// Problem: only name and email are needed, but far too many properties required

// Appropriate constraint — require only what you need
function processUser2<T extends { name: string; email: string }>(user: T): string {
return `${user.name} (${user.email})`;
}
// Any object with name and email works

// Too loose — loss of type safety
function processAny<T>(item: T): string {
return String(item); // No information about structure — can't do much beyond toString
}

// Balanced: require only the properties actually used
function formatEntity<T extends { id: number | string; toString(): string }>(
entity: T
): string {
return `[${entity.id}] ${entity.toString()}`;
}

Precise Type Inference with Conditional Types and Constraints

// Array → element type, otherwise pass through
type ElementOf<T> = T extends (infer E)[] ? E : T;

function first<T>(collection: T): ElementOf<T> {
if (Array.isArray(collection)) {
return collection[0] as ElementOf<T>;
}
return collection as ElementOf<T>;
}

const n = first([1, 2, 3]); // number
const s = first("hello"); // string (not an array)

// Filter keys by value type in an object
type KeysWithValueType<T, V> = {
[K in keyof T]: T[K] extends V ? K : never
}[keyof T];

interface Mixed {
id: number;
name: string;
active: boolean;
score: number;
label: string;
}

type StringKeys = KeysWithValueType<Mixed, string>; // "name" | "label"
type NumberKeys = KeysWithValueType<Mixed, number>; // "id" | "score"

function getStringProp<T extends object>(
obj: T,
key: KeysWithValueType<T, string>
): string {
return obj[key] as string;
}

const mixed: Mixed = { id: 1, name: "Alice", active: true, score: 95, label: "A" };
getStringProp(mixed, "name"); // OK
getStringProp(mixed, "label"); // OK
getStringProp(mixed, "id"); // Error: id is a number

Minimizing Type Parameter Propagation

// Bad pattern: unnecessarily many type parameters
function badMerge<
T extends object,
U extends object,
K1 extends keyof T,
K2 extends keyof U
>(obj1: T, obj2: U, key1: K1, key2: K2): T[K1] & U[K2] {
return { ...(obj1[key1] as object), ...(obj2[key2] as object) } as T[K1] & U[K2];
}

// Good pattern: only the constraints that are necessary
function goodMerge<T extends object, U extends object>(
obj1: T,
obj2: U
): T & U {
return { ...obj1, ...obj2 };
}

Summary Table

Constraint PatternSyntaxMeaning
Basic extendsT extends UT is a subtype of U
Property constraintT extends { name: string }T has a name property
keyof constraintK extends keyof TK is one of T's keys
object constraintT extends objectT is not a primitive
Compound constraintT extends A & BT satisfies both A and B
Cross-parameter constraintU extends TU is a subtype of T
Record constraintT extends Record<string, unknown>T has an index signature
Array constraintT extends unknown[]T is an array type

Up Next...

Section 5.3 covers Built-in Utility Types. You will explore the utility types TypeScript ships with — Partial, Required, Readonly, Pick, Omit, Record, and more — implement each one yourself to understand how they work internally, and learn how to combine them for complex DTO transformation patterns and a custom DeepPartial.

Advertisement