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
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:
- Has the
T extends { id: number }constraint add(item: T): add an itemfindById(id: number): find an item by IDupdate(id: number, updates: Partial<T>): partially update an itemdelete(id: number): delete an itemfilter(predicate: (item: T) => boolean): get items that match a condition
// Implement the following class
// class Store<T extends { id: number }> {
// ...
// }
Hint
- Hold the internal data with
private items: T[] = [] findByIduses thefindmethodupdatedoes a partial update withObject.assigndeleteusesfindIndexandsplicefilteruses the array'sfiltermethod
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
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.
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".