Skip to content

Advanced TypeScript Patterns — Builder, Strategy, Observer, Factory

DodaTech Updated 2026-06-28 9 min read

In this tutorial, you will learn about Advanced TypeScript Patterns. We cover key concepts, practical examples, and best practices to help you master this topic.

TypeScript's generics and interface system enable type-safe design patterns that prevent entire categories of runtime errors — patterns that in plain JavaScript rely on convention, TypeScript enforces through the compiler.

What You'll Learn

  • Builder pattern with typed steps
  • Strategy pattern with discriminated unions
  • Observer pattern with generics
  • Factory method pattern
  • Singleton pattern with module scope
  • Repository pattern with generics

Why It Matters

Design patterns solve recurring problems, but without TypeScript, they're just conventions that developers can violate. TypeScript's type system encodes the pattern rules into the compiler — if you use a pattern incorrectly, the code doesn't compile.

Real-World Use

The Durga Antivirus Pro threat detection engine uses multiple design patterns: Strategy pattern for different scanning algorithms, Observer for real-time threat notifications, and Builder for constructing scan configurations.

Learning Path

flowchart LR
  A[Testing] --> B[Advanced Patterns]
  B --> C[SOLID Principles]
  B --> D[You Are Here]
  C --> E[Error Handling]
  D --> F[Performance]

Builder Pattern

The Builder pattern constructs complex objects step by step. TypeScript's this typing ensures method chaining respects the builder state:

class EmailBuilder {
  private recipients: string[] = [];
  private subject = '';
  private body = '';
  private attachments: string[] = [];

  addTo(email: string): this {
    this.recipients.push(email);
    return this;
  }

  setSubject(subject: string): this {
    this.subject = subject;
    return this;
  }

  setBody(body: string): this {
    this.body = body;
    return this;
  }

  addAttachment(path: string): this {
    this.attachments.push(path);
    return this;
  }

  build(): Email {
    if (this.recipients.length === 0) {
      throw new Error('Email must have at least one recipient');
    }
    return {
      to: this.recipients,
      subject: this.subject,
      body: this.body,
      attachments: this.attachments,
    };
  }
}

interface Email {
  to: string[];
  subject: string;
  body: string;
  attachments: string[];
}

// Usage — TypeScript validates the chain
const email = new EmailBuilder()
  .addTo('alice@example.com')
  .addTo('bob@example.com')
  .setSubject('Meeting Reminder')
  .setBody('Don\'t forget the meeting at 3pm.')
  .build();

The this return type means TypeScript preserves the concrete type through the chain — if you extended EmailBuilder, chained methods return the subclass type.

Typed Builder with Generics

For builders that produce different types, use generics:

interface QueryBuilder<T> {
  where(field: keyof T, value: T[keyof T]): this;
  limit(n: number): this;
  execute(): Promise<T[]>;
}

class UserQueryBuilder implements QueryBuilder<User> {
  private conditions: string[] = [];
  private maxResults = 10;

  where(field: keyof User, value: User[keyof User]): this {
    this.conditions.push(`${String(field)} = ${value}`);
    return this;
  }

  limit(n: number): this {
    this.maxResults = n;
    return this;
  }

  async execute(): Promise<User[]> {
    const query = `SELECT * FROM users WHERE ${this.conditions.join(' AND ')} LIMIT ${this.maxResults}`;
    console.log('Executing:', query);
    return [];
  }
}

Strategy Pattern

The Strategy pattern lets you swap algorithms at runtime. TypeScript enforces that all strategies implement the same interface:

// Strategy interface
interface CompressionStrategy {
  compress(data: Buffer): Promise<Buffer>;
  decompress(data: Buffer): Promise<Buffer>;
  readonly name: string;
}

// Concrete strategies
class GzipStrategy implements CompressionStrategy {
  readonly name = 'gzip';

  async compress(data: Buffer): Promise<Buffer> {
    console.log('Compressing with gzip...');
    return data; // Simulated
  }

  async decompress(data: Buffer): Promise<Buffer> {
    console.log('Decompressing gzip...');
    return data;
  }
}

class DeflateStrategy implements CompressionStrategy {
  readonly name = 'deflate';

  async compress(data: Buffer): Promise<Buffer> {
    console.log('Compressing with deflate...');
    return data;
  }

  async decompress(data: Buffer): Promise<Buffer> {
    console.log('Decompressing deflate...');
    return data;
  }
}

// Context that uses the strategy
class Compressor {
  constructor(private strategy: CompressionStrategy) {}

  setStrategy(strategy: CompressionStrategy): void {
    this.strategy = strategy;
  }

  async compress(data: Buffer): Promise<Buffer> {
    console.log(`Using ${this.strategy.name} compression`);
    return this.strategy.compress(data);
  }
}

// Usage — TypeScript ensures strategy interface is satisfied
const compressor = new Compressor(new GzipStrategy());
await compressor.compress(Buffer.from('hello'));

compressor.setStrategy(new DeflateStrategy());
await compressor.compress(Buffer.from('hello'));

Observer Pattern

TypeScript generics make the Observer pattern type-safe:

type Observer<T> = (data: T) => void;

class Observable<T> {
  private observers: Set<Observer<T>> = new Set();

  subscribe(observer: Observer<T>): () => void {
    this.observers.add(observer);
    return () => this.observers.delete(observer);
  }

  notify(data: T): void {
    this.observers.forEach((observer) => observer(data));
  }
}

// Typed event system
interface FileEvent {
  type: 'created' | 'modified' | 'deleted';
  path: string;
  size?: number;
}

const fileWatcher = new Observable<FileEvent>();

// Subscribe — callback receives typed data
const unsubscribe = fileWatcher.subscribe((event: FileEvent) => {
  console.log(`File ${event.type}: ${event.path}`);
});

// Notify — TypeScript validates the shape
fileWatcher.notify({ type: 'created', path: '/tmp/test.txt', size: 1024 });
fileWatcher.notify({ type: 'deleted', path: '/tmp/test.txt' });

// Later
unsubscribe();

The generic Observable<T> ensures that every subscriber receives data of the same type and that notify() can only be called with valid data.

Factory Method Pattern

Factories centralize object creation. TypeScript ensures the factory returns the correct type:

interface Logger {
  log(message: string): void;
}

class ConsoleLogger implements Logger {
  log(message: string): void {
    console.log(`[Console] ${message}`);
  }
}

class FileLogger implements Logger {
  constructor(private filePath: string) {}

  log(message: string): void {
    console.log(`[File: ${this.filePath}] ${message}`);
  }
}

class DatabaseLogger implements Logger {
  log(message: string): void {
    console.log(`[Database] ${message}`);
  }
}

type LoggerType = 'console' | 'file' | 'database';

class LoggerFactory {
  static createLogger(type: LoggerType, config?: { filePath?: string }): Logger {
    switch (type) {
      case 'console':
        return new ConsoleLogger();
      case 'file':
        if (!config?.filePath) {
          throw new Error('filePath is required for FileLogger');
        }
        return new FileLogger(config.filePath);
      case 'database':
        return new DatabaseLogger();
      default:
        throw new Error(`Unknown logger type: ${type}`);
    }
  }
}

// Usage — TypeScript ensures only valid types are passed
const logger = LoggerFactory.createLogger('file', { filePath: '/var/log/app.log' });
logger.log('Application started');

The discriminated union LoggerType ensures you can't pass an invalid type at compile time.

Singleton Pattern

TypeScript modules naturally create singletons through module Caching:

// db.ts — singleton via module scope
let instance: DatabaseConnection | null = null;

class DatabaseConnection {
  private connected = false;

  private constructor(private url: string) {}

  static getInstance(url: string): DatabaseConnection {
    if (!instance) {
      instance = new DatabaseConnection(url);
    }
    return instance;
  }

  async connect(): Promise<void> {
    this.connected = true;
    console.log(`Connected to ${this.url}`);
  }

  isConnected(): boolean {
    return this.connected;
  }
}

export const db = DatabaseConnection.getInstance(process.env.DATABASE_URL!);

For a more idiomatic TypeScript approach, use the module system itself:

// config.ts — module-level singleton
export const appConfig = {
  port: parseInt(process.env.PORT || '3000', 10),
  nodeEnv: process.env.NODE_ENV || 'development',
  isProduction: (): boolean => appConfig.nodeEnv === 'production',
};

Repository Pattern

The Repository pattern abstracts data access. Generics make it reusable across entities:

interface Entity {
  id: string;
}

interface Repository<T extends Entity> {
  findById(id: string): Promise<T | null>;
  findAll(): Promise<T[]>;
  create(data: Omit<T, 'id'>): Promise<T>;
  update(id: string, data: Partial<T>): Promise<T>;
  delete(id: string): Promise<void>;
}

class InMemoryRepository<T extends Entity> implements Repository<T> {
  private items: Map<string, T> = new Map();

  async findById(id: string): Promise<T | null> {
    return this.items.get(id) ?? null;
  }

  async findAll(): Promise<T[]> {
    return Array.from(this.items.values());
  }

  async create(data: Omit<T, 'id'>): Promise<T> {
    const item = { ...data, id: crypto.randomUUID() } as T;
    this.items.set(item.id, item);
    return item;
  }

  async update(id: string, data: Partial<T>): Promise<T> {
    const existing = this.items.get(id);
    if (!existing) throw new Error(`Entity ${id} not found`);
    const updated = { ...existing, ...data };
    this.items.set(id, updated);
    return updated;
  }

  async delete(id: string): Promise<void> {
    this.items.delete(id);
  }
}

// Usage
interface User extends Entity {
  name: string;
  email: string;
}

const userRepo = new InMemoryRepository<User>();
const user = await userRepo.create({ name: 'Alice', email: 'alice@example.com' });
// user is typed as User

Common Mistakes

1. Over-engineering with patterns

Not every problem needs a design pattern. Start simple, add patterns when you see the need emerge.

2. Violating the Builder pattern's invariant

Builders should enforce required fields and validate in the build() method, not silently create invalid objects.

3. Memory leaks in Observer pattern

Always return an unsubscribe function and clean up observers when they're no longer needed.

4. Using new in the Factory Pattern

The factory method should handle object creation — clients should not also use new or the factory loses its value.

5. Accidental singleton state pollution

Singletons persist state across requests in server environments. Use request-scoped instances instead.

6. Making Repository too generic

Repositories for simple CRUD are fine, but complex queries should use query-specific methods.

7. Ignoring TypeScript-specific pattern optimizations

Discriminated unions can replace some Strategy patterns, and module-level singletons are simpler than class-based ones.

Practice Questions

  1. What does this return type do in the Builder pattern? It ensures method chaining returns the concrete subclass type, not the base class type, enabling proper inheritance.

  2. How does the Strategy pattern differ from a simple if-else chain? Strategy defines a family of interchangeable algorithms through a common interface. New strategies can be added without modifying existing code.

  3. Why use Observable<T> instead of a plain callback list? The generic ensures all subscribers and the notification method agree on the data type, preventing type mismatches.

  4. When should you use the Repository pattern? When you need to abstract data storage so business logic doesn't depend on the database implementation.

  5. What's the TypeScript module singleton alternative to the class Singleton pattern? Export a single instance from a module. Module caching ensures the same instance is used throughout the application.

Challenge

Implement a type-safe event bus using the Observer pattern with typed event names and payloads. Support event filtering, wildcard subscriptions, and one-time listeners.

FAQ

Are design patterns still relevant in modern TypeScript?

Yes. Patterns solve architectural problems. TypeScript makes them better by enforcing the pattern rules through the type system.

What patterns are most useful in TypeScript?

Builder, Strategy, Repository, and Observer are the most practical. Factory and Singleton are well-supported by TypeScript's module system.

Should I use classes or functions for patterns?

Both work. Functions with closures can implement many patterns (Strategy, Observer) without classes. Use classes when you need mutable state and identity.

How does TypeScript improve the Factory pattern?

Discriminated unions as factory input types ensure you only pass valid options. Generic return types ensure the factory produces correctly typed objects.

Is the Singleton pattern considered an anti-pattern?

Singletons get overused and complicate testing. In TypeScript, module-level exports provide a cleaner singleton behavior without the pattern overhead.

Can I combine multiple patterns?

Yes. Patterns compose naturally — a Repository can use a Factory to create entities, and an Observer can notify subscribers when the repository changes.

Mini Project

Build a type-safe plugin system:

  • Strategy pattern: Each plugin must implement a Plugin interface
  • Observer pattern: Plugin lifecycle events (loaded, enabled, disabled, unloaded)
  • Factory pattern: Plugin factory that discovers and instantiates plugins
  • Repository pattern: Plugin storage and retrieval
  • Builder pattern: Plugin configuration builder

What's Next

You've mastered advanced patterns with TypeScript. Now learn how to apply Clean Architecture principles with {{< ref "50-solid-principles" >}}, or handle errors systematically with {{< ref "51-error-handling" >}}.

For performance optimization, see {{< ref "54-performance" >}}.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro