← Back to Course

🔷 Advanced Generics

Generics are one of TypeScript's most powerful features. Beyond basic generics, you can use constraints, multiple type parameters, and generic utility types to create highly flexible and type-safe code.

Generic Constraints

Constraints limit what types can be used as generic parameters using the extends keyword:

Example: Generic Constraints

// Constraint: T must have a length property
function logLength<T extends { length: number }>(item: T): void {
  console.log(`Length: ${item.length}`);
}

logLength("hello");        // ✓ OK - strings have length
logLength([1, 2, 3]);      // ✓ OK - arrays have length
// logLength(123);         // ✗ Error - numbers don't have length

// Constraint with interface
interface Nameable {
  name: string;
}

function greetEntity<T extends Nameable>(entity: T): string {
  return `Hello, ${entity.name}!`;
}

greetEntity({ name: "Hero", level: 5 });  // ✓ OK
// greetEntity({ id: 1 });  // ✗ Error - missing 'name'

Multiple Type Parameters

Functions and classes can have multiple generic type parameters:

Example: Multiple Type Parameters

// Function with two type parameters
function pair<K, V>(key: K, value: V): [K, V] {
  return [key, value];
}

const entry1 = pair("name", "Alice");      // [string, string]
const entry2 = pair(1, { data: "value" }); // [number, { data: string }]

// Generic map function
function mapObject<T, U>(
  obj: Record<string, T>,
  mapper: (value: T) => U
): Record<string, U> {
  const result: Record<string, U> = {};
  for (const key in obj) {
    result[key] = mapper(obj[key]);
  }
  return result;
}

const numbers = { a: 1, b: 2, c: 3 };
const doubled = mapObject(numbers, n => n * 2);
// { a: 2, b: 4, c: 6 }

Generic Classes

Classes can be generic, allowing flexible data structures:

Example: Generic Classes

// Generic Stack data structure
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;
  }
}

// Use with different types
const numberStack = new Stack<number>();
numberStack.push(1);
numberStack.push(2);
console.log(numberStack.pop());  // 2

const stringStack = new Stack<string>();
stringStack.push("hello");
stringStack.push("world");

Generic Constraints with Keyof

The keyof operator creates a union of an object's keys:

Example: Keyof Constraints

// Get property value by key name (type-safe)
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const player = {
  name: "Hero",
  level: 5,
  health: 100
};

const name = getProperty(player, "name");    // string
const level = getProperty(player, "level");  // number
// const invalid = getProperty(player, "invalid");  // ✗ Error

// Update property (type-safe setter)
function setProperty<T, K extends keyof T>(
  obj: T,
  key: K,
  value: T[K]
): void {
  obj[key] = value;
}

setProperty(player, "level", 6);  // ✓ OK
// setProperty(player, "level", "six");  // ✗ Error - wrong type

Generic Type Inference

TypeScript can infer complex generic types from usage:

Example: Advanced Type Inference

// TypeScript infers both type parameters
function merge<T, U>(obj1: T, obj2: U): T & U {
  return { ...obj1, ...obj2 };
}

const result = merge(
  { name: "Hero" },
  { level: 5 }
);
// Type inferred as: { name: string } & { level: number }
// Which simplifies to: { name: string; level: number }

console.log(result.name);   // ✓ OK
console.log(result.level);  // ✓ OK

// Conditional type with generics
type ArrayOrSingle<T> = T extends any[] ? T : T[];

function ensureArray<T>(value: T): ArrayOrSingle<T> {
  return (Array.isArray(value) ? value : [value]) as ArrayOrSingle<T>;
}

const arr1 = ensureArray([1, 2, 3]);  // number[]
const arr2 = ensureArray(5);          // number[]

Real-World Example

Example: Typed Event System

// Generic event system with type safety
type EventMap = {
  "player:move": { x: number; y: number; };
  "player:damage": { amount: number; source: string; };
  "item:collect": { itemId: string; itemType: string; };
};

class EventBus<T extends Record<string, any>> {
  private listeners: {
    [K in keyof T]?: Array<(data: T[K]) => void>;
  } = {};
  
  on<K extends keyof T>(event: K, callback: (data: T[K]) => void): void {
    if (!this.listeners[event]) {
      this.listeners[event] = [];
    }
    this.listeners[event]!.push(callback);
  }
  
  emit<K extends keyof T>(event: K, data: T[K]): void {
    const callbacks = this.listeners[event];
    if (callbacks) {
      callbacks.forEach(callback => callback(data));
    }
  }
}

const events = new EventBus<EventMap>();

// Type-safe event listeners
events.on("player:move", (data) => {
  console.log(`Player moved to ${data.x}, ${data.y}`);
});

events.on("player:damage", (data) => {
  console.log(`Player took ${data.amount} damage from ${data.source}`);
});

// Type-safe event emission
events.emit("player:move", { x: 10, y: 5 });
events.emit("player:damage", { amount: 25, source: "enemy" });
// events.emit("player:move", { invalid: true });  // ✗ Error

🎯 Key Concepts

  • Use extends to constrain generic types
  • Multiple type parameters enable complex relationships
  • keyof T creates a union of object keys
  • T[K] accesses the type of property K in T
  • Generic classes create reusable data structures
  • TypeScript infers generic types from usage when possible
  • Combine generics with utility types for powerful patterns
← Back to Intermediate Course