DEV Community

Alex Chen
Alex Chen

Posted on

TypeScript Generics Explained: The Practical Guide

TypeScript Generics Explained: The Practical Guide

Generics look scary. They're not. They're just "placeholders for types." Here's everything you need.

What Are Generics?

// Without generics — duplicate code for different types
function identityString(arg: string): string {
  return arg;
}

function identityNumber(arg: number): number {
  return arg;
}

function identityBoolean(arg: boolean): boolean {
  return arg;
}

// With generics — ONE function for ALL types
function identity<T>(arg: T): T {
  return arg;
}

identity('hello');    // T is inferred as string
identity(42);         // T is inferred as number
identity(true);       // T is inferred as boolean
identity<string>('hello'); // Explicit type (rarely needed)
Enter fullscreen mode Exit fullscreen mode

The Mental Model

Generic = Type Variable = "I'll tell you the type later"

<T> = "This function works with any type T"
    When you call it, TypeScript figures out what T is
    Based on the arguments you pass in
Enter fullscreen mode Exit fullscreen mode

Practical Examples

1. Generic Functions

// First element of array
function first<T>(arr: T[]): T | undefined {
  return arr[0];
}

first([1, 2, 3])         // number | undefined
first(['a', 'b', 'c'])   // string | undefined

// Merge two objects
function merge<T, U>(obj1: T, obj2: U): T & U {
  return { ...obj1, ...obj2 };
}

const result = merge({ name: 'Alice' }, { age: 30 });
// { name: string; age: number }

// Filter array
function filter<T>(arr: T[], predicate: (item: T) => boolean): T[] {
  return arr.filter(predicate);
}

const adults = filter(
  [{ name: 'Alice', age: 30 }, { name: 'Bob', age: 15 }],
  person => person.age >= 18
);
// Type: { name: string; age: number }[]

// Promise wrapper
function safeAsync<T>(promise: Promise<T>): Promise<T | null> {
  return promise.catch(() => null);
}

const user = await safeAsync(fetchUser(id));
// Type: User | null
Enter fullscreen mode Exit fullscreen mode

2. Generic Interfaces & Types

// API Response wrapper
interface ApiResponse<T> {
  data: T;
  status: number;
  message: string;
  timestamp: string;
}

// Usage
type User = { id: number; name: string; email: string };
type Post = { id: number; title: string; content: string };

const userResponse: ApiResponse<User> = {
  data: { id: 1, name: 'Alice', email: 'alice@test.com' },
  status: 200,
  message: 'OK',
  timestamp: new Date().toISOString(),
};

const postsResponse: ApiResponse<Post[]> = {
  data: [{ id: 1, title: 'Hello', content: 'World' }],
  status: 200,
  message: 'OK',
  timestamp: new Date().toISOString(),
};

// Paginated response
interface PaginatedResponse<T> {
  data: T[];
  meta: {
    page: number;
    per_page: number;
    total: number;
    total_pages: number;
  };
}

type PaginatedUsers = PaginatedResponse<User>;
// { data: User[]; meta: { page: number; ... } }
Enter fullscreen mode Exit fullscreen mode

3. Generic Classes

// Data store
class DataStore<T> {
  private items: Map<string, T> = new Map();

  set(key: string, item: T): void {
    this.items.set(key, item);
  }

  get(key: string): T | undefined {
    return this.items.get(key);
  }

  has(key: string): boolean {
    return this.items.has(key);
  }

  getAll(): T[] {
    return Array.from(this.items.values());
  }

  delete(key: string): boolean {
    return this.items.delete(key);
  }
}

const userStore = new DataStore<User>();
userStore.set('user1', { id: 1, name: 'Alice', email: 'a@test.com' });
const user = userStore.get('user1'); // Type: User | undefined

const postStore = new DataStore<Post>();
postStore.set('post1', { id: 1, title: 'Hello', content: 'World' });
Enter fullscreen mode Exit fullscreen mode

4. Generic Constraints (extends)

// Constrain T to have a specific property
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const user = { name: 'Alice', age: 30, email: 'a@test.com' };
getProperty(user, 'name');  // string
getProperty(user, 'age');   // number
getProperty(user, 'xyz');   // ❌ Error! 'xyz' is not a key of user

// Constrain to objects with id property
function findById<T extends { id: number }>(items: T[], id: number): T | undefined {
  return items.find(item => item.id === id);
}

const users = [
  { id: 1, name: 'Alice' },
  { id: 2, name: 'Bob' },
];
findById(users, 1);  // { id: number; name: string } | undefined

// Constrain to classes
class Animal {
  constructor(public name: string) {}
  speak() { return `${this.name} makes a sound`; }
}

class Dog extends Animal {
  speak() { return `${this.name} barks`; }
}

function makeSound<T extends Animal>(animal: T): string {
  return animal.speak();
}

makeSound(new Dog('Rex'));  // 'Rex barks'
makeSound(new Animal('Cat')); // 'Cat makes a sound'
Enter fullscreen mode Exit fullscreen mode

5. Default Type Parameters

// T defaults to string if not specified
interface Dictionary<T = string> {
  [key: string]: T;
}

const strings: Dictionary = { a: 'hello', b: 'world' }; // T = string
const numbers: Dictionary<number> = { a: 1, b: 2 };     // T = number

// Default generic in function
function createArray<T = string>(length: number, value: T): T[] {
  return Array(length).fill(value);
}

createArray(3, 'hello');  // ['hello', 'hello', 'hello']
createArray(3, 42);       // [42, 42, 42]
createArray(3, true);     // [true, true, true]
Enter fullscreen mode Exit fullscreen mode

6. Utility Types with Generics

// These built-in generics are incredibly useful:

// Partial — all properties optional
type PartialUser = Partial<User>;
// { id?: number; name?: string; email?: string }

// Required — all properties required
type RequiredUser = Required<PartialUser>;
// { id: number; name: string; email: string }

// Pick — select specific properties
type UserPreview = Pick<User, 'id' | 'name'>;
// { id: number; name: string }

// Omit — exclude specific properties
type UserWithoutEmail = Omit<User, 'email'>;
// { id: number; name: string }

// Record — object with specific key/value types
type RolePermissions = Record<string, string[]>;
// { admin: string[]; user: string[]; ... }

// Readonly — immutable object
type FrozenUser = Readonly<User>;
// { readonly id: number; readonly name: string; ... }

// Combine them!
type UpdateUserDTO = Partial<Omit<User, 'id'>>;
// All properties optional except id (which is omitted)
Enter fullscreen mode Exit fullscreen mode

Common Patterns

// 1. Type-safe event emitter
type EventHandler<T = void> = T extends void 
  ? () => void 
  : (data: T) => void;

interface Events {
  userCreated: { id: number; name: string };
  orderPlaced: { orderId: string; total: number };
  error: { message: string; code: number };
  ready: void;
}

class TypedEmitter<E extends Record<string, any>> {
  private handlers = new Map<string, Set<Function>>();

  on<K extends keyof E>(event: K, handler: EventHandler<E[K]>): void {
    if (!this.handlers.has(event as string)) {
      this.handlers.set(event as string, new Set());
    }
    this.handlers.get(event as string)!.add(handler);
  }

  emit<K extends keyof E>(event: K, ...args: E[K] extends void ? [] : [E[K]]): void {
    this.handlers.get(event as string)?.forEach(fn => fn(...args));
  }
}

// 2. Type-safe API client
interface ApiRoutes {
  'GET /users': { response: User[] };
  'GET /users/:id': { response: User; params: { id: number } };
  'POST /users': { response: User; body: CreateUserDTO };
  'DELETE /users/:id': { response: void; params: { id: number } };
}

function apiClient<K extends keyof ApiRoutes>(
  route: K,
  options?: Omit<ApiRoutes[K], 'response'>
): Promise<ApiRoutes[K]['response']> {
  return fetch(route as string, options as any).then(r => r.json());
}

// Fully typed!
const users = await apiClient('GET /users');        // User[]
const user = await apiClient('GET /users/:id', { params: { id: 1 } }); // User
Enter fullscreen mode Exit fullscreen mode

Quick Reference

Pattern Syntax When to Use
Generic function fn<T>(arg: T): T Function works with any type
Multiple types fn<T, U>(a: T, b: U) Different types for different params
Constraint fn<T extends SomeType> Type must have certain properties
Default type fn<T = string> Common fallback type
Generic interface interface I<T> Type varies per implementation
Generic class class C<T> Internal data type varies
keyof K extends keyof T Constrain to object's keys
Utility types Partial<T>, Pick<T> Transform existing types

Are generics clicking for you? What confused you at first?

Follow @armorbreak for more TypeScript content.

Top comments (0)