+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Part 56 of 354

🧬 Generics Introduction: Writing Reusable Code

Master TypeScript generics to create flexible, reusable, and type-safe code that works with multiple types 🚀

🚀Intermediate
30 min read

Prerequisites

  • Understanding of TypeScript types 📝
  • Basic knowledge of functions and interfaces 🔍
  • Familiarity with type annotations 💻

What you'll learn

  • Understand what generics are and why they're powerful 🎯
  • Create generic functions and interfaces 🏗️
  • Apply type constraints to generics 🛡️
  • Build reusable, type-safe components ✨

🎯 Introduction

Welcome to the powerful world of TypeScript generics! 🎉 In this guide, we’ll explore how generics enable you to write flexible, reusable code that maintains type safety across different data types.

You’ll discover how generics are like recipe templates 📋 - they define the structure and process, but let you choose the ingredients! Whether you’re building utility functions 🔧, data structures 📊, or complex applications 🏗️, understanding generics is essential for writing professional TypeScript code.

By the end of this tutorial, you’ll be confidently creating generic code that adapts to any type while maintaining full type safety! Let’s unlock the power of generics! 🏊‍♂️

📚 Understanding Generics

🤔 What are Generics?

Generics are a way to create reusable components that can work with multiple types rather than a single one. They allow you to write code that is both flexible and type-safe, capturing the type information when the code is used rather than when it’s written.

Think of generics like:

  • 📦 Shipping containers: Same container, different contents
  • 🎭 Theater costumes: Same role, different actors
  • 🧰 Tool templates: Same tool design, different materials
  • 🍱 Bento boxes: Same structure, different foods

💡 Why Use Generics?

Here’s why generics are game-changers:

  1. Type Safety 🛡️: Maintain type checking with flexible code
  2. Code Reusability ♻️: Write once, use with many types
  3. Better IntelliSense 💡: IDE knows exact types
  4. Avoid Code Duplication 📋: No need for multiple versions

Real-world example: Array methods 📊 - map, filter, and reduce work with any type of array while preserving type information!

🔧 Your First Generic

📝 The Problem Without Generics

Let’s start by understanding the problem generics solve:

// ❌ Without generics - Type information is lost
function getFirstItem(items: any[]): any {
  return items[0];
}

const numbers = [1, 2, 3, 4, 5];
const firstNumber = getFirstItem(numbers); // Type is 'any' 😢
// No IntelliSense, no type safety!

// ❌ Creating multiple versions - Code duplication
function getFirstNumber(items: number[]): number {
  return items[0];
}

function getFirstString(items: string[]): string {
  return items[0];
}

function getFirstBoolean(items: boolean[]): boolean {
  return items[0];
}

// This approach doesn't scale! 😫

✨ The Generic Solution

Now let’s see how generics solve this elegantly:

// ✅ With generics - Type safety preserved!
function getFirstItem<T>(items: T[]): T {
  return items[0];
}

// TypeScript infers the type
const numbers = [1, 2, 3, 4, 5];
const firstNumber = getFirstItem(numbers); // Type is 'number' ✨
console.log(firstNumber.toFixed(2)); // IntelliSense works!

const strings = ['hello', 'world'];
const firstString = getFirstItem(strings); // Type is 'string' ✨
console.log(firstString.toUpperCase()); // String methods available!

// 🎯 You can also explicitly specify the type
const explicitFirst = getFirstItem<boolean>([true, false]); // Type is 'boolean'

// 🏗️ Generic with multiple operations
function reverseArray<T>(items: T[]): T[] {
  return items.slice().reverse();
}

function shuffleArray<T>(items: T[]): T[] {
  const array = [...items];
  for (let i = array.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [array[i], array[j]] = [array[j], array[i]];
  }
  return array;
}

// Works with any type!
const reversedNumbers = reverseArray([1, 2, 3, 4, 5]); // number[]
const shuffledStrings = shuffleArray(['a', 'b', 'c', 'd']); // string[]

interface User {
  id: number;
  name: string;
}

const users: User[] = [
  { id: 1, name: 'Alice' },
  { id: 2, name: 'Bob' }
];

const reversedUsers = reverseArray(users); // User[] - Complex types work too!

🚀 Generic Syntax Deep Dive

📐 Anatomy of a Generic

Let’s understand the syntax and conventions:

// 🎯 Basic generic syntax
function identity<T>(value: T): T {
  return value;
}
//             ^^^ Type parameter declaration
//                      ^ Using type parameter
//                              ^ Return type using parameter

// 📊 Common naming conventions
// T - Type (most common)
// U, V - Additional types
// K - Key
// V - Value  
// E - Element
// R - Return type

// 🏗️ Multiple type parameters
function pair<T, U>(first: T, second: U): [T, U] {
  return [first, second];
}

const result = pair('hello', 42); // [string, number]
const coords = pair(10.5, 20.3); // [number, number]

// 🎨 Generic type aliases
type Container<T> = {
  value: T;
  timestamp: Date;
};

const numberContainer: Container<number> = {
  value: 42,
  timestamp: new Date()
};

const stringContainer: Container<string> = {
  value: 'Hello',
  timestamp: new Date()
};

// 🔧 Generic interfaces
interface Result<T> {
  success: boolean;
  data?: T;
  error?: string;
}

function processData<T>(data: T): Result<T> {
  try {
    // Process data...
    return { success: true, data };
  } catch (error) {
    return { success: false, error: error.message };
  }
}

const numberResult = processData(42);
// Type is Result<number>

const userResult = processData({ id: 1, name: 'Alice' });
// Type is Result<{ id: number; name: string }>

🎭 Generic Constraints

Adding constraints to ensure type compatibility:

// 🎯 Basic constraint - T must have a length property
function logLength<T extends { length: number }>(item: T): T {
  console.log(`Length: ${item.length}`);
  return item;
}

logLength('Hello'); // ✅ Strings have length
logLength([1, 2, 3]); // ✅ Arrays have length
logLength({ length: 10, value: 'custom' }); // ✅ Objects with length
// logLength(42); // ❌ Numbers don't have length

// 🏗️ Using keyof for property constraints
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const person = { name: 'Alice', age: 30, email: '[email protected]' };

const name = getProperty(person, 'name'); // string
const age = getProperty(person, 'age'); // number
// getProperty(person, 'invalid'); // ❌ Error: 'invalid' is not a key

// 🔧 Extending specific types
interface Timestamped {
  createdAt: Date;
  updatedAt: Date;
}

function updateTimestamp<T extends Timestamped>(item: T): T {
  return {
    ...item,
    updatedAt: new Date()
  };
}

const document = {
  title: 'My Document',
  createdAt: new Date('2024-01-01'),
  updatedAt: new Date('2024-01-01')
};

const updated = updateTimestamp(document); // ✅ Has required properties

// 🎨 Complex constraints
interface Comparable<T> {
  compareTo(other: T): number;
}

function sort<T extends Comparable<T>>(items: T[]): T[] {
  return items.slice().sort((a, b) => a.compareTo(b));
}

class Version implements Comparable<Version> {
  constructor(private version: string) {}
  
  compareTo(other: Version): number {
    return this.version.localeCompare(other.version);
  }
}

const versions = [
  new Version('1.2.0'),
  new Version('1.1.0'),
  new Version('1.3.0')
];

const sorted = sort(versions); // ✅ Works with Comparable types

🎨 Generic Classes and Interfaces

🏗️ Generic Classes

Creating flexible class definitions:

// 🎯 Generic Stack implementation
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;
  }
}

// 📊 Type-safe usage
const numberStack = new Stack<number>();
numberStack.push(1);
numberStack.push(2);
numberStack.push(3);
const num = numberStack.pop(); // number | undefined

const stringStack = new Stack<string>();
stringStack.push('hello');
stringStack.push('world');
const str = stringStack.peek(); // string | undefined

// 🏗️ Generic Queue with constraints
interface QueueItem {
  priority: number;
}

class PriorityQueue<T extends QueueItem> {
  private items: T[] = [];
  
  enqueue(item: T): void {
    let added = false;
    
    for (let i = 0; i < this.items.length; i++) {
      if (item.priority > this.items[i].priority) {
        this.items.splice(i, 0, item);
        added = true;
        break;
      }
    }
    
    if (!added) {
      this.items.push(item);
    }
  }
  
  dequeue(): T | undefined {
    return this.items.shift();
  }
  
  front(): T | undefined {
    return this.items[0];
  }
}

interface Task extends QueueItem {
  id: string;
  name: string;
  priority: number;
}

const taskQueue = new PriorityQueue<Task>();
taskQueue.enqueue({ id: '1', name: 'High Priority', priority: 10 });
taskQueue.enqueue({ id: '2', name: 'Low Priority', priority: 1 });
taskQueue.enqueue({ id: '3', name: 'Medium Priority', priority: 5 });

const nextTask = taskQueue.dequeue(); // High priority task comes first!

🎭 Generic Interfaces

Building flexible contracts:

// 🎯 Generic repository interface
interface Repository<T, ID> {
  findById(id: ID): Promise<T | null>;
  findAll(): Promise<T[]>;
  save(entity: T): Promise<T>;
  delete(id: ID): Promise<boolean>;
}

// 📊 Implementation for different entities
interface User {
  id: number;
  username: string;
  email: string;
}

interface Product {
  sku: string;
  name: string;
  price: number;
}

class UserRepository implements Repository<User, number> {
  async findById(id: number): Promise<User | null> {
    // Database query
    return { id, username: 'john', email: '[email protected]' };
  }
  
  async findAll(): Promise<User[]> {
    return [];
  }
  
  async save(user: User): Promise<User> {
    // Save to database
    return user;
  }
  
  async delete(id: number): Promise<boolean> {
    // Delete from database
    return true;
  }
}

class ProductRepository implements Repository<Product, string> {
  async findById(sku: string): Promise<Product | null> {
    return { sku, name: 'Widget', price: 9.99 };
  }
  
  async findAll(): Promise<Product[]> {
    return [];
  }
  
  async save(product: Product): Promise<Product> {
    return product;
  }
  
  async delete(sku: string): Promise<boolean> {
    return true;
  }
}

// 🔧 Generic factory interface
interface Factory<T> {
  create(): T;
  createMany(count: number): T[];
}

class CarFactory implements Factory<Car> {
  create(): Car {
    return new Car('Toyota', 'Camry');
  }
  
  createMany(count: number): Car[] {
    return Array(count).fill(null).map(() => this.create());
  }
}

class Car {
  constructor(public make: string, public model: string) {}
}

🎪 Real-World Generic Patterns

🔄 Generic Utility Functions

Common patterns you’ll use every day:

// 🎯 Safe object access
function getOrDefault<T, K extends keyof T>(
  obj: T,
  key: K,
  defaultValue: T[K]
): T[K] {
  return obj[key] ?? defaultValue;
}

const config = {
  port: 3000,
  host: 'localhost',
  debug: false
};

const port = getOrDefault(config, 'port', 8080); // 3000
const timeout = getOrDefault(config, 'timeout' as any, 5000); // 5000 (default)

// 🏗️ Type-safe object mapping
function mapObject<T, U>(
  obj: T,
  fn: <K extends keyof T>(value: T[K], key: K) => U
): Record<keyof T, U> {
  const result = {} as Record<keyof T, U>;
  
  for (const key in obj) {
    result[key] = fn(obj[key], key);
  }
  
  return result;
}

const prices = {
  apple: 1.5,
  banana: 0.8,
  orange: 2.0
};

const formattedPrices = mapObject(prices, (price) => `$${price.toFixed(2)}`);
// { apple: '$1.50', banana: '$0.80', orange: '$2.00' }

// 🔧 Generic memoization
function memoize<T extends (...args: any[]) => any>(fn: T): T {
  const cache = new Map<string, ReturnType<T>>();
  
  return ((...args: Parameters<T>) => {
    const key = JSON.stringify(args);
    
    if (cache.has(key)) {
      return cache.get(key)!;
    }
    
    const result = fn(...args);
    cache.set(key, result);
    return result;
  }) as T;
}

const expensiveCalculation = (n: number): number => {
  console.log(`Calculating for ${n}...`);
  return n * n;
};

const memoizedCalc = memoize(expensiveCalculation);
console.log(memoizedCalc(5)); // Logs "Calculating for 5..." and returns 25
console.log(memoizedCalc(5)); // Returns 25 from cache

// 🎨 Generic event emitter
class EventEmitter<T extends Record<string, any[]>> {
  private listeners = new Map<keyof T, Set<Function>>();
  
  on<K extends keyof T>(event: K, listener: (...args: T[K]) => void): void {
    if (!this.listeners.has(event)) {
      this.listeners.set(event, new Set());
    }
    this.listeners.get(event)!.add(listener);
  }
  
  emit<K extends keyof T>(event: K, ...args: T[K]): void {
    this.listeners.get(event)?.forEach(listener => listener(...args));
  }
}

// Define event types
interface AppEvents {
  login: [user: { id: string; name: string }];
  logout: [];
  message: [text: string, sender: string];
}

const emitter = new EventEmitter<AppEvents>();

emitter.on('login', (user) => {
  console.log(`User ${user.name} logged in`);
});

emitter.on('message', (text, sender) => {
  console.log(`${sender}: ${text}`);
});

emitter.emit('login', { id: '123', name: 'Alice' }); // Type-safe!
emitter.emit('message', 'Hello!', 'Bob'); // Type-safe!

🏗️ Advanced Generic Patterns

Building sophisticated type-safe structures:

// 🎯 Conditional types with generics
type AsyncResult<T> = T extends Promise<infer U> ? U : T;

type StringResult = AsyncResult<string>; // string
type PromiseResult = AsyncResult<Promise<number>>; // number

function processResult<T>(result: T): AsyncResult<T> {
  if (result instanceof Promise) {
    throw new Error('Use processResultAsync for promises');
  }
  return result as AsyncResult<T>;
}

// 🔧 Generic type guards
function isArray<T>(value: T | T[]): value is T[] {
  return Array.isArray(value);
}

function processValue<T>(value: T | T[]): T[] {
  return isArray(value) ? value : [value];
}

console.log(processValue(5)); // [5]
console.log(processValue([1, 2, 3])); // [1, 2, 3]

// 🏗️ Builder pattern with generics
class Builder<T> {
  private object: Partial<T> = {};
  
  set<K extends keyof T>(key: K, value: T[K]): this {
    this.object[key] = value;
    return this;
  }
  
  build(): T {
    // In real implementation, would validate required fields
    return this.object as T;
  }
}

interface Person {
  name: string;
  age: number;
  email: string;
  address?: string;
}

const person = new Builder<Person>()
  .set('name', 'Alice')
  .set('age', 30)
  .set('email', '[email protected]')
  .build();

// 🎨 Generic state management
interface Action<T = any> {
  type: string;
  payload?: T;
}

type Reducer<S, A extends Action> = (state: S, action: A) => S;

class Store<S, A extends Action> {
  private state: S;
  private listeners = new Set<(state: S) => void>();
  
  constructor(
    private reducer: Reducer<S, A>,
    initialState: S
  ) {
    this.state = initialState;
  }
  
  getState(): S {
    return this.state;
  }
  
  dispatch(action: A): void {
    this.state = this.reducer(this.state, action);
    this.listeners.forEach(listener => listener(this.state));
  }
  
  subscribe(listener: (state: S) => void): () => void {
    this.listeners.add(listener);
    return () => this.listeners.delete(listener);
  }
}

// Usage
interface CounterState {
  count: number;
}

type CounterAction = 
  | { type: 'INCREMENT' }
  | { type: 'DECREMENT' }
  | { type: 'SET'; payload: number };

const counterReducer: Reducer<CounterState, CounterAction> = (state, action) => {
  switch (action.type) {
    case 'INCREMENT':
      return { count: state.count + 1 };
    case 'DECREMENT':
      return { count: state.count - 1 };
    case 'SET':
      return { count: action.payload! };
    default:
      return state;
  }
};

const store = new Store(counterReducer, { count: 0 });
store.subscribe(state => console.log('State:', state));

store.dispatch({ type: 'INCREMENT' });
store.dispatch({ type: 'SET', payload: 10 });

🎮 Hands-On Exercise

Let’s build a generic data validation system!

📝 Challenge: Type-Safe Validation Framework

Create a validation framework that:

  1. Works with any object type
  2. Provides type-safe validation rules
  3. Returns detailed error messages
  4. Supports async validation
// Your challenge: Implement this validation system
interface ValidationRule<T> {
  validate(value: T): boolean | Promise<boolean>;
  message: string;
}

interface ValidationResult {
  valid: boolean;
  errors: string[];
}

// Example usage to support:
interface User {
  username: string;
  email: string;
  age: number;
}

const userValidator = new Validator<User>()
  .addRule('username', {
    validate: (value) => value.length >= 3,
    message: 'Username must be at least 3 characters'
  })
  .addRule('email', {
    validate: (value) => value.includes('@'),
    message: 'Invalid email format'
  })
  .addRule('age', {
    validate: (value) => value >= 18,
    message: 'Must be 18 or older'
  });

const result = await userValidator.validate({
  username: 'jo',
  email: 'invalid',
  age: 16
});
// Should return validation errors for all fields

💡 Solution

Click to see the solution
// 🎯 Generic validation framework
class Validator<T> {
  private rules = new Map<keyof T, ValidationRule<any>[]>();
  
  addRule<K extends keyof T>(
    field: K,
    rule: ValidationRule<T[K]>
  ): this {
    if (!this.rules.has(field)) {
      this.rules.set(field, []);
    }
    
    this.rules.get(field)!.push(rule);
    return this;
  }
  
  addRules<K extends keyof T>(
    field: K,
    ...rules: ValidationRule<T[K]>[]
  ): this {
    rules.forEach(rule => this.addRule(field, rule));
    return this;
  }
  
  async validate(data: T): Promise<ValidationResult> {
    const errors: string[] = [];
    
    // Validate each field
    for (const [field, rules] of this.rules.entries()) {
      const value = data[field];
      
      for (const rule of rules) {
        const result = await rule.validate(value);
        
        if (!result) {
          errors.push(`${String(field)}: ${rule.message}`);
        }
      }
    }
    
    return {
      valid: errors.length === 0,
      errors
    };
  }
  
  // Convenience method for creating rules
  static rule<T>(
    validate: (value: T) => boolean | Promise<boolean>,
    message: string
  ): ValidationRule<T> {
    return { validate, message };
  }
}

// 🏗️ Common validation rules
const ValidationRules = {
  required: <T>(): ValidationRule<T> => ({
    validate: (value) => value !== null && value !== undefined && value !== '',
    message: 'This field is required'
  }),
  
  minLength: (min: number): ValidationRule<string> => ({
    validate: (value) => value.length >= min,
    message: `Must be at least ${min} characters`
  }),
  
  maxLength: (max: number): ValidationRule<string> => ({
    validate: (value) => value.length <= max,
    message: `Must be at most ${max} characters`
  }),
  
  min: (min: number): ValidationRule<number> => ({
    validate: (value) => value >= min,
    message: `Must be at least ${min}`
  }),
  
  max: (max: number): ValidationRule<number> => ({
    validate: (value) => value <= max,
    message: `Must be at most ${max}`
  }),
  
  email: (): ValidationRule<string> => ({
    validate: (value) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value),
    message: 'Must be a valid email address'
  }),
  
  pattern: (regex: RegExp, message?: string): ValidationRule<string> => ({
    validate: (value) => regex.test(value),
    message: message || `Must match pattern ${regex}`
  }),
  
  asyncUnique: <T>(
    checkUnique: (value: T) => Promise<boolean>,
    message?: string
  ): ValidationRule<T> => ({
    validate: checkUnique,
    message: message || 'This value is already taken'
  })
};

// 🎨 Advanced usage example
interface RegistrationForm {
  username: string;
  email: string;
  password: string;
  confirmPassword: string;
  age: number;
  terms: boolean;
}

// Simulate async uniqueness check
const checkUsernameAvailable = async (username: string): Promise<boolean> => {
  await new Promise(resolve => setTimeout(resolve, 100)); // Simulate API call
  const taken = ['admin', 'root', 'user'];
  return !taken.includes(username.toLowerCase());
};

const checkEmailAvailable = async (email: string): Promise<boolean> => {
  await new Promise(resolve => setTimeout(resolve, 100));
  const taken = ['[email protected]', '[email protected]'];
  return !taken.includes(email.toLowerCase());
};

// Create comprehensive validator
const registrationValidator = new Validator<RegistrationForm>()
  .addRules('username',
    ValidationRules.required(),
    ValidationRules.minLength(3),
    ValidationRules.maxLength(20),
    ValidationRules.pattern(/^[a-zA-Z0-9_]+$/, 'Username can only contain letters, numbers, and underscores'),
    ValidationRules.asyncUnique(checkUsernameAvailable, 'Username is already taken')
  )
  .addRules('email',
    ValidationRules.required(),
    ValidationRules.email(),
    ValidationRules.asyncUnique(checkEmailAvailable, 'Email is already registered')
  )
  .addRules('password',
    ValidationRules.required(),
    ValidationRules.minLength(8),
    ValidationRules.pattern(
      /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
      'Password must contain lowercase, uppercase, and number'
    )
  )
  .addRule('confirmPassword', {
    validate: function(this: RegistrationForm, value) {
      return value === this.password;
    },
    message: 'Passwords do not match'
  })
  .addRules('age',
    ValidationRules.required(),
    ValidationRules.min(18),
    ValidationRules.max(120)
  )
  .addRule('terms', {
    validate: (value) => value === true,
    message: 'You must accept the terms and conditions'
  });

// 💫 Test the validation system
async function testValidation() {
  console.log('=== Registration Form Validation ===\n');
  
  // Test invalid data
  const invalidData: RegistrationForm = {
    username: 'ab',
    email: 'invalid-email',
    password: 'weak',
    confirmPassword: 'different',
    age: 16,
    terms: false
  };
  
  console.log('Testing invalid data...');
  const result1 = await registrationValidator.validate(invalidData);
  console.log('Valid:', result1.valid);
  console.log('Errors:', result1.errors);
  
  // Test valid data
  console.log('\nTesting valid data...');
  const validData: RegistrationForm = {
    username: 'johndoe123',
    email: '[email protected]',
    password: 'SecurePass123',
    confirmPassword: 'SecurePass123',
    age: 25,
    terms: true
  };
  
  const result2 = await registrationValidator.validate(validData);
  console.log('Valid:', result2.valid);
  console.log('Errors:', result2.errors);
  
  // Test async validation
  console.log('\nTesting taken username...');
  const takenUsername: RegistrationForm = {
    ...validData,
    username: 'admin'
  };
  
  const result3 = await registrationValidator.validate(takenUsername);
  console.log('Valid:', result3.valid);
  console.log('Errors:', result3.errors);
}

testValidation();

🎯 Summary

You’ve mastered the fundamentals of TypeScript generics! 🎉 You learned how to:

  • 🧬 Create generic functions that work with any type
  • 📦 Build generic classes and interfaces
  • 🔒 Apply constraints to ensure type compatibility
  • 🏗️ Implement real-world patterns with generics
  • 🎨 Design flexible, reusable components
  • ✨ Maintain type safety while writing flexible code

Generics are one of TypeScript’s most powerful features, enabling you to write code that is both flexible and type-safe. They’re the foundation for building professional, reusable TypeScript applications!

Keep exploring the power of generic programming! 🚀