Mastering TypeScript Generics: From Basics to Advanced Patterns

Mastering TypeScript Generics: From Basics to Advanced Patterns

TypeScript
Generics
Type Safety
Design Patterns
2025-04-25

Table of Contents

Introduction

TypeScript has revolutionized JavaScript development by introducing static types, offering better tooling, and catching errors before runtime. Among its most powerful features are generics, which allow you to create reusable components that work with a variety of types while maintaining type safety.

Despite their power, generics can be intimidating for developers new to TypeScript or even those with experience who haven't fully explored their capabilities. In this comprehensive guide, we'll journey from the basics to advanced patterns, illustrating how generics can make your code more flexible, reusable, and type-safe.

Whether you're just starting with TypeScript or looking to level up your type skills, this post will provide practical examples and patterns you can immediately apply to your projects.

What Are Generics?

At their core, generics allow you to write code that works with multiple types while preserving type information throughout your program. Think of them as type variables or placeholders that get replaced with actual types when the code is used.

Without generics, you'd have to choose between:

  • Using specific types, limiting reusability
  • Using any, sacrificing type safety
  • Duplicating code for different types

Generics give you the best of all worlds: reusable code that preserves type information. Let's see the difference with a simple example:

Without Generics (Using any)
// Without generics (using any) function getFirstElement(arr: any[]): any { return arr[0]; } // We lose type information const firstNumber = getFirstElement([1, 2, 3]); // type: any const firstString = getFirstElement(["a", "b", "c"]); // type: any // This compiles but would cause runtime errors! firstNumber.toFixed(2); // No type checking
With Generics
// With generics function getFirstElement<T>(arr: T[]): T | undefined { return arr[0]; } // Type information is preserved const firstNumber = getFirstElement([1, 2, 3]); // type: number | undefined const firstString = getFirstElement(["a", "b", "c"]); // type: string | undefined // Type checking works! firstNumber?.toFixed(2); // Safe access with optional chaining // firstString?.toFixed(2); // Error: Property 'toFixed' does not exist on type 'string'

In the generic version, T is a type parameter that gets replaced with the actual type when the function is called. This allows TypeScript to track types throughout your code, providing better type checking and editor support.

Generic Syntax and Basic Patterns

Let's explore the basic syntax of generics and common patterns you'll encounter in TypeScript code.

Generic Functions

Generic functions use type parameters, typically denoted by a single capital letter (conventionally starting with T):

function identity<T>(arg: T): T { return arg; } // Explicit type parameter const result1 = identity<string>("hello"); // type: string // Type inference (preferred when possible) const result2 = identity(42); // type: number

Generic Interfaces

You can create generic interfaces to define reusable shapes:

interface Box<T> { value: T; } const numberBox: Box<number> = { value: 42 }; const stringBox: Box<string> = { value: "hello" }; // Generic interface with multiple type parameters interface Pair<K, V> { key: K; value: V; } const keyValuePair: Pair<string, number> = { key: "age", value: 30 };

Generic Classes

Classes can also use generics to create reusable components:

class Queue<T> { private items: T[] = []; enqueue(item: T): void { this.items.push(item); } dequeue(): T | undefined { return this.items.shift(); } peek(): T | undefined { return this.items[0]; } } const numberQueue = new Queue<number>(); numberQueue.enqueue(1); numberQueue.enqueue(2); const firstItem = numberQueue.dequeue(); // type: number | undefined const stringQueue = new Queue<string>(); stringQueue.enqueue("hello"); stringQueue.enqueue("world"); const greeting = stringQueue.peek(); // type: string | undefined

Generic Type Aliases

Type aliases can also be generic, allowing you to create reusable type definitions:

type Result<T, E = Error> = { success: true; data: T; } | { success: false; error: E; }; function fetchData(): Result<string> { try { // Simulating API call return { success: true, data: "Successfully fetched data" }; } catch (e) { return { success: false, error: e as Error }; } } const result = fetchData(); if (result.success) { console.log(result.data); // type: string } else { console.error(result.error); // type: Error }

Generic Constraints

Sometimes you want to limit what types can be used with your generics. This is where constraints come in, using the extends keyword:

// Basic constraint interface HasLength { length: number; } function getLength<T extends HasLength>(item: T): number { return item.length; } // Works for strings, arrays, and any type with a length property getLength("hello"); // 5 getLength([1, 2, 3]); // 3 getLength({ length: 10 }); // 10 // getLength(42); // Error: number doesn't have a length property

Constraints are particularly useful when you want to ensure certain properties or methods are available:

// Ensuring an object has specific properties function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] { return obj[key]; } const person = { name: "Alice", age: 30, city: "Wonderland" }; // TypeScript knows the return type based on the key const name = getProperty(person, "name"); // type: string const age = getProperty(person, "age"); // type: number // const height = getProperty(person, "height"); // Error: "height" is not a key of person

Using Multiple Constraints

You can combine multiple constraints using intersection types:

interface Printable { print(): void; } interface Loggable { log(): void; } // T must implement both Printable and Loggable function printAndLog<T extends Printable & Loggable>(item: T): void { item.print(); item.log(); } class Document implements Printable, Loggable { constructor(private content: string) {} print() { console.log(`Printing: ${this.content}`); } log() { console.log(`Logging: ${this.content}`); } } const doc = new Document("Hello World"); printAndLog(doc); // Works fine // Error: Object literal doesn't implement required interfaces // printAndLog({ // print: () => console.log("Printing") // // missing log method // });

Default Type Parameters

Like function parameters, type parameters can have defaults:

// Default type parameter interface ApiResponse<T = any> { data: T; status: number; message: string; } // No need to specify the type parameter const response1: ApiResponse = { data: "success", status: 200, message: "OK" }; // We can be explicit when needed const response2: ApiResponse<{ id: number; name: string }> = { data: { id: 1, name: "John" }, status: 200, message: "OK" };

Default type parameters are especially useful for configuration objects or options:

interface PaginationOptions<T = unknown> { data: T[]; page: number; perPage: number; total: number; } function createPagination<T>( data: T[], options: Partial<Omit<PaginationOptions<T>, 'data'>> = {} ): PaginationOptions<T> { return { data, page: options.page ?? 1, perPage: options.perPage ?? 10, total: options.total ?? data.length }; } const users = [{ id: 1, name: "Alice" }, { id: 2, name: "Bob" }]; const pagination = createPagination(users); // { // data: [{ id: 1, name: "Alice" }, { id: 2, name: "Bob" }], // page: 1, // perPage: 10, // total: 2 // }

Advanced Patterns with Generics

Mapped Types

Mapped types allow you to create new types based on existing ones by transforming each property in some way:

// Making all properties optional type Partial<T> = { [P in keyof T]?: T[P]; }; // Making all properties readonly type Readonly<T> = { readonly [P in keyof T]: T[P]; }; // Making all properties nullable type Nullable<T> = { [P in keyof T]: T[P] | null; }; interface User { id: number; name: string; email: string; } // All properties are optional type PartialUser = Partial<User>; // { id?: number; name?: string; email?: string; } // All properties are readonly type ReadonlyUser = Readonly<User>; // { readonly id: number; readonly name: string; readonly email: string; } // All properties can be null type NullableUser = Nullable<User>; // { id: number | null; name: string | null; email: string | null; }

Conditional Types

Conditional types allow you to select types based on a condition:

// Conditional type using the ternary operator syntax type NonNullable<T> = T extends null | undefined ? never : T; // Remove properties from T that are assignable to U type Omit<T, K extends keyof T> = { [P in keyof T as P extends K ? never : P]: T[P] }; // Extract types from a union that are assignable to U type Extract<T, U> = T extends U ? T : never; type StringOrNumber = string | number | boolean; type JustStrings = Extract<StringOrNumber, string>; // string // Use case: Extracting properties of a certain type type User = { id: number; name: string; isAdmin: boolean; createdAt: Date; }; // Extract all string properties from User type StringProps<T> = { [K in keyof T as T[K] extends string ? K : never]: T[K] }; type UserStringProps = StringProps<User>; // { name: string }

Recursive Types

Generics can be used to create self-referential or recursive types:

// Recursive type for a tree structure type TreeNode<T> = { value: T; children: TreeNode<T>[]; }; // JSON value recursive type type JSONValue = | string | number | boolean | null | JSONValue[] | { [key: string]: JSONValue }; // Deep partial type (recursively makes all properties optional) type DeepPartial<T> = T extends object ? { [K in keyof T]?: DeepPartial<T[K]> } : T; interface NestedObject { name: string; details: { age: number; address: { street: string; city: string; }; }; } // All properties are optional at every level type PartialNestedObject = DeepPartial<NestedObject>; const partial: PartialNestedObject = { name: "John", details: { // age is optional address: { // street is optional city: "New York" } } };

Real-World Examples

Type-Safe Event Emitter

Here's a type-safe event emitter using generics:

// Define event map type interface EventMap { [eventName: string]: any; } // Type-safe event emitter class TypedEventEmitter<Events extends EventMap> { private listeners: { [E in keyof Events]?: Array<(data: Events[E]) => void>; } = {}; // Add event listener on<E extends keyof Events>(event: E, listener: (data: Events[E]) => void): void { if (!this.listeners[event]) { this.listeners[event] = []; } this.listeners[event]?.push(listener); } // Remove event listener off<E extends keyof Events>(event: E, listener: (data: Events[E]) => void): void { const eventListeners = this.listeners[event]; if (eventListeners) { this.listeners[event] = eventListeners.filter(l => l !== listener); } } // Emit event emit<E extends keyof Events>(event: E, data: Events[E]): void { const eventListeners = this.listeners[event]; if (eventListeners) { eventListeners.forEach(listener => listener(data)); } } } // Usage interface MyEvents { userLoggedIn: { userId: string; timestamp: number }; dataLoaded: { items: string[] }; error: Error; } const emitter = new TypedEventEmitter<MyEvents>(); // Type-safe event names and data emitter.on("userLoggedIn", data => { console.log(`User ${data.userId} logged in at ${data.timestamp}`); }); emitter.on("dataLoaded", data => { console.log(`Loaded ${data.items.length} items`); }); emitter.emit("userLoggedIn", { userId: "user123", timestamp: Date.now() }); // Type error: wrong event data structure // emitter.emit("userLoggedIn", { userId: "user123" }); // Error: missing timestamp // Type error: event doesn't exist // emitter.emit("userRegistered", { userId: "user123" }); // Error: event not in MyEvents

Generic React Components

Generics are commonly used in React to create flexible, reusable components:

import React, { useState } from 'react'; // Generic list component interface ListProps<T> { items: T[]; renderItem: (item: T, index: number) => React.ReactNode; keyExtractor: (item: T, index: number) => string; } function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) { return ( <ul className="list"> {items.map((item, index) => ( <li key={keyExtractor(item, index)}> {renderItem(item, index)} </li> ))} </ul> ); } // Generic form input with value type interface InputProps<T> { value: T; onChange: (value: T) => void; label?: string; formatter?: (value: T) => string; parser?: (input: string) => T; } function Input<T>({ value, onChange, label, formatter = String, parser = (v: string) => v as unknown as T }: InputProps<T>) { const [inputValue, setInputValue] = useState(formatter(value)); const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { setInputValue(e.target.value); onChange(parser(e.target.value)); }; return ( <div className="input-group"> {label && <label>{label}</label>} <input type="text" value={inputValue} onChange={handleChange} /> </div> ); } // Usage in a component interface User { id: number; name: string; email: string; } function UserList() { const users: User[] = [ { id: 1, name: 'Alice', email: 'alice@example.com' }, { id: 2, name: 'Bob', email: 'bob@example.com' }, ]; return ( <div> <h2>Users</h2> <List<User> items={users} keyExtractor={(user) => user.id.toString()} renderItem={(user) => ( <div> <h3>{user.name}</h3> <p>{user.email}</p> </div> )} /> <Input<number> value={42} onChange={(value) => console.log(value)} label="Age" formatter={(value) => value.toString()} parser={(input) => parseInt(input, 10)} /> </div> ); }

Type-Safe API Client

Build a type-safe API client with generics:

// Define endpoint configurations interface ApiEndpoints { '/users': { get: { params: { limit?: number }; response: { users: { id: number; name: string }[] }; }; post: { body: { name: string; email: string }; response: { id: number; name: string; email: string }; }; }; '/users/:id': { get: { params: { id: number }; response: { id: number; name: string; email: string }; }; put: { params: { id: number }; body: { name?: string; email?: string }; response: { id: number; name: string; email: string }; }; delete: { params: { id: number }; response: { success: boolean }; }; }; } // Generic API client class ApiClient { constructor(private baseUrl: string) {} // GET request async get< Path extends keyof ApiEndpoints, Method extends keyof ApiEndpoints[Path] & 'get' >( path: Path, params: ApiEndpoints[Path][Method] extends { params: infer P } ? P : never ): Promise< ApiEndpoints[Path][Method] extends { response: infer R } ? R : unknown > { // Build URL with path and query parameters const url = new URL(this.baseUrl + this.buildPath(path, params)); // Add query parameters Object.entries(params).forEach(([key, value]) => { if (this.isQueryParam(path, key)) { url.searchParams.append(key, String(value)); } }); // Make the request const response = await fetch(url.toString()); if (!response.ok) { throw new Error(`API error: ${response.status}`); } return response.json(); } // POST request async post< Path extends keyof ApiEndpoints, Method extends keyof ApiEndpoints[Path] & 'post' >( path: Path, params: ApiEndpoints[Path][Method] extends { params: infer P } ? P : Record<string, never>, body: ApiEndpoints[Path][Method] extends { body: infer B } ? B : never ): Promise< ApiEndpoints[Path][Method] extends { response: infer R } ? R : unknown > { // Build URL with path parameters const url = new URL(this.baseUrl + this.buildPath(path, params)); // Make the request const response = await fetch(url.toString(), { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(body), }); if (!response.ok) { throw new Error(`API error: ${response.status}`); } return response.json(); } // Helper method to build path with parameters private buildPath(path: string, params: Record<string, any>): string { return path.replace(/:([a-zA-Z0-9_]+)/g, (_, param) => { const value = params[param]; if (value === undefined) { throw new Error(`Missing path parameter: ${param}`); } return String(value); }); } // Helper to check if a parameter is a query parameter private isQueryParam(path: string, param: string): boolean { return !path.includes(`:${param}`); } } // Usage const api = new ApiClient('https://api.example.com'); // Type-safe API calls async function fetchUsers() { // Types are inferred correctly const response = await api.get('/users', { limit: 10 }); console.log(response.users.length); // OK // console.log(response.otherProp); // Error: Property doesn't exist } async function createUser() { const user = await api.post('/users', {}, { name: 'John Doe', email: 'john@example.com' }); console.log(user.id, user.name, user.email); // All correctly typed } async function getUser() { const user = await api.get('/users/:id', { id: 1 }); console.log(user.id, user.name, user.email); // All correctly typed }

Tips and Best Practices

Here are some tips and best practices for using generics effectively:

  1. Use descriptive type parameter names: While T, U, V are conventional for simple cases, use descriptive names like TItem, TKey, TValue for clarity in complex scenarios.
  2. Apply constraints thoughtfully: Use constraints to ensure you have the properties or methods you need, but don't over-constrain and limit reusability.
  3. Prefer inference over explicit types: Let TypeScript infer types when possible rather than explicitly declaring them.
  4. Combine with utility types: TypeScript's built-in utility types like Partial, Pick, Omit, and Record work well with generics.
  5. Use conditional types for more complex logic: When you need to choose types based on other types, conditional types are powerful.
  6. Don't overuse: Not everything needs to be generic. Sometimes a simple union type or interface is clearer.
Example of Utility Types
interface User { id: number; name: string; email: string; role: 'admin' | 'user'; department: string; createdAt: Date; } // Pick specific properties type UserBasicInfo = Pick<User, 'id' | 'name' | 'email'>; // Omit specific properties type UserWithoutDates = Omit<User, 'createdAt'>; // Make specific properties required type PartialUserWithRequiredId = Partial<User> & Pick<User, 'id'>; // Record: Create a dictionary with keys of one type and values of another type UsersByRole = Record<User['role'], User[]>; // Combining generics with utility types function createSafeRecord<T extends object, K extends keyof T>( items: T[], getKey: (item: T) => K ): Record<string, T> { return items.reduce((acc, item) => { const key = getKey(item); acc[String(key)] = item; return acc; }, {} as Record<string, T>); } const users: User[] = [ { id: 1, name: 'Alice', email: 'alice@example.com', role: 'admin', department: 'IT', createdAt: new Date() }, { id: 2, name: 'Bob', email: 'bob@example.com', role: 'user', department: 'Marketing', createdAt: new Date() } ]; const usersById = createSafeRecord(users, user => user.id); const usersByName = createSafeRecord(users, user => user.name);

Conclusion

TypeScript's generics are a powerful feature that bring flexibility and type safety to your code. By creating reusable components that work with multiple types while maintaining type information, you can write cleaner, safer, and more maintainable code.

From basic functions and interfaces to advanced patterns like mapped types, conditional types, and recursive types, generics provide tools to tackle complex type relationships. They enable you to create type-safe APIs, event systems, state management, and more.

As with any powerful feature, the key is to use generics judiciously. Start with simple cases, and as you grow more comfortable, incorporate more advanced patterns where they provide clear benefits.

By mastering TypeScript generics, you'll add a valuable tool to your programming arsenal, one that will help you build more robust applications with fewer runtime errors and better developer experiences.

Further Reading

Additional resources to deepen your understanding of TypeScript generics:

Key Resources

Official TypeScript Documentation

The official guide to TypeScript's generics and type system.

TypeScript Deep Dive

Basarat Ali Syed's comprehensive guide to TypeScript.

Effective TypeScript

Dan Vanderkam's 62 ways to improve your TypeScript.

Advanced TypeScript Types Cheat Sheet

A reference for TypeScript's advanced type features.