Skip to main content
โšก Calmops

Generics: Functions, Classes, and Constraints in TypeScript

Generics: Functions, Classes, and Constraints in TypeScript

Generics enable writing reusable, type-safe code. This article covers generic functions, classes, constraints, and advanced patterns.

Introduction

Generics provide:

  • Code reusability
  • Type safety
  • Flexibility
  • Maintainability
  • Better IDE support

Understanding generics helps you:

  • Write reusable code
  • Create flexible APIs
  • Maintain type safety
  • Build scalable systems
  • Reduce code duplication

Generic Functions

Basic Generic Functions

// โœ… Good: Basic generic function
function identity<T>(value: T): T {
  return value;
}

const str = identity<string>('hello');
const num = identity<number>(42);
const bool = identity<boolean>(true);

// โœ… Good: Type inference
const str2 = identity('hello'); // T inferred as string
const num2 = identity(42); // T inferred as number

// โœ… Good: Multiple type parameters
function pair<T, U>(first: T, second: U): [T, U] {
  return [first, second];
}

const result = pair<string, number>('hello', 42);
const result2 = pair('hello', 42); // Types inferred

Generic Constraints

// โœ… Good: Constrain generic types
function getLength<T extends { length: number }>(value: T): number {
  return value.length;
}

getLength('hello'); // OK
getLength([1, 2, 3]); // OK
getLength({ length: 5 }); // OK
// getLength(42); // Error: number has no length

// โœ… Good: Extend specific type
function getProperty<T extends object, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const user = { name: 'John', age: 30 };
const name = getProperty(user, 'name'); // OK
// getProperty(user, 'email'); // Error: 'email' not in user

// โœ… Good: Conditional constraints
function process<T extends string | number>(value: T): T {
  return value;
}

process('hello'); // OK
process(42); // OK
// process(true); // Error

Generic Utility Functions

// โœ… Good: Generic array functions
function first<T>(array: T[]): T | undefined {
  return array[0];
}

function last<T>(array: T[]): T | undefined {
  return array[array.length - 1];
}

function reverse<T>(array: T[]): T[] {
  return array.reverse();
}

// โœ… Good: Generic filter
function filter<T>(array: T[], predicate: (item: T) => boolean): T[] {
  return array.filter(predicate);
}

const numbers = [1, 2, 3, 4, 5];
const evens = filter(numbers, (n) => n % 2 === 0);

// โœ… Good: Generic map
function map<T, U>(array: T[], transform: (item: T) => U): U[] {
  return array.map(transform);
}

const lengths = map(['hello', 'world'], (s) => s.length);

Generic Classes

Basic Generic Classes

// โœ… Good: Generic class
class Container<T> {
  private value: T;

  constructor(value: T) {
    this.value = value;
  }

  getValue(): T {
    return this.value;
  }

  setValue(value: T): void {
    this.value = value;
  }
}

const stringContainer = new Container<string>('hello');
const numberContainer = new Container<number>(42);

// โœ… Good: Generic with constraints
class Repository<T extends { id: number }> {
  private items: T[] = [];

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

  getById(id: number): T | undefined {
    return this.items.find((item) => item.id === id);
  }

  getAll(): T[] {
    return this.items;
  }
}

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

const userRepo = new Repository<User>();
userRepo.add({ id: 1, name: 'John' });

Generic Inheritance

// โœ… Good: Generic class inheritance
class BaseRepository<T extends { id: number }> {
  protected items: T[] = [];

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

  getById(id: number): T | undefined {
    return this.items.find((item) => item.id === id);
  }
}

class UserRepository extends BaseRepository<User> {
  findByName(name: string): User | undefined {
    return this.items.find((user) => user.name === name);
  }
}

// โœ… Good: Multiple type parameters
class Pair<T, U> {
  constructor(private first: T, private second: U) {}

  getFirst(): T {
    return this.first;
  }

  getSecond(): U {
    return this.second;
  }
}

const pair = new Pair<string, number>('hello', 42);

Advanced Generic Patterns

Generic Constraints with Keyof

// โœ… Good: Use keyof for type-safe property access
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const user = { name: 'John', age: 30 };
const name = getProperty(user, 'name'); // OK
// getProperty(user, 'email'); // Error

// โœ… Good: Generic object mapper
function mapObject<T, K extends keyof T, U>(
  obj: T,
  key: K,
  transform: (value: T[K]) => U
): U {
  return transform(obj[key]);
}

const result = mapObject(user, 'name', (name) => name.toUpperCase());

Conditional Generic Types

// โœ… Good: Conditional types with generics
type Flatten<T> = T extends Array<infer U> ? U : T;

type Str = Flatten<string[]>; // string
type Num = Flatten<number>; // number

// โœ… Good: Extract type from Promise
type Awaited<T> = T extends Promise<infer U> ? U : T;

type PromiseString = Awaited<Promise<string>>; // string
type PlainNumber = Awaited<number>; // number

// โœ… Good: Generic with conditional
function process<T>(value: T): T extends string ? number : string {
  if (typeof value === 'string') {
    return value.length as any;
  }
  return 'not a string' as any;
}

Generic Decorators

// โœ… Good: Generic decorator
function memoize<T extends (...args: any[]) => any>(
  target: any,
  propertyKey: string,
  descriptor: PropertyDescriptor
) {
  const originalMethod = descriptor.value;
  const cache = new Map();

  descriptor.value = function (...args: any[]) {
    const key = JSON.stringify(args);

    if (cache.has(key)) {
      return cache.get(key);
    }

    const result = originalMethod.apply(this, args);
    cache.set(key, result);
    return result;
  };

  return descriptor;
}

class Calculator {
  @memoize
  fibonacci(n: number): number {
    if (n <= 1) return n;
    return this.fibonacci(n - 1) + this.fibonacci(n - 2);
  }
}

Practical Examples

Generic API Client

// โœ… Good: Generic API client
interface ApiResponse<T> {
  status: number;
  data: T;
  error?: string;
}

class ApiClient {
  async get<T>(url: string): Promise<ApiResponse<T>> {
    const response = await fetch(url);
    const data = await response.json();

    return {
      status: response.status,
      data
    };
  }

  async post<T>(url: string, body: any): Promise<ApiResponse<T>> {
    const response = await fetch(url, {
      method: 'POST',
      body: JSON.stringify(body)
    });
    const data = await response.json();

    return {
      status: response.status,
      data
    };
  }
}

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

const client = new ApiClient();
const response = await client.get<User>('/api/users/1');

Generic State Management

// โœ… Good: Generic state store
class Store<T> {
  private state: T;
  private listeners: ((state: T) => void)[] = [];

  constructor(initialState: T) {
    this.state = initialState;
  }

  getState(): T {
    return this.state;
  }

  setState(newState: T): void {
    this.state = newState;
    this.notify();
  }

  subscribe(listener: (state: T) => void): () => void {
    this.listeners.push(listener);

    return () => {
      this.listeners = this.listeners.filter((l) => l !== listener);
    };
  }

  private notify(): void {
    this.listeners.forEach((listener) => listener(this.state));
  }
}

interface AppState {
  user: User | null;
  isLoading: boolean;
}

const store = new Store<AppState>({
  user: null,
  isLoading: false
});

store.subscribe((state) => {
  console.log('State updated:', state);
});

Best Practices

  1. Use meaningful type parameter names:

    // โœ… Good
    function process<T, U>(first: T, second: U): [T, U] { }
    
    // โŒ Bad
    function process<A, B>(first: A, second: B): [A, B] { }
    
  2. Constrain when possible:

    // โœ… Good
    function getLength<T extends { length: number }>(value: T): number {
      return value.length;
    }
    
    // โŒ Bad
    function getLength<T>(value: T): number {
      return (value as any).length;
    }
    
  3. Use keyof for type safety:

    // โœ… Good
    function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
      return obj[key];
    }
    
    // โŒ Bad
    function getProperty<T>(obj: T, key: string): any {
      return (obj as any)[key];
    }
    

Common Mistakes

  1. Over-generalizing:

    // โŒ Bad
    function process<T>(value: T): T {
      return value; // Too generic
    }
    
    // โœ… Good
    function process<T extends string | number>(value: T): T {
      return value;
    }
    
  2. Not using constraints:

    // โŒ Bad
    function getLength<T>(value: T): number {
      return (value as any).length;
    }
    
    // โœ… Good
    function getLength<T extends { length: number }>(value: T): number {
      return value.length;
    }
    

Summary

Generics are powerful. Key takeaways:

  • Use generics for reusable code
  • Constrain types appropriately
  • Use keyof for type safety
  • Leverage type inference
  • Use conditional types
  • Apply to functions and classes
  • Maintain type safety
  • Document generic parameters

Next Steps

Comments