TypeScript Generics: Writing Reusable Type-Safe Code

TypeScript Generics: Writing Reusable Type-Safe Code
Generics are one of TypeScript’s most powerful features, allowing you to write flexible, reusable code while maintaining type safety. Let’s explore how to use them effectively.
Basic Generic Functions
Create functions that work with any type:
// Without generics - limited and repetitive
function identityNumber(arg: number): number {
return arg;
}
function identityString(arg: string): string {
return arg;
}
// With generics - flexible and type-safe
function identity<T>(arg: T): T {
return arg;
}
// TypeScript infers the type
const num = identity(42); // type: number
const str = identity("hello"); // type: string
const obj = identity({ id: 1 }); // type: { id: number }
// Or explicitly specify the type
const explicit = identity<string>("world");Generic Interfaces
Define interfaces with generic types:
interface ApiResponse<T> {
data: T;
status: number;
message: string;
}
interface User {
id: number;
name: string;
email: string;
}
interface Product {
id: number;
title: string;
price: number;
}
// Use the generic interface
const userResponse: ApiResponse<User> = {
data: { id: 1, name: "Alice", email: "alice@example.com" },
status: 200,
message: "Success"
};
const productResponse: ApiResponse<Product[]> = {
data: [
{ id: 1, title: "Laptop", price: 999 },
{ id: 2, title: "Mouse", price: 25 }
],
status: 200,
message: "Success"
};Generic Classes
Create reusable class structures:
class DataStore<T> {
private data: T[] = [];
add(item: T): void {
this.data.push(item);
}
remove(item: T): void {
const index = this.data.indexOf(item);
if (index > -1) {
this.data.splice(index, 1);
}
}
find(predicate: (item: T) => boolean): T | undefined {
return this.data.find(predicate);
}
getAll(): T[] {
return [...this.data];
}
}
// Usage with different types
const numberStore = new DataStore<number>();
numberStore.add(1);
numberStore.add(2);
numberStore.add(3);
const userStore = new DataStore<User>();
userStore.add({ id: 1, name: "Bob", email: "bob@example.com" });
const user = userStore.find(u => u.id === 1);Generic Constraints
Restrict generics to specific types:
// Constrain to objects with a length property
interface HasLength {
length: number;
}
function logLength<T extends HasLength>(arg: T): T {
console.log(`Length: ${arg.length}`);
return arg;
}
logLength("hello"); // OK
logLength([1, 2, 3]); // OK
logLength({ length: 10 }); // OK
// logLength(123); // Error: number doesn't have length
// Constrain to specific object shape
interface Identifiable {
id: number;
}
function findById<T extends Identifiable>(items: T[], id: number): T | undefined {
return items.find(item => item.id === id);
}
const users: User[] = [
{ id: 1, name: "Alice", email: "alice@example.com" },
{ id: 2, name: "Bob", email: "bob@example.com" }
];
const user = findById(users, 1); // type: User | undefinedMultiple Type Parameters
Use multiple generic type parameters:
function merge<T, U>(obj1: T, obj2: U): T & U {
return { ...obj1, ...obj2 };
}
const person = { name: "Alice", age: 30 };
const employee = { company: "Tech Corp", salary: 80000 };
const fullProfile = merge(person, employee);
// type: { name: string; age: number; company: string; salary: number }
console.log(fullProfile.name); // OK
console.log(fullProfile.company); // OKGeneric Type Utilities
Create powerful type transformation utilities:
// Make all properties optional
type Partial<T> = {
[P in keyof T]?: T[P];
};
// Make all properties required
type Required<T> = {
[P in keyof T]-?: T[P];
};
// Make all properties readonly
type Readonly<T> = {
readonly [P in keyof T]: T[P];
};
// Pick specific properties
type Pick<T, K extends keyof T> = {
[P in K]: T[P];
};
// Usage
interface Todo {
title: string;
description: string;
completed: boolean;
}
type PartialTodo = Partial<Todo>;
// { title?: string; description?: string; completed?: boolean; }
type TodoPreview = Pick<Todo, "title" | "completed">;
// { title: string; completed: boolean; }Generic Promise Handling
Type-safe async operations:
async function fetchData<T>(url: string): Promise<T> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json() as T;
}
// Usage
interface Post {
id: number;
title: string;
body: string;
}
async function getPosts() {
try {
const posts = await fetchData<Post[]>('https://api.example.com/posts');
posts.forEach(post => {
console.log(post.title); // TypeScript knows post has title
});
} catch (error) {
console.error('Failed to fetch posts:', error);
}
}Advanced: Conditional Types
Create types that depend on conditions:
type IsString<T> = T extends string ? true : false;
type A = IsString<string>; // true
type B = IsString<number>; // false
// Practical example: Extract return type
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
function getUser() {
return { id: 1, name: "Alice" };
}
type UserReturn = ReturnType<typeof getUser>;
// type: { id: number; name: string }Generic Default Parameters
Provide default types for generics:
interface Request<T = any> {
data: T;
timestamp: number;
}
// Use with default
const req1: Request = {
data: "anything",
timestamp: Date.now()
};
// Use with specific type
const req2: Request<User> = {
data: { id: 1, name: "Alice", email: "alice@example.com" },
timestamp: Date.now()
};Best Practices
// 1. Use descriptive type parameter names for clarity
// Bad
function process<T, U, V>(a: T, b: U): V { }
// Good
function mapUserToDTO<TUser, TDTO>(user: TUser): TDTO { }
// 2. Constrain generics appropriately
function sortItems<T extends { createdAt: Date }>(items: T[]): T[] {
return items.sort((a, b) =>
a.createdAt.getTime() - b.createdAt.getTime()
);
}
// 3. Don't over-use generics
// Sometimes a specific type is better than unnecessary generalityConclusion
TypeScript generics enable you to write flexible, reusable code without sacrificing type safety. They’re essential for building scalable applications and libraries. Master generics to take your TypeScript skills to the next level.