Skip to main content

Generic constraints

In this chapter, you learn how to add constraints to generics, plus generic classes and interfaces.

What you learn in this chapter

  • Type constraints with extends
  • Multiple type parameters
  • Generic classes
  • Generic interfaces
  • Default type parameters
info

By using constraints, you can keep the flexibility of generics while accepting only types that have the required properties or methods.

Type constraints with extends

By adding a constraint to a type parameter, you can accept only types that have specific properties or methods.

The problem without constraints

// No constraint (accepts all types)
function logLength<T>(value: T): void {
// Error: T does not necessarily have a length property
// console.log(value.length);
}

// T can be any type, so the existence of a length property is not guaranteed

Adding a constraint with extends

// Define a type that has a length property
interface HasLength {
length: number;
}

// Add a constraint with T extends HasLength
function logLength<T extends HasLength>(value: T): void {
// OK: T is guaranteed to have length
console.log(value.length);
}

// Usage
logLength("Hello"); // 5 (string has length)
logLength([1, 2, 3]); // 3 (arrays have length)
logLength({ length: 10 }); // 10 (an object that has a length property)

// Error: number does not have length
// logLength(42);

Constraints to object types

// Constrain to an object type
function getProperty<T extends object, K extends keyof T>(
obj: T,
key: K
): T[K] {
return obj[key];
}

const user = {
name: "Yamada Taro",
age: 30,
email: "yamada@example.com"
};

const name = getProperty(user, "name"); // string type
const age = getProperty(user, "age"); // number type

// Error: a key that does not exist
// getProperty(user, "invalid");

// Error: a primitive type is not allowed
// getProperty(123, "toString");

Multiple constraints

You can combine multiple constraints with an intersection type (&).

// A constraint that satisfies multiple interfaces
interface Printable {
print(): void;
}

interface Loggable {
log(): void;
}

// Must satisfy both Printable and Loggable
function execute<T extends Printable & Loggable>(obj: T): void {
obj.print();
obj.log();
}

class Document implements Printable, Loggable {
print(): void {
console.log("Printing document...");
}

log(): void {
console.log("Logging document...");
}
}

const doc = new Document();
execute(doc); // OK

// Error: does not implement Loggable
// execute({ print: () => console.log("print") });

Multiple type parameters

Basic usage

// Two type parameters
function pair<T, U>(first: T, second: U): [T, U] {
return [first, second];
}

const p1 = pair<number, string>(1, "one"); // [number, string]
const p2 = pair<string, boolean>("flag", true); // [string, boolean]
const p3 = pair(42, "answer"); // [number, string] by type inference

Constraints between type parameters

// K is constrained to the keys of T
function getKey<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}

const product = {
id: 1,
name: "Laptop",
price: 100000
};

const productName = getKey(product, "name"); // string
const productPrice = getKey(product, "price"); // number

// Error: "invalid" is not a key of product
// getKey(product, "invalid");

A practical example: transforming an object

// Transform the values while preserving the keys
function mapObject<K extends string, V, R>(
obj: Record<K, V>,
mapper: (value: V) => R
): Record<K, R> {
const result = {} as Record<K, R>;

for (const key in obj) {
result[key] = mapper(obj[key]);
}

return result;
}

const numbers = { a: 1, b: 2, c: 3 };
const doubled = mapObject(numbers, (n) => n * 2);
// { a: 2, b: 4, c: 6 }

const strings = mapObject(numbers, (n) => `Value: ${n}`);
// { a: "Value: 1", b: "Value: 2", c: "Value: 3" }

Generic classes

You can use a type parameter across an entire class.

A basic generic class

// Defining a generic class
class Box<T> {
private value: T;

constructor(value: T) {
this.value = value;
}

getValue(): T {
return this.value;
}

setValue(value: T): void {
this.value = value;
}
}

// Usage
const numberBox = new Box<number>(42);
console.log(numberBox.getValue()); // 42
numberBox.setValue(100);
// numberBox.setValue("string"); // Error: a string cannot be assigned

const stringBox = new Box<string>("Hello");
console.log(stringBox.getValue()); // "Hello"
stringBox.setValue("World");

A practical example: a data store

// A simple data store class
class DataStore<T extends { id: number }> {
private data: T[] = [];

add(item: T): void {
this.data.push(item);
}

findById(id: number): T | undefined {
return this.data.find(item => item.id === id);
}

getAll(): T[] {
return [...this.data]; // return a copy
}

remove(id: number): boolean {
const index = this.data.findIndex(item => item.id === id);
if (index !== -1) {
this.data.splice(index, 1);
return true;
}
return false;
}

update(id: number, updates: Partial<T>): boolean {
const item = this.findById(id);
if (item) {
Object.assign(item, updates);
return true;
}
return false;
}
}

// Note: Partial<T> is a utility type that "makes all properties optional".
// It is covered in detail in the next chapter (Chapter 15) along with Partial / Required / Pick / Omit.

// Usage
interface User {
id: number;
name: string;
email: string;
}

const userStore = new DataStore<User>();

userStore.add({ id: 1, name: "Yamada Taro", email: "yamada@example.com" });
userStore.add({ id: 2, name: "Tanaka Hanako", email: "tanaka@example.com" });

console.log(userStore.findById(1));
// { id: 1, name: "Yamada Taro", email: "yamada@example.com" }

userStore.update(1, { name: "Yamada Jiro" });
console.log(userStore.getAll());

// It can also be used with another entity
interface Product {
id: number;
name: string;
price: number;
}

const productStore = new DataStore<Product>();
productStore.add({ id: 1, name: "Laptop", price: 100000 });

Inheriting a generic class

// The base class
class Container<T> {
constructor(protected value: T) {}

getValue(): T {
return this.value;
}
}

// Inherit with a fixed type
class NumberContainer extends Container<number> {
double(): number {
return this.value * 2;
}
}

// Inherit while keeping it generic
class PrintableContainer<T> extends Container<T> {
print(): void {
console.log(this.value);
}
}

const numContainer = new NumberContainer(10);
console.log(numContainer.double()); // 20

const strContainer = new PrintableContainer<string>("Hello");
strContainer.print(); // "Hello"

Generic interfaces

A basic generic interface

// A generic interface
interface Result<T> {
success: boolean;
data?: T;
error?: string;
}

// Usage
const successResult: Result<number> = {
success: true,
data: 42
};

const errorResult: Result<string> = {
success: false,
error: "Data not found"
};

// Using it as a function's return value
interface User {
id: number;
name: string;
email: string;
}

function fetchUser(id: number): Result<User> {
if (id > 0) {
return {
success: true,
data: { id, name: "Yamada Taro", email: "yamada@example.com" }
};
}
return {
success: false,
error: "Invalid ID"
};
}

A result type for asynchronous processing

// An async result type
interface AsyncResult<T, E = Error> {
data?: T;
error?: E;
loading: boolean;
}

// Usage
const loadingState: AsyncResult<User> = {
loading: true
};

const successState: AsyncResult<User> = {
data: { id: 1, name: "Yamada Taro", email: "yamada@example.com" },
loading: false
};

const errorState: AsyncResult<User, string> = {
error: "Network error",
loading: false
};

A function-type generic interface

// A function-type generic interface
interface Transformer<T, R> {
(value: T): R;
}

// Usage
const numberToString: Transformer<number, string> = (n) => n.toString();
const stringToNumber: Transformer<string, number> = (s) => parseInt(s);

console.log(numberToString(42)); // "42"
console.log(stringToNumber("123")); // 123

// Use it to transform an array
function mapArray<T, R>(arr: T[], transformer: Transformer<T, R>): R[] {
return arr.map(transformer);
}

const numbers = [1, 2, 3];
const strings = mapArray(numbers, numberToString); // ["1", "2", "3"]

Default type parameters

You can set a default value for a type parameter.

Basic default type parameters

// A default type parameter
interface Response<T = unknown> {
status: number;
data: T;
}

// When the type parameter is not specified, it becomes unknown
const response1: Response = {
status: 200,
data: "Anything is OK" // unknown type
};

// Specify the type parameter
const response2: Response<User> = {
status: 200,
data: { id: 1, name: "Yamada Taro", email: "yamada@example.com" }
};

Combining constraints and defaults

// Combining a constraint and a default
interface Config<T extends object = { [key: string]: unknown }> {
settings: T;
version: string;
}

// Use the default type
const config1: Config = {
settings: { theme: "dark", language: "ja" },
version: "1.0.0"
};

// Specify a concrete type
interface AppSettings {
theme: "light" | "dark";
language: string;
notifications: boolean;
}

const config2: Config<AppSettings> = {
settings: {
theme: "dark",
language: "ja",
notifications: true
},
version: "1.0.0"
};

Multiple default type parameters

// Multiple default type parameters
interface ApiResponse<T = unknown, E = Error> {
data?: T;
error?: E;
statusCode: number;
}

// All defaults
const res1: ApiResponse = {
data: { message: "OK" },
statusCode: 200
};

// Specify only the first
const res2: ApiResponse<User> = {
data: { id: 1, name: "Taro", email: "test@example.com" },
statusCode: 200
};

// Specify both
const res3: ApiResponse<User, string> = {
error: "Not Found",
statusCode: 404
};

A practical example: a type-safe event emitter

// Defining the event types
interface EventMap {
click: { x: number; y: number };
change: { value: string };
submit: { data: Record<string, unknown> };
}

// A type-safe event emitter
class EventEmitter<T extends Record<string, unknown>> {
private handlers: Map<keyof T, ((data: unknown) => void)[]> = new Map();

on<K extends keyof T>(event: K, handler: (data: T[K]) => void): void {
if (!this.handlers.has(event)) {
this.handlers.set(event, []);
}
this.handlers.get(event)!.push(handler as (data: unknown) => void);
}

emit<K extends keyof T>(event: K, data: T[K]): void {
const eventHandlers = this.handlers.get(event);
if (eventHandlers) {
eventHandlers.forEach(handler => handler(data));
}
}

off<K extends keyof T>(event: K, handler: (data: T[K]) => void): void {
const eventHandlers = this.handlers.get(event);
if (eventHandlers) {
const index = eventHandlers.indexOf(handler as (data: unknown) => void);
if (index !== -1) {
eventHandlers.splice(index, 1);
}
}
}
}

// Usage
const emitter = new EventEmitter<EventMap>();

// Type-safe event handlers
emitter.on("click", (data) => {
console.log(`Clicked at (${data.x}, ${data.y})`);
});

emitter.on("change", (data) => {
console.log(`Value changed to: ${data.value}`);
});

// Type-safe emit
emitter.emit("click", { x: 100, y: 200 });
emitter.emit("change", { value: "new value" });

// Error: the type does not match
// emitter.emit("click", { value: "string" });

Try it: build a data store class ★★★

Implement a generic data store class that meets the following requirements.

Requirements:

  1. Has the T extends { id: number } constraint
  2. add(item: T): add an item
  3. findById(id: number): find an item by ID
  4. update(id: number, updates: Partial<T>): partially update an item
  5. delete(id: number): delete an item
  6. filter(predicate: (item: T) => boolean): get items that match a condition
// Implement the following class
// class Store<T extends { id: number }> {
// ...
// }
Hint
  1. Hold the internal data with private items: T[] = []
  2. findById uses the find method
  3. update does a partial update with Object.assign
  4. delete uses findIndex and splice
  5. filter uses the array's filter method
Answer and explanation
class Store<T extends { id: number }> {
private items: T[] = [];

// Add an item
add(item: T): void {
// Check that the same ID does not already exist
if (this.findById(item.id)) {
throw new Error(`ID ${item.id} already exists`);
}
this.items.push(item);
}

// Find an item by ID
findById(id: number): T | undefined {
return this.items.find(item => item.id === id);
}

// Partially update an item
update(id: number, updates: Partial<T>): boolean {
const item = this.findById(id);
if (item) {
// Make id non-updatable
const { id: _, ...safeUpdates } = updates;
Object.assign(item, safeUpdates);
return true;
}
return false;
}

// Delete an item
delete(id: number): boolean {
const index = this.items.findIndex(item => item.id === id);
if (index !== -1) {
this.items.splice(index, 1);
return true;
}
return false;
}

// Get items that match a condition
filter(predicate: (item: T) => boolean): T[] {
return this.items.filter(predicate);
}

// Get all items
getAll(): T[] {
return [...this.items]; // return a copy
}

// Get the item count
count(): number {
return this.items.length;
}
}

// Test
interface Task {
id: number;
title: string;
completed: boolean;
priority: "low" | "medium" | "high";
}

const taskStore = new Store<Task>();

// Add
taskStore.add({ id: 1, title: "Learn TypeScript", completed: false, priority: "high" });
taskStore.add({ id: 2, title: "Write a report", completed: false, priority: "medium" });
taskStore.add({ id: 3, title: "Reply to email", completed: true, priority: "low" });

// Find
console.log(taskStore.findById(1));
// { id: 1, title: "Learn TypeScript", completed: false, priority: "high" }

// Update
taskStore.update(1, { completed: true });
console.log(taskStore.findById(1));
// { id: 1, title: "Learn TypeScript", completed: true, priority: "high" }

// Filter
const incompleteTasks = taskStore.filter(task => !task.completed);
console.log(incompleteTasks);
// [{ id: 2, title: "Write a report", ... }]

const highPriorityTasks = taskStore.filter(task => task.priority === "high");
console.log(highPriorityTasks);
// [{ id: 1, title: "Learn TypeScript", ... }]

// Delete
taskStore.delete(3);
console.log(taskStore.count()); // 2

Explanation:

  • T extends { id: number } ensures that only types with an id property are accepted
  • Using Partial<T> makes it possible to update only some properties
  • Returning a copy in getAll() prevents direct changes from the outside
  • Thanks to generics, you can use it type-safely with any entity type (User, Product, Task, etc.)

Summary

What you learned in this chapter:

  • Constraints with extends: add conditions to a type parameter
  • Multiple type parameters: handle multiple types with <T, U>
  • Constraints between type parameters: constrain keys with K extends keyof T
  • Generic classes: use a type parameter across an entire class
  • Generic interfaces: use a type parameter in an interface
  • Default type parameters: set a default value with <T = unknown>

In the next chapter, you will learn about TypeScript's built-in utility types.

Common pitfalls for beginners

warning

Common mistakes

❌ Making constraints too strict

// ❌ A constraint that is stricter than necessary
interface FullUser {
id: number;
name: string;
email: string;
age: number;
}

function getUserId<T extends FullUser>(user: T): number {
return user.id;
}

// The constraint is too strict, so an object without email or age cannot be passed
const simpleUser = { id: 1, name: "Taro" };
// getUserId(simpleUser); // Error

// ✅ The minimum necessary constraint
function getUserIdSafe<T extends { id: number }>(user: T): number {
return user.id;
}

getUserIdSafe(simpleUser); // OK

Cause: A constraint stricter than necessary hurts reusability. Solution: Include only the properties the function actually uses in the constraint.

❌ Using a type parameter in a static member of a generic class

// ❌ A type parameter cannot be used in a static member
class Container<T> {
static defaultValue: T; // Error: a static member cannot reference a type parameter

constructor(public value: T) {}
}

// ✅ Use a concrete type for a static member
class ContainerFixed<T> {
static defaultValue: unknown = null; // use a concrete type

constructor(public value: T) {}
}

Cause: A static member belongs to the class itself and is accessed before an instance is created, so the type parameter is not determined. Solution: Use a concrete type for a static member, or define a separate type parameter if you make it a static method.

❌ Getting the order of default type parameters wrong

// ❌ A non-default parameter comes after a default one
// interface BadResult<T = unknown, E> { ... } // Error

// ✅ Put the non-default one first
interface GoodResult<E, T = unknown> {
data?: T;
error?: E;
}

// Or set defaults for both
interface BetterResult<T = unknown, E = Error> {
data?: T;
error?: E;
}

Cause: A type parameter without a default value cannot be placed after one with a default value. Solution: Place required type parameters first and ones with defaults afterward.

❌ Getting the type wrong in the extends clause

// ❌ Writing a value after extends
// function log<T extends "string">(value: T): void { } // "string" is a literal type

// If you mean a constraint to the string type
function logString<T extends string>(value: T): void {
console.log(value);
}

logString("hello"); // OK: T is the "hello" type
// logString(123); // Error: number cannot be assigned to string

Cause: extends "string" is a constraint to the literal type "string", not to the whole string type. Solution: Use the type name (starting with a lowercase letter), like extends string.

❌ A contradiction arises in an intersection constraint

interface A {
prop: string;
}

interface B {
prop: number;
}

// ❌ An intersection of contradicting types
type Impossible = A & B;
// prop is string & number = never

function process<T extends A & B>(value: T): void {
// value.prop is the never type (effectively unusable)
}

// ✅ An intersection of non-contradicting types
interface C {
name: string;
}

interface D {
age: number;
}

type Possible = C & D; // { name: string; age: number; }

Cause: When same-name properties have different types, an intersection becomes never. Solution: Before an intersection, check the types' compatibility and make sure there is no contradiction.


NoInfer<T> (TS 5.4+)

When the same type parameter is used in multiple places, there are situations where you want to exclude a specific argument from the basis of type inference. Added in TypeScript 5.4, NoInfer<T> is a utility for exactly this purpose.

The problem: all arguments are used for inference

// A function that receives a list of colors and a default color
function createStreetLight<C extends string>(colors: C[], defaultColor: C) {
return { colors, defaultColor }
}

// If you pass 'blue' as defaultColor...
const light = createStreetLight(['red', 'yellow', 'green'], 'blue')
// C widens to 'red' | 'yellow' | 'green' | 'blue' (unintended)

The intent is "make the default a color that is included in colors", but because defaultColor is also used for inference, 'blue' widens into the union.

The solution: suppress inference with NoInfer

// defaultColor is excluded from inference
function createStreetLightV2<C extends string>(
colors: C[],
defaultColor: NoInfer<C>,
) {
return { colors, defaultColor }
}

createStreetLightV2(['red', 'yellow', 'green'], 'blue')
// ❌ Error: 'blue' is not assignable to 'red' | 'yellow' | 'green'

createStreetLightV2(['red', 'yellow', 'green'], 'red')
// ✅ OK

An argument with NoInfer<C> behaves so that it "is checked against type C" but "does not participate in inferring type C". This prevents unintended values from slipping into a library API's arguments.

info

NoInfer is especially useful in library API design. It clearly tells the reader that "this argument is declared as C for the purpose of type checking, not type inference".