Generics allow you to create reusable components that work with multiple types.

Why Generics?

Without generics, you'd need to use any or create separate functions:

// ❌ Using any - loses type safety
function identity(arg: any): any {
  return arg;
}

// ❌ Creating separate functions - not reusable
function identityString(arg: string): string {
  return arg;
}

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

With generics:

// ✅ Generic function - type-safe and reusable
function identity<T>(arg: T): T {
  return arg;
}

identity<string>("hello");  // string
identity<number>(42);        // number
identity("hello");           // TypeScript infers T as string

Generic Functions

Basic Generic Function

function getFirst<T>(items: T[]): T | undefined {
  return items[0];
}

getFirst<string>(["a", "b", "c"]);     // string | undefined
getFirst<number>([1, 2, 3]);            // number | undefined
getFirst([true, false]);                 // TypeScript infers boolean

Multiple Type Parameters

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

pair<string, number>("Alice", 25);      // [string, number]
pair<boolean, string>(true, "yes");     // [boolean, string]

Generic Constraints

Limit what types can be used.

interface HasLength {
  length: number;
}

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

logLength("hello");        // ✅ string has length
logLength([1, 2, 3]);      // ✅ array has length
// logLength(42);          // ❌ number doesn't have length

Using Type Parameters in Constraints

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

const person = { name: "Alice", age: 25 };
getProperty(person, "name");  // string
getProperty(person, "age");   // number
// getProperty(person, "email"); // ❌ Error: "email" is not a key

Generic Interfaces

interface Box<T> {
  value: T;
}

const stringBox: Box<string> = { value: "hello" };
const numberBox: Box<number> = { value: 42 };

Generic Interface with Methods

interface Repository<T> {
  findById(id: string): T | undefined;
  save(entity: T): void;
  delete(id: string): void;
}

class UserRepository implements Repository<User> {
  findById(id: string): User | undefined {
    // Implementation
    return undefined;
  }

  save(entity: User): void {
    // Implementation
  }

  delete(id: string): void {
    // Implementation
  }
}

Generic Classes

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

const numberStack = new Stack<number>();
numberStack.push(1);
numberStack.push(2);

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

Generic Type Aliases

type Result<T> = {
  success: boolean;
  data?: T;
  error?: string;
};

type ApiResponse<T> = Promise<Result<T>>;

Default Type Parameters

Provide default types for generics.

interface ApiResponse<T = any> {
  data: T;
  status: number;
}

const response1: ApiResponse = { data: "anything", status: 200 };
const response2: ApiResponse<User> = { data: user, status: 200 };

Exercises

Exercise 1: Basic Generic Function

// Create a generic function 'swap' that:
// - Takes two parameters of the same type
// - Returns a tuple with them swapped
// Example: swap(1, 2) returns [2, 1]

Exercise 2: Generic Array Functions

// Create generic functions:
// - 'last': returns the last element of an array
// - 'reverse': returns a reversed array
// - 'unique': returns array with duplicates removed

Exercise 3: Generic Constraints

// Create a generic function 'compare' that:
// - Takes two parameters that extend { id: number }
// - Returns true if they have the same id

Exercise 4: Generic Class

// Create a generic class 'Queue' with:
// - enqueue(item: T): void
// - dequeue(): T | undefined
// - isEmpty(): boolean
// - size(): number

Exercise 5: Generic Interface

// Create a generic interface 'Cache' with:
// - get(key: string): T | undefined
// - set(key: string, value: T): void
// - clear(): void
// Then implement it in a class

Best Practices

  1. Use descriptive type parameter names - T, U, V for simple cases, TKey, TValue for complex
  2. Add constraints when needed - Use extends to limit types
  3. Let TypeScript infer when possible - Don't always need explicit type arguments
  4. Use default types - When a sensible default exists
  5. Document complex generics - Add comments explaining constraints

Common Mistakes

  1. Overusing generics
// ❌ Unnecessary generic
function process<T>(value: string): string {
  return value.toUpperCase();
}

// ✅ Simpler
function process(value: string): string {
  return value.toUpperCase();
}
  1. Not using constraints
// ❌ Error-prone
function getLength<T>(item: T): number {
  return item.length; // Error: Property 'length' doesn't exist
}

// ✅ With constraint
function getLength<T extends { length: number }>(item: T): number {
  return item.length;
}
  1. Forgetting type inference
// ❌ Verbose
const result = identity<string>("hello");

// ✅ Let TypeScript infer
const result = identity("hello");

Next Steps

Learn about Utility Types.