Programming

Mastering TypeScript Generics: The Ultimate Guide for Modern Developers

Do you want to write TypeScript code that is more flexible and reusable? You can do this while keeping full type safety. TypeScript generics are a powerful feature you must master. This guide covers basic concepts and advanced patterns. It includes practical examples for you to use right away in your projects.

What Are TypeScript Generics? Understanding the Fundamentals

TypeScript generics let you build reusable code that works with different data types. They keep your code safe and type-checked. Think of generics like templates. You choose the data type when you use the component, not when you define it.

// Basic generic function
function identity<T>(arg: T): T {
  return arg;
}

// Usage
const stringResult = identity<string>("Hello TypeScript");  // type: string
const numberResult = identity(42);  // type: number (type inference)

Why TypeScript generics are essential for modern development:

  • Enhance code reusability – write once, use with any compatible type
  • Maintain strict type checking – catch errors at compile time, not runtime
  • Improve code readability – clearly express relationships between inputs and outputs
  • Eliminate duplicate code – avoid creating separate functions for different types
  • Enable better IDE support – get intelligent autocomplete suggestions

Getting Started: TypeScript Generics Fundamentals

Let’s dive into the basics with a simple example that illustrates the core concept:

// Without generics - limited to strings only
function returnString(value: string): string {
  return value;
}

// With generics - works with any type
function returnAnything<T>(value: T): T {
  return value;
}

// Usage examples
const string = returnAnything<string>("Hello world");
const number = returnAnything<number>(42);
const boolean = returnAnything<boolean>(true);

The <T> syntax defines a type variable that captures the type provided by the user, allowing the function to preserve and return that same type.

Generic Interfaces and Classes: Building Flexible Structures

Generics aren’t just for functions. They also excel when used in interfaces and classes.

// Generic interface
interface Container<T> {
  value: T;
  getValue(): T;
}

// Generic class implementation
class Box<T> implements Container<T> {
  constructor(public value: T) {}
  
  getValue(): T {
    return this.value;
  }
}

// Create strongly-typed instances
const numberBox = new Box<number>(123);
const stringBox = new Box<string>("TypeScript Rocks");

console.log(numberBox.getValue()); // 123
console.log(stringBox.getValue()); // "TypeScript Rocks"

This pattern is ideal for creating container-like structures while preserving type information.

Advanced Techniques: Generic Constraints and Defaults

Take your generics to the next level with constraints and default types:

// Generic with constraint - must have a name property
interface HasName {
  name: string;
}

function greet<T extends HasName>(entity: T): T {
  console.log(`Hello, ${entity.name}!`);
  return entity;
}

// Works with any type that has a name property
greet({ name: "Alice", age: 30 }); // Hello, Alice!
greet({ name: "Company XYZ", founded: 2010 }); // Hello, Company XYZ!
greet({ name: "Rover", species: "Dog" }); // Hello, Rover!

// This would cause a compile error
// greet({ age: 25 }); // Error: Property 'name' is missing

// Generic with default type parameter
function createBox<T = number>(value: T): { value: T } {
  return { value };
}

// If type parameter is omitted, defaults to number
const defaultBox = createBox(42); // Type is { value: number }
const stringBox = createBox<string>("hello"); // Type is { value: string }

Real-World Generic Patterns

Example #1 – Type-Safe API Responses

// Generic API response structure
interface ApiResult<T> {
  data: T;
  success: boolean;
  errorMessage?: string;
}

// Simple fetch function with generics
async function fetchApi<T>(endpoint: string): Promise<ApiResult<T>> {
  try {
    // Simulate API call
    const response = await fetch(`https://api.example.com/${endpoint}`);
    
    if (!response.ok) {
      return {
        data: null as unknown as T,
        success: false,
        errorMessage: `Error: ${response.status}`
      };
    }
    
    const json = await response.json();
    
    return {
      data: json as T,
      success: true
    };
  } catch (error) {
    return {
      data: null as unknown as T,
      success: false,
      errorMessage: error instanceof Error ? error.message : 'Unknown error'
    };
  }
}

// Define different data types for different endpoints
interface Product {
  id: string;
  name: string;
  price: number;
}

interface Customer {
  id: string;
  name: string;
  email: string;
}

// Usage with specific types
async function loadData() {
  // Products endpoint returns Product array
  const productsResult = await fetchApi<Product[]>('products');
  
  if (productsResult.success) {
    // TypeScript knows this is Product[]
    productsResult.data.forEach(product => {
      console.log(`${product.name}: $${product.price}`);
    });
  }
  
  // Customer endpoint returns single Customer
  const customerResult = await fetchApi<Customer>('customers/1');
  
  if (customerResult.success) {
    // TypeScript knows this is Customer
    console.log(`Customer: ${customerResult.data.name} (${customerResult.data.email})`);
  }
}

Example 2 – Generic caching utility

// Generic caching utility with timeout functionality

// Define the cached item structure
interface CacheItem<T> {
  value: T;
  expiry: number;
}

// Generic cache manager
class Cache<T extends Record<string, any>> {
  private cache: Map<keyof T, CacheItem<T[keyof T]>> = new Map();
  private defaultTTL: number;
  
  constructor(defaultTTLSeconds: number = 300) { // Default 5 minutes
    this.defaultTTL = defaultTTLSeconds * 1000;
  }
  
  // Get an item from cache
  get<K extends keyof T>(key: K): T[K] | null {
    const item = this.cache.get(key);
    
    // Check if item exists and hasn't expired
    if (item && item.expiry > Date.now()) {
      return item.value as T[K];
    }
    
    // Remove expired item
    if (item) {
      this.cache.delete(key);
    }
    
    return null;
  }
  
  // Set an item in cache with optional custom TTL
  set<K extends keyof T>(key: K, value: T[K], ttlSeconds?: number): void {
    const expiry = Date.now() + (ttlSeconds ? ttlSeconds * 1000 : this.defaultTTL);
    
    this.cache.set(key, {
      value,
      expiry
    });
  }
  
  // Remove an item from cache
  remove<K extends keyof T>(key: K): void {
    this.cache.delete(key);
  }
  
  // Clear all cached items
  clear(): void {
    this.cache.clear();
  }
  
  // Get all valid cache keys
  getKeys(): (keyof T)[] {
    const keys: (keyof T)[] = [];
    
    this.cache.forEach((item, key) => {
      if (item.expiry > Date.now()) {
        keys.push(key);
      }
    });
    
    return keys;
  }
}

// Usage with specific cache types
interface AppCache {
  'user-profile': {
    id: string;
    name: string;
    email: string;
  };
  'app-settings': {
    theme: 'light' | 'dark';
    notifications: boolean;
  };
  'product-list': Array<{
    id: string;
    name: string;
    price: number;
  }>;
}

// Create a typed cache
const appCache = new Cache<AppCache>();

// Set values with correct types
appCache.set('user-profile', {
  id: 'user123',
  name: 'John Doe',
  email: 'john@example.com'
});

appCache.set('app-settings', {
  theme: 'dark',
  notifications: true
}, 3600); // Custom TTL of 1 hour

// Retrieve values with correct typing
const userProfile = appCache.get('user-profile');
if (userProfile) {
  console.log(`Hello, ${userProfile.name}!`);
}

const settings = appCache.get('app-settings');
if (settings?.theme === 'dark') {
  console.log('Applying dark theme');
}

// This would cause a compiler error - wrong property type
// appCache.set('app-settings', {
//   theme: 'blue', // Error: Type '"blue"' is not assignable to type '"light" | "dark"'
//   notifications: true
// });

Example 3 – Generic form validation system

// Generic form validation system

// Define validation results
interface ValidationResult {
  valid: boolean;
  errors: string[];
}

// Generic validator interface
interface Validator<T> {
  validate(value: T): ValidationResult;
}

// Concrete validators
class StringValidator implements Validator<string> {
  private minLength: number;
  private maxLength: number;
  
  constructor(minLength = 0, maxLength = Infinity) {
    this.minLength = minLength;
    this.maxLength = maxLength;
  }
  
  validate(value: string): ValidationResult {
    const errors: string[] = [];
    
    if (value.length < this.minLength) {
      errors.push(`Must be at least ${this.minLength} characters`);
    }
    
    if (value.length > this.maxLength) {
      errors.push(`Must be no more than ${this.maxLength} characters`);
    }
    
    return {
      valid: errors.length === 0,
      errors
    };
  }
}

class NumberValidator implements Validator<number> {
  private min: number;
  private max: number;
  
  constructor(min = -Infinity, max = Infinity) {
    this.min = min;
    this.max = max;
  }
  
  validate(value: number): ValidationResult {
    const errors: string[] = [];
    
    if (value < this.min) {
      errors.push(`Must be at least ${this.min}`);
    }
    
    if (value > this.max) {
      errors.push(`Must be no more than ${this.max}`);
    }
    
    return {
      valid: errors.length === 0,
      errors
    };
  }
}

// Email validator example
class EmailValidator implements Validator<string> {
  validate(value: string): ValidationResult {
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    const valid = emailRegex.test(value);
    
    return {
      valid,
      errors: valid ? [] : ['Invalid email format']
    };
  }
}

// Generic form field with validation
class FormField<T> {
  private value: T;
  private validators: Validator<T>[] = [];
  
  constructor(initialValue: T) {
    this.value = initialValue;
  }
  
  getValue(): T {
    return this.value;
  }
  
  setValue(newValue: T): void {
    this.value = newValue;
  }
  
  addValidator(validator: Validator<T>): void {
    this.validators.push(validator);
  }
  
  validate(): ValidationResult {
    const errors: string[] = [];
    
    for (const validator of this.validators) {
      const result = validator.validate(this.value);
      if (!result.valid) {
        errors.push(...result.errors);
      }
    }
    
    return {
      valid: errors.length === 0,
      errors
    };
  }
}

// Usage in a form context
interface UserForm {
  username: FormField<string>;
  email: FormField<string>;
  age: FormField<number>;
}

// Create a user registration form
const userForm: UserForm = {
  username: new FormField<string>(''),
  email: new FormField<string>(''),
  age: new FormField<number>(0)
};

// Add validators to fields
userForm.username.addValidator(new StringValidator(3, 20));
userForm.email.addValidator(new EmailValidator());
userForm.age.addValidator(new NumberValidator(18, 120));

// Simulate form input
userForm.username.setValue('al');
userForm.email.setValue('not-an-email');
userForm.age.setValue(15);

// Validate form
const validateForm = () => {
  const usernameResult = userForm.username.validate();
  const emailResult = userForm.email.validate();
  const ageResult = userForm.age.validate();
  
  console.log('Username validation:', usernameResult);
  console.log('Email validation:', emailResult);
  console.log('Age validation:', ageResult);
  
  const formValid = usernameResult.valid && emailResult.valid && ageResult.valid;
  console.log('Form valid:', formValid);
};

validateForm();

Conclusion: Leveling Up Your TypeScript Skills

TypeScript generics may seem tough at first. However, mastering them opens up better type safety and code reusability. Begin with easy examples. Then, add constraints and advanced patterns. Soon, you’ll write clean and maintainable TypeScript code.

Using generics helps you write flexible code. This builds a strong base for apps that adapt to changing needs. Plus, it keeps the safety that TypeScript offers.

Related Articles

Back to top button