TypeScript Generics: Complete Guide with Examples

Generics solve a fundamental problem in typed programming: how do you write reusable code that works with multiple types without losing type safety? Without generics, you're forced to choose between...

Key Insights

  • Generics eliminate code duplication while maintaining type safety, allowing you to write reusable components that work with multiple types without sacrificing TypeScript’s compile-time guarantees.
  • Constraints using extends and keyof transform generics from simple type placeholders into powerful type-safe abstractions that enforce business logic at the type level.
  • The real power of generics emerges in complex patterns like conditional types and mapped types, enabling you to build sophisticated type utilities that would be impossible with static typing alone.

Introduction to Generics

Generics solve a fundamental problem in typed programming: how do you write reusable code that works with multiple types without losing type safety? Without generics, you’re forced to choose between duplication or type erasure using any.

Consider this common scenario without generics:

function getFirstString(arr: string[]): string {
  return arr[0];
}

function getFirstNumber(arr: number[]): number {
  return arr[0];
}

// Or worse, losing all type safety:
function getFirst(arr: any[]): any {
  return arr[0];
}

With generics, you get type safety and reusability:

function getFirst<T>(arr: T[]): T {
  return arr[0];
}

const firstNumber = getFirst([1, 2, 3]); // Type: number
const firstString = getFirst(['a', 'b']); // Type: string

The <T> syntax declares a type parameter that acts as a placeholder. TypeScript infers the concrete type from usage, maintaining full type safety.

Generic Functions and Type Parameters

Generic functions are the foundation. The syntax places type parameters in angle brackets before the function parameters:

function identity<T>(value: T): T {
  return value;
}

// TypeScript infers T as number
const num = identity(42);

// Explicit type argument
const str = identity<string>('hello');

Multiple type parameters enable more complex relationships:

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

const result = pair('age', 30); // Type: [string, number]

Constraints with extends restrict what types can be used. This is where generics become powerful:

interface HasLength {
  length: number;
}

function logLength<T extends HasLength>(item: T): void {
  console.log(item.length);
}

logLength('hello'); // OK
logLength([1, 2, 3]); // OK
logLength({ length: 10 }); // OK
logLength(42); // Error: number doesn't have length

Here’s a practical example with array filtering:

function filterByProperty<T, K extends keyof T>(
  items: T[],
  key: K,
  value: T[K]
): T[] {
  return items.filter(item => item[key] === value);
}

interface User {
  id: number;
  name: string;
  active: boolean;
}

const users: User[] = [
  { id: 1, name: 'Alice', active: true },
  { id: 2, name: 'Bob', active: false },
];

// Type-safe property access and comparison
const activeUsers = filterByProperty(users, 'active', true);

Generic Interfaces and Type Aliases

Generic interfaces create reusable type contracts. They’re essential for API responses, data structures, and design patterns:

interface ApiResponse<T> {
  data: T;
  status: number;
  message: string;
  timestamp: Date;
}

interface User {
  id: number;
  email: string;
}

// Type-safe API response
const userResponse: ApiResponse<User> = {
  data: { id: 1, email: 'user@example.com' },
  status: 200,
  message: 'Success',
  timestamp: new Date(),
};

// Array of users
const usersResponse: ApiResponse<User[]> = {
  data: [{ id: 1, email: 'user@example.com' }],
  status: 200,
  message: 'Success',
  timestamp: new Date(),
};

Generic type aliases work similarly but are more flexible:

type Result<T, E = Error> = 
  | { success: true; value: T }
  | { success: false; error: E };

function divide(a: number, b: number): Result<number> {
  if (b === 0) {
    return { success: false, error: new Error('Division by zero') };
  }
  return { success: true, value: a / b };
}

The repository pattern benefits enormously from generics:

interface Repository<T> {
  find(id: string): Promise<T | null>;
  findAll(): Promise<T[]>;
  create(item: Omit<T, 'id'>): Promise<T>;
  update(id: string, item: Partial<T>): Promise<T>;
  delete(id: string): Promise<void>;
}

// Implement for any entity type
class UserRepository implements Repository<User> {
  async find(id: string): Promise<User | null> {
    // Implementation
    return null;
  }
  // ... other methods
}

Generic Classes

Generic classes maintain type parameters across all instance methods:

class Stack<T> {
  private items: T[] = [];

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

  pop(): T | undefined {
    return this.items.pop();
  }

  peek(): T | undefined {
    return this.items[this.items.length - 1];
  }

  get size(): number {
    return this.items.length;
  }
}

const numberStack = new Stack<number>();
numberStack.push(1);
numberStack.push(2);
const top = numberStack.pop(); // Type: number | undefined

const stringStack = new Stack<string>();
stringStack.push('hello');
// stringStack.push(42); // Error: number not assignable to string

Important limitation: static members cannot reference class type parameters:

class Container<T> {
  // Error: Static members cannot reference class type parameters
  // static defaultValue: T;
  
  // This works - static method with its own type parameter
  static create<U>(value: U): Container<U> {
    const container = new Container<U>();
    return container;
  }
}

A practical generic service class:

class DataService<T extends { id: string }> {
  constructor(private baseUrl: string) {}

  async getById(id: string): Promise<T> {
    const response = await fetch(`${this.baseUrl}/${id}`);
    return response.json();
  }

  async getAll(): Promise<T[]> {
    const response = await fetch(this.baseUrl);
    return response.json();
  }

  async create(data: Omit<T, 'id'>): Promise<T> {
    const response = await fetch(this.baseUrl, {
      method: 'POST',
      body: JSON.stringify(data),
    });
    return response.json();
  }
}

const userService = new DataService<User>('/api/users');

Advanced Generic Patterns

Conditional types create type-level logic:

type Unwrap<T> = T extends Promise<infer U> ? U : T;

type A = Unwrap<Promise<string>>; // string
type B = Unwrap<number>; // number

Build type-safe property pickers using keyof:

function pick<T, 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 user = { id: 1, name: 'Alice', email: 'alice@example.com' };
const subset = pick(user, ['id', 'name']); // Type: { id: number; name: string }

Create custom utility types:

type DeepPartial<T> = {
  [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};

interface Config {
  database: {
    host: string;
    port: number;
  };
  cache: {
    ttl: number;
  };
}

const partialConfig: DeepPartial<Config> = {
  database: {
    host: 'localhost',
    // port is optional
  },
};

Generic event emitter with type-safe events:

type EventMap = Record<string, any>;

class TypedEventEmitter<Events extends EventMap> {
  private listeners: { [K in keyof Events]?: Array<(data: Events[K]) => void> } = {};

  on<K extends keyof Events>(event: K, callback: (data: Events[K]) => void): void {
    if (!this.listeners[event]) {
      this.listeners[event] = [];
    }
    this.listeners[event]!.push(callback);
  }

  emit<K extends keyof Events>(event: K, data: Events[K]): void {
    this.listeners[event]?.forEach(callback => callback(data));
  }
}

// Define your event types
interface MyEvents {
  userLogin: { userId: string; timestamp: Date };
  userLogout: { userId: string };
  dataUpdate: { id: string; changes: Record<string, any> };
}

const emitter = new TypedEventEmitter<MyEvents>();

emitter.on('userLogin', (data) => {
  console.log(data.userId, data.timestamp); // Fully typed
});

emitter.emit('userLogin', { 
  userId: '123', 
  timestamp: new Date() 
});

Real-World Use Cases

Here’s a complete generic HTTP client:

class HttpClient {
  constructor(private baseUrl: string) {}

  async get<T>(endpoint: string): Promise<ApiResponse<T>> {
    const response = await fetch(`${this.baseUrl}${endpoint}`);
    const data = await response.json();
    return {
      data,
      status: response.status,
      message: response.statusText,
      timestamp: new Date(),
    };
  }

  async post<T, B = unknown>(
    endpoint: string,
    body: B
  ): Promise<ApiResponse<T>> {
    const response = await fetch(`${this.baseUrl}${endpoint}`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(body),
    });
    const data = await response.json();
    return {
      data,
      status: response.status,
      message: response.statusText,
      timestamp: new Date(),
    };
  }
}

// Usage with full type safety
const client = new HttpClient('https://api.example.com');

interface Product {
  id: string;
  name: string;
  price: number;
}

const response = await client.get<Product[]>('/products');
response.data.forEach(product => {
  console.log(product.name, product.price); // Fully typed
});

const newProduct = await client.post<Product, Omit<Product, 'id'>>(
  '/products',
  { name: 'Widget', price: 29.99 }
);

Best Practices and Common Pitfalls

Use descriptive type parameter names for complex generics, but stick with T, U, V for simple cases:

// Good for simple cases
function map<T, U>(arr: T[], fn: (item: T) => U): U[] {
  return arr.map(fn);
}

// Good for complex cases
function createRepository<Entity, CreateDTO, UpdateDTO>() {
  // Implementation
}

Avoid over-engineering. Don’t use generics when a union type suffices:

// Bad: Unnecessary generic
function processId<T extends string | number>(id: T): T {
  return id;
}

// Good: Simple union type
function processId(id: string | number): string | number {
  return id;
}

Prefer inference over explicit type arguments:

// Verbose
const result = identity<string>('hello');

// Better - let TypeScript infer
const result = identity('hello');

Use generics when you need to maintain relationships between input and output types, when building reusable libraries, or when implementing design patterns like Repository or Factory. Avoid them for simple one-off functions or when the type relationship doesn’t add value.

Generics aren’t free at development time—complex generic types can slow down your IDE and increase compilation time. Profile your build if you’re using heavily nested generic types, and consider simplifying if you notice performance degradation.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.