Back to Tutorial Overview

Master TypeScript Generics

Learn to write flexible, reusable, and type-safe code with generics

Section 1: Why Generics?

The Problem Without Generics

Imagine you need a function that returns the first element of an array. Without generics:

// Option 1: Lose type safety
function getFirst(arr: any[]): any {
  return arr[0];
}

const num = getFirst([1, 2, 3]); // Type is 'any' ❌
const str = getFirst(['a', 'b']); // Type is 'any' ❌

// Option 2: Create multiple functions
function getFirstNumber(arr: number[]): number {
  return arr[0];
}

function getFirstString(arr: string[]): string {
  return arr[0];
}
// ... need a function for every type! đŸ˜Ģ

The Solution: Generics

Generics allow us to create ONE function that works with ANY type while maintaining type safety:

// Generic function - works with any type!
function getFirst<T>(arr: T[]): T {
  return arr[0];
}

const num = getFirst([1, 2, 3]);      // Type is 'number' ✅
const str = getFirst(['a', 'b']);     // Type is 'string' ✅
const bool = getFirst([true, false]); // Type is 'boolean' ✅

// TypeScript infers the type automatically!

Section 2: Generic Functions

Basic Generic Function

// Identity function - returns the same type it receives
function identity<T>(value: T): T {
  return value;
}

// Usage - TypeScript infers the type
const numResult = identity(42);         // number
const strResult = identity("hello");    // string
const objResult = identity({ x: 10 }); // { x: number }

// You can also explicitly specify the type
const explicitResult = identity<string>("world");

Multiple Type Parameters

// Function with two generic types
function pair<T, U>(first: T, second: U): [T, U] {
  return [first, second];
}

const numStr = pair(1, "hello");      // [number, string]
const boolObj = pair(true, { x: 5 }); // [boolean, { x: number }]

// Create a map from two arrays
function createMap<K, V>(keys: K[], values: V[]): Map<K, V> {
  const map = new Map<K, V>();
  keys.forEach((key, index) => {
    map.set(key, values[index]);
  });
  return map;
}

const userMap = createMap(
  ['user1', 'user2'],
  [{ age: 25 }, { age: 30 }]
);
// Map<string, { age: number }>

Generic Arrow Functions

// Arrow function syntax
const wrap = <T>(value: T): { value: T } => {
  return { value };
};

// Array methods with generics
const numbers = [1, 2, 3, 4, 5];
const doubled = numbers.map<number>(n => n * 2);
const strings = numbers.map<string>(n => n.toString());

// Filter with type narrowing
const items = [1, "hello", 2, "world", 3];
const numbersOnly = items.filter((item): item is number => {
  return typeof item === 'number';
}); // Type is number[]

Section 3: Generic Interfaces & Type Aliases

Generic Interfaces

// Generic API response interface
interface ApiResponse<T> {
  data: T;
  status: number;
  message: string;
}

// Usage with different data types
type UserResponse = ApiResponse<{ id: string; name: string }>;
type PostsResponse = ApiResponse<Array<{ title: string; content: string }>>;

// Generic key-value store
interface KeyValueStore<K, V> {
  get(key: K): V | undefined;
  set(key: K, value: V): void;
  has(key: K): boolean;
  delete(key: K): boolean;
}

// Implementation
class SimpleStore<K, V> implements KeyValueStore<K, V> {
  private store = new Map<K, V>();

  get(key: K): V | undefined {
    return this.store.get(key);
  }

  set(key: K, value: V): void {
    this.store.set(key, value);
  }

  has(key: K): boolean {
    return this.store.has(key);
  }

  delete(key: K): boolean {
    return this.store.delete(key);
  }
}

const userStore = new SimpleStore<string, User>();
const configStore = new SimpleStore<string, Config>();

Generic Type Aliases

// Generic promise type
type AsyncResult<T> = Promise<T | Error>;

// Generic callback type
type Callback<T> = (data: T) => void;

// Generic factory function type
type Factory<T> = () => T;

// Usage
const fetchUser: AsyncResult<User> = fetchUserData();
const handleData: Callback<string> = (data) => console.log(data);
const createUser: Factory<User> = () => ({ id: '1', name: 'John' });

Section 4: Generic Classes

Building a 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 = [];
  }
}

// Usage 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 Queue with Advanced Features

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

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

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

  front(): T | undefined {
    return this.items[0];
  }

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

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

  // Generic method within generic class
  map<U>(fn: (item: T) => U): Queue<U> {
    const newQueue = new Queue<U>();
    this.items.forEach(item => {
      newQueue.enqueue(fn(item));
    });
    return newQueue;
  }

  filter(predicate: (item: T) => boolean): Queue<T> {
    const newQueue = new Queue<T>();
    this.items.filter(predicate).forEach(item => {
      newQueue.enqueue(item);
    });
    return newQueue;
  }
}

// Usage
const taskQueue = new Queue<{ id: number; name: string }>();
taskQueue.enqueue({ id: 1, name: "Task 1" });
taskQueue.enqueue({ id: 2, name: "Task 2" });

// Transform queue items
const idQueue = taskQueue.map(task => task.id); // Queue<number>

Section 5: Generic Constraints

Constraining with extends

Sometimes you need to ensure a generic type has certain properties:

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

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

// Constraint: T must extend a base interface
interface Identifiable {
  id: string;
}

function findById<T extends Identifiable>(
  items: T[],
  id: string
): T | undefined {
  return items.find(item => item.id === id);
}

interface User extends Identifiable {
  name: string;
}

interface Product extends Identifiable {
  price: number;
}

const users: User[] = [{ id: '1', name: 'John' }];
const foundUser = findById(users, '1'); // Type is User | undefined

Using keyof Constraint

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

const user = {
  id: '1',
  name: 'John',
  age: 30
};

const name = getProperty(user, 'name');  // Type is string
const age = getProperty(user, 'age');    // Type is number
// const x = getProperty(user, 'invalid'); // ❌ Error

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

setProperty(user, 'age', 31);           // ✅
// setProperty(user, 'age', 'invalid'); // ❌ Error: wrong type

Section 6: Advanced Generic Patterns

Conditional Types

// Conditional type: unwrap Promise type
type Awaited<T> = T extends Promise<infer U> ? U : T;

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

// Exclude null and undefined
type NonNullable<T> = T extends null | undefined ? never : T;

type C = NonNullable<string | null>;      // string
type D = NonNullable<number | undefined>; // number

// Extract function return type
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;

function getUser() {
  return { id: '1', name: 'John' };
}

type UserType = ReturnType<typeof getUser>; // { id: string; name: string }

Mapped Types

// Make all properties optional
type Partial<T> = {
  [K in keyof T]?: T[K];
};

// Make all properties required
type Required<T> = {
  [K in keyof T]-?: T[K];
};

// Make all properties readonly
type Readonly<T> = {
  readonly [K in keyof T]: T[K];
};

// Pick specific properties
type Pick<T, K extends keyof T> = {
  [P in K]: T[P];
};

// Usage
interface User {
  id: string;
  name: string;
  email: string;
  age?: number;
}

type PartialUser = Partial<User>;
// { id?: string; name?: string; email?: string; age?: number }

type UserPreview = Pick<User, 'id' | 'name'>;
// { id: string; name: string }

Real-World Example: API Client

Type-Safe API Client with Generics

class ApiClient {
  private baseUrl: string;

  constructor(baseUrl: string) {
    this.baseUrl = baseUrl;
  }

  async get<T>(endpoint: string): Promise<T> {
    const response = await fetch(`${this.baseUrl}${endpoint}`);
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }
    return response.json();
  }

  async post<T, U>(endpoint: string, data: T): Promise<U> {
    const response = await fetch(`${this.baseUrl}${endpoint}`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data),
    });
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }
    return response.json();
  }
}

// Usage
interface User {
  id: string;
  name: string;
  email: string;
}

interface CreateUserDto {
  name: string;
  email: string;
}

const api = new ApiClient('https://api.example.com');

// Type-safe API calls
const users = await api.get<User[]>('/users');
const newUser = await api.post<CreateUserDto, User>('/users', {
  name: 'John',
  email: 'john@example.com'
});

// TypeScript knows the exact types! ✨

Practice Makes Perfect!

Start using generics in your projects to master these concepts.

Back to Tutorial Overview