Skip to main content
TypeScript Generics

Generics Deep Dive

Generics are the cornerstone of reusable, type-safe code in TypeScript. They allow you to write code that works with any type while maintaining full type information.

1. Why Generics?

Consider this function:
// Without generics - loses type information
function identity(value: any): any {
  return value;
}

const result = identity('hello'); // type is 'any' - not helpful!
With generics:
// With generics - type is preserved
function identity<T>(value: T): T {
  return value;
}

const result = identity('hello'); // type is 'string'
const num = identity(42);          // type is 'number'

2. Generic Functions

Basic Syntax

// Type parameter T
function wrap<T>(value: T): T[] {
  return [value];
}

wrap('hello'); // string[]
wrap(42);      // number[]
wrap(true);    // boolean[]

// Explicit type argument
wrap<string>('hello'); // string[]

Multiple Type Parameters

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

pair('hello', 42);           // [string, number]
pair(true, { name: 'Alice' }); // [boolean, { name: string }]

// Map-like function
function mapPair<T, U, V>(pair: [T, U], fn: (a: T, b: U) => V): V {
  return fn(pair[0], pair[1]);
}

mapPair([1, 2], (a, b) => a + b); // 3 (number)

Generic Arrow Functions

// Arrow function with generics
const identity = <T>(value: T): T => value;

// In TSX files, add a trailing comma to avoid JSX ambiguity
const identity2 = <T,>(value: T): T => value;

// With constraint
const getLength = <T extends { length: number }>(item: T): number => {
  return item.length;
};

3. Generic Constraints

Restrict what types can be used with generics.

extends Keyword

interface Lengthwise {
  length: number;
}

function logLength<T extends Lengthwise>(item: T): number {
  console.log(item.length);
  return item.length;
}

logLength('hello');        // 5 - string has length
logLength([1, 2, 3]);      // 3 - array has length
logLength({ length: 10 }); // 10 - object with length
// logLength(42);          // ❌ Error: number doesn't have length

keyof Constraint

function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const user = { name: 'Alice', age: 25 };

getProperty(user, 'name'); // string
getProperty(user, 'age');  // number
// getProperty(user, 'email'); // ❌ Error: 'email' is not a key

Multiple Constraints

interface Printable {
  print(): void;
}

interface Loggable {
  log(): void;
}

function process<T extends Printable & Loggable>(item: T): void {
  item.print();
  item.log();
}

Conditional Constraints

type NumberOrString<T> = T extends number ? number : string;

function process<T extends number | string>(value: T): NumberOrString<T> {
  if (typeof value === 'number') {
    return (value * 2) as NumberOrString<T>;
  }
  return value.toUpperCase() as NumberOrString<T>;
}

const num = process(42);       // number (84)
const str = process('hello'); // string ('HELLO')

4. Generic Classes

Classes that work with multiple types.
class Container<T> {
  private value: T;

  constructor(value: T) {
    this.value = value;
  }

  getValue(): T {
    return this.value;
  }

  setValue(value: T): void {
    this.value = value;
  }
}

const stringContainer = new Container('hello');
console.log(stringContainer.getValue()); // 'hello' (string)

const numberContainer = new Container(42);
console.log(numberContainer.getValue()); // 42 (number)

Generic Stack

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];
  }

  isEmpty(): boolean {
    return this.items.length === 0;
  }

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

  clear(): void {
    this.items = [];
  }
}

const stack = new Stack<number>();
stack.push(1);
stack.push(2);
stack.push(3);
console.log(stack.pop()); // 3

Generic with Static Members

class Registry<T> {
  private static instances = new Map<string, any>();
  private items = new Map<string, T>();

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

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

  static getInstance<U>(name: string): Registry<U> {
    if (!this.instances.has(name)) {
      this.instances.set(name, new Registry<U>());
    }
    return this.instances.get(name);
  }
}

5. Generic Interfaces

// Generic interface
interface Repository<T> {
  find(id: number): T | undefined;
  findAll(): T[];
  create(item: Omit<T, 'id'>): T;
  update(id: number, item: Partial<T>): T | undefined;
  delete(id: number): boolean;
}

// Generic with multiple parameters
interface Result<T, E> {
  success: boolean;
  data?: T;
  error?: E;
}

// Extending generic interface
interface TimestampedRepository<T> extends Repository<T> {
  findByDate(start: Date, end: Date): T[];
}

Implementing Generic Interfaces

interface Entity {
  id: number;
}

interface User extends Entity {
  name: string;
  email: string;
}

class UserRepository implements Repository<User> {
  private users: User[] = [];
  private nextId = 1;

  find(id: number): User | undefined {
    return this.users.find(u => u.id === id);
  }

  findAll(): User[] {
    return [...this.users];
  }

  create(item: Omit<User, 'id'>): User {
    const user = { ...item, id: this.nextId++ };
    this.users.push(user);
    return user;
  }

  update(id: number, item: Partial<User>): User | undefined {
    const index = this.users.findIndex(u => u.id === id);
    if (index === -1) return undefined;
    this.users[index] = { ...this.users[index], ...item };
    return this.users[index];
  }

  delete(id: number): boolean {
    const index = this.users.findIndex(u => u.id === id);
    if (index === -1) return false;
    this.users.splice(index, 1);
    return true;
  }
}

6. Default Type Parameters

Provide defaults for generic parameters.
// Default type parameter
interface Response<T = any> {
  data: T;
  status: number;
  message: string;
}

const response1: Response = { data: 'anything', status: 200, message: 'OK' };
const response2: Response<User> = { data: user, status: 200, message: 'OK' };

// Multiple defaults
interface Paginated<T = any, M = Record<string, unknown>> {
  items: T[];
  total: number;
  page: number;
  metadata: M;
}

// Defaults with constraints
interface Container<T extends object = object> {
  value: T;
}

7. Generic Type Aliases

// Generic type alias
type Nullable<T> = T | null | undefined;

type MaybeString = Nullable<string>; // string | null | undefined

// Complex generic type
type AsyncResult<T> = {
  loading: boolean;
  error: Error | null;
  data: T | null;
};

type UserResult = AsyncResult<User>;

// Recursive generic
type DeepPartial<T> = {
  [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};

// Generic function type
type Mapper<T, U> = (item: T) => U;

const toUpper: Mapper<string, string> = (s) => s.toUpperCase();
const toLength: Mapper<string, number> = (s) => s.length;

8. Generic Utility Patterns

Factory Pattern

interface Constructor<T> {
  new (...args: any[]): T;
}

function createInstance<T>(ctor: Constructor<T>, ...args: any[]): T {
  return new ctor(...args);
}

class User {
  constructor(public name: string) {}
}

const user = createInstance(User, 'Alice'); // User

Builder Pattern

class QueryBuilder<T> {
  private query: Partial<T> = {};

  where<K extends keyof T>(key: K, value: T[K]): this {
    this.query[key] = value;
    return this;
  }

  build(): Partial<T> {
    return { ...this.query };
  }
}

interface User {
  id: number;
  name: string;
  email: string;
  role: 'admin' | 'user';
}

const query = new QueryBuilder<User>()
  .where('role', 'admin')
  .where('name', 'Alice')
  .build();
// { role: 'admin', name: 'Alice' }

Result/Either Pattern

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

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

const result = divide(10, 2);
if (result.ok) {
  console.log(result.value); // 5
} else {
  console.error(result.error);
}

Option/Maybe Pattern

class Option<T> {
  private constructor(private value: T | null) {}

  static some<T>(value: T): Option<T> {
    return new Option(value);
  }

  static none<T>(): Option<T> {
    return new Option<T>(null);
  }

  map<U>(fn: (value: T) => U): Option<U> {
    if (this.value === null) return Option.none();
    return Option.some(fn(this.value));
  }

  flatMap<U>(fn: (value: T) => Option<U>): Option<U> {
    if (this.value === null) return Option.none();
    return fn(this.value);
  }

  getOrElse(defaultValue: T): T {
    return this.value ?? defaultValue;
  }

  isSome(): boolean {
    return this.value !== null;
  }
}

function findUser(id: number): Option<User> {
  const user = users.find(u => u.id === id);
  return user ? Option.some(user) : Option.none();
}

const userName = findUser(1)
  .map(user => user.name)
  .getOrElse('Unknown');

9. Advanced Generic Patterns

Variadic Tuple Types

// Concatenate tuples
type Concat<T extends any[], U extends any[]> = [...T, ...U];

type A = Concat<[1, 2], [3, 4]>; // [1, 2, 3, 4]

// Function composition
function pipe<A extends any[], B, C>(
  fn1: (...args: A) => B,
  fn2: (arg: B) => C
): (...args: A) => C {
  return (...args) => fn2(fn1(...args));
}

const addOne = (x: number) => x + 1;
const double = (x: number) => x * 2;
const addOneThenDouble = pipe(addOne, double);

console.log(addOneThenDouble(5)); // 12

Recursive Generics

// Flatten nested arrays
type Flatten<T> = T extends Array<infer U> ? Flatten<U> : T;

type Nested = number[][][];
type Flat = Flatten<Nested>; // number

// Deep object paths
type PathKeys<T, Prefix extends string = ''> = T extends object
  ? {
      [K in keyof T]: K extends string
        ? Prefix extends ''
          ? K | `${K}.${PathKeys<T[K], K>}`
          : `${Prefix}.${K}` | `${Prefix}.${K}.${PathKeys<T[K], K>}`
        : never;
    }[keyof T]
  : never;

interface User {
  name: string;
  address: {
    city: string;
    country: {
      code: string;
    };
  };
}

type UserPaths = PathKeys<User>;
// 'name' | 'address' | 'address.city' | 'address.country' | 'address.country.code'

Conditional Generics

// Different return types based on input
type ArrayOrSingle<T, Multi extends boolean> = Multi extends true
  ? T[]
  : T;

function fetch<T, Multi extends boolean = false>(
  url: string,
  options?: { multi?: Multi }
): Promise<ArrayOrSingle<T, Multi>> {
  // Implementation
  return {} as any;
}

const single = await fetch<User>('/user/1');         // User
const multiple = await fetch<User>('/users', { multi: true }); // User[]

10. Real-World Examples

Type-Safe Event Emitter

type EventMap = Record<string, any>;

type EventKey<T extends EventMap> = string & keyof T;
type EventCallback<T> = (data: T) => void;

class EventEmitter<T extends EventMap> {
  private listeners = new Map<keyof T, Set<EventCallback<any>>>();

  on<K extends EventKey<T>>(event: K, callback: EventCallback<T[K]>): () => void {
    if (!this.listeners.has(event)) {
      this.listeners.set(event, new Set());
    }
    this.listeners.get(event)!.add(callback);

    // Return unsubscribe function
    return () => this.off(event, callback);
  }

  off<K extends EventKey<T>>(event: K, callback: EventCallback<T[K]>): void {
    this.listeners.get(event)?.delete(callback);
  }

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

// Usage
interface AppEvents {
  login: { userId: string };
  logout: { timestamp: Date };
  error: { message: string; code: number };
}

const events = new EventEmitter<AppEvents>();

const unsubscribe = events.on('login', (data) => {
  console.log(`User ${data.userId} logged in`);
});

events.emit('login', { userId: '123' }); // Type-safe!
unsubscribe(); // Clean up

Type-Safe HTTP Client

interface ApiEndpoints {
  '/users': {
    GET: { response: User[] };
    POST: { body: Omit<User, 'id'>; response: User };
  };
  '/users/:id': {
    GET: { response: User };
    PUT: { body: Partial<User>; response: User };
    DELETE: { response: void };
  };
}

type Method = 'GET' | 'POST' | 'PUT' | 'DELETE';

type PathParams<P extends string> = P extends `${string}:${infer Param}/${infer Rest}`
  ? { [K in Param | keyof PathParams<Rest>]: string }
  : P extends `${string}:${infer Param}`
  ? { [K in Param]: string }
  : {};

class HttpClient<T extends Record<string, Record<Method, any>>> {
  async request<
    P extends keyof T & string,
    M extends keyof T[P] & Method
  >(
    method: M,
    path: P,
    options?: {
      params?: PathParams<P>;
      body?: T[P][M] extends { body: infer B } ? B : never;
    }
  ): Promise<T[P][M] extends { response: infer R } ? R : never> {
    // Implementation
    return {} as any;
  }
}

const client = new HttpClient<ApiEndpoints>();

// Fully type-safe API calls
const users = await client.request('GET', '/users');
const newUser = await client.request('POST', '/users', {
  body: { name: 'Alice', email: '[email protected]' }
});

Summary

ConceptExample
Basic Genericfunction identity<T>(val: T): T
Multiple Parametersfunction pair<T, U>(a: T, b: U): [T, U]
Constraints<T extends { length: number }>
keyof Constraint<K extends keyof T>
Generic Classclass Stack<T> { items: T[] }
Generic Interfaceinterface Repo<T> { find(id): T }
Default Parameterinterface Response<T = any>
ConditionalT extends U ? X : Y
inferT extends Array<infer U> ? U : T
Variadic Tuples[...T, ...U]
Next, we’ll explore modules and configuration!