A Practical Design Pattern Decision Guide for TypeScript

A Practical Design Pattern Decision Guide for TypeScript

Updated August 29, 2026
8 min readEstimated reading time: 8 minutes

A practical design pattern decision guide for TypeScript

Design patterns are names for recurring design problems. They are useful when a name helps a team discuss a boundary. They are not a reason to turn every function into a class hierarchy.

The useful question is not, "Which pattern should I use?" It is, "What is changing, and where should that change stop?" In TypeScript, the answer is often a function, an object literal, or a module. More structure is justified when it makes a boundary easier to test, replace, or understand.

This guide focuses on five related decisions:

  • Factory or Factory Method when object creation varies.
  • Builder when construction has many optional parts or validation steps.
  • Adapter when one interface must fit another.
  • Anti-Corruption Layer when an entire boundary has different domain concepts.
  • Strategy when a family of algorithms should be interchangeable.

The examples are intentionally small. If a pattern cannot be explained with a small example, the design probably needs another pass.

Begin with the change

Before introducing a pattern, write down three things:

  1. The behavior or dependency that varies.
  2. The code that should not know about that variation.
  3. The smallest boundary that can contain it.

That process prevents a common mistake: choosing a pattern because the code resembles a diagram rather than because the system has the problem the pattern addresses.

TypeScript helps define those boundaries. Its type system is structural, so a value can satisfy an interface because it has the required shape, not because it extends a particular class. Unions can describe a fixed set of choices, and generics can preserve relationships between inputs and outputs. None of this replaces runtime validation for data arriving from a network or another process.

That distinction matters throughout this article. A type annotation documents and checks the code TypeScript can see. It does not validate an untrusted JSON response at runtime.

Factory and Factory Method: vary creation

The practical choice: a factory function

Use a factory when callers need a product interface but should not know which concrete implementation to create. A factory is a good fit when the choice depends on configuration, a request, or another runtime value.

For many TypeScript applications, a function is enough:

interface Notifier {
  send(message: string): Promise<void>;
}

class EmailNotifier implements Notifier {
  async send(message: string): Promise<void> {
    console.log(`Email: ${message}`);
  }
}

class SmsNotifier implements Notifier {
  async send(message: string): Promise<void> {
    console.log(`SMS: ${message}`);
  }
}

type NotificationChannel = 'email' | 'sms';

function createNotifier(channel: NotificationChannel): Notifier {
  switch (channel) {
    case 'email':
      return new EmailNotifier();
    case 'sms':
      return new SmsNotifier();
  }
}

const notifier = createNotifier('email');
await notifier.send('Your report is ready');

The union type keeps callers from passing an arbitrary channel. The switch also gives the compiler a useful place to report a missing case when the union grows.

This is often called a factory function or a simple factory. It is useful, but it is not automatically the classic Factory Method pattern.

When Factory Method is the better name

Factory Method defines an operation for creating a product while allowing a subclass or implementation to decide which concrete product is returned. The creation hook is part of a larger workflow owned by the creator.

interface Parser {
  parse(input: string): Record<string, unknown>;
}

class JsonParser implements Parser {
  parse(input: string): Record<string, unknown> {
    const value: unknown = JSON.parse(input);

    if (typeof value !== 'object' || value === null || Array.isArray(value)) {
      throw new Error('Expected a JSON object');
    }

    return value as Record<string, unknown>;
  }
}

abstract class ImportJob {
  run(input: string): Record<string, unknown> {
    const parser = this.createParser();
    return parser.parse(input);
  }

  protected abstract createParser(): Parser;
}

class JsonImportJob extends ImportJob {
  protected createParser(): Parser {
    return new JsonParser();
  }
}

Here, run owns the workflow and createParser is the extension point. If all you need is a lookup from a string to a constructor, a factory function or registry is easier to read and test.

Choose Factory when

  • the product has a stable interface;
  • the concrete type depends on runtime input;
  • construction belongs in one small boundary.

Do not choose Factory when

  • there is only one concrete type;
  • a direct constructor call communicates the design clearly;
  • the factory is only hiding a two-line new expression.

Builder: make complex construction readable

Builder separates the steps used to assemble an object from the finished object. It is useful when an object has several optional parts, when construction has meaningful validation, or when the same construction process can produce different representations.

TypeScript object literals already solve many builder-shaped problems. Prefer them when the input is small and the final shape is obvious:

interface HttpRequestOptions {
  url: string;
  method?: 'GET' | 'POST';
  headers?: Record<string, string>;
  body?: string;
}

const request: HttpRequestOptions = {
  url: '/users',
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({ name: 'Ada' }),
};

An explicit builder earns its keep when it owns defaults and invariants:

interface HttpRequest {
  readonly url: string;
  readonly method: 'GET' | 'POST';
  readonly headers: Readonly<Record<string, string>>;
  readonly body?: string;
}

class HttpRequestBuilder {
  private method: HttpRequest['method'] = 'GET';
  private headers: Record<string, string> = {};
  private body?: string;

  constructor(private readonly url: string) {}

  withMethod(method: HttpRequest['method']): this {
    this.method = method;
    return this;
  }

  withHeader(name: string, value: string): this {
    this.headers[name] = value;
    return this;
  }

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

  build(): HttpRequest {
    if (this.method === 'GET' && this.body !== undefined) {
      throw new Error('GET requests cannot have a body in this example');
    }

    return {
      url: this.url,
      method: this.method,
      headers: { ...this.headers },
      body: this.body,
    };
  }
}

const apiRequest = new HttpRequestBuilder('/users')
  .withMethod('POST')
  .withHeader('content-type', 'application/json')
  .withBody(JSON.stringify({ name: 'Ada' }))
  .build();

The builder is mutable while it is being assembled, but the returned request is read-only from the caller's perspective. The copy of headers also prevents later builder changes from mutating the built value.

Choose Builder when

  • construction has several meaningful steps;
  • defaults and validation belong together;
  • a long positional argument list is hiding intent.

Prefer an object or factory when

  • there are only a few options;
  • construction has no sequence or validation;
  • the builder would only forward every field to a constructor.

Adapter: translate one interface

An Adapter lets a client use an existing object through the interface it already expects. The adapter changes the boundary, not the underlying service.

For example, an application can keep its payment port independent of a legacy provider that uses cents and a different method name:

interface PaymentGateway {
  charge(amountInCents: number): Promise<string>;
}

class LegacyPayments {
  transfer(cents: number): Promise<{ reference: string }> {
    return Promise.resolve({ reference: `legacy-${cents}` });
  }
}

class LegacyPaymentsAdapter implements PaymentGateway {
  constructor(private readonly payments: LegacyPayments) {}

  async charge(amountInCents: number): Promise<string> {
    const result = await this.payments.transfer(amountInCents);
    return result.reference;
  }
}

const gateway: PaymentGateway = new LegacyPaymentsAdapter(new LegacyPayments());

The client depends on PaymentGateway, not on LegacyPayments. That makes the dependency visible and makes a fake gateway straightforward to provide in a test.

Keep conversion rules at the edge. In real payment code, do not convert money through an unchecked floating-point dollar value. Accept a minor-unit integer or a domain-specific money type, validate currency, and let the provider-specific adapter handle its own request format.

Choose Adapter when

  • one dependency has the wrong method names, data shape, or units;
  • the dependency cannot or should not be changed;
  • the translation is local to one client or integration.

Anti-Corruption Layer: protect a domain boundary

An Adapter and an Anti-Corruption Layer are related, but they are not interchangeable names.

An adapter usually translates one interface. An Anti-Corruption Layer protects one subsystem from another subsystem's models and semantics. It can include facades, adapters, DTO mapping, validation, error translation, and protocol handling. Microsoft describes it as a translation boundary that lets systems communicate without forcing one system to adopt the other's design.

Imagine a new order domain integrating with a legacy order system:

interface Order {
  id: string;
  totalInCents: number;
}

interface LegacyOrderClient {
  createOrder(payload: { order_no: string; total: string }): Promise<{ order_id: string }>;
}

class LegacyOrderAcl {
  constructor(private readonly client: LegacyOrderClient) {}

  async create(order: Order): Promise<{ id: string }> {
    if (!Number.isInteger(order.totalInCents) || order.totalInCents < 0) {
      throw new Error('Order total must be a non-negative integer');
    }

    const response = await this.client.createOrder({
      order_no: order.id,
      total: order.totalInCents.toString(),
    });

    return { id: response.order_id };
  }
}

The ACL is a good home for translation and boundary validation. It is not a good home for unrelated orchestration or new business rules. It also has a cost: another component to operate, observe, scale, and retire. Microsoft specifically calls out added latency and the need to monitor consistency, so use an ACL when the semantic boundary is worth protecting.

Strategy: vary an algorithm

Strategy encapsulates a family of algorithms behind a common contract. The context delegates the algorithm instead of containing every branch itself.

In TypeScript, a strategy can be an interface, a class, or a function. Functions are often the clearest option for stateless behavior:

type ShippingStrategy = (weightInGrams: number) => number;

const standardShipping: ShippingStrategy = weightInGrams => 500 + Math.ceil(weightInGrams / 1000) * 100;

const freeShipping: ShippingStrategy = () => 0;

function calculateTotal(subtotalInCents: number, weightInGrams: number, shipping: ShippingStrategy): number {
  return subtotalInCents + shipping(weightInGrams);
}

const total = calculateTotal(2_500, 1_700, standardShipping);

Use a class or interface when a strategy needs dependencies, multiple operations, or state. The important part is the replaceable behavior, not the ceremony around it.

Choose Strategy when

  • the algorithm varies independently of the caller;
  • the choice can be made at runtime or at composition time;
  • a conditional block is growing separate rules and tests.

Prefer a conditional or lookup when

  • there are only two trivial branches;
  • the behavior is not reused;
  • introducing a strategy would make navigation harder than the original code.

The decision matrix

Problem Start with Add the pattern when Main cost
Choose a concrete product Factory function Creation rules or product types are spreading Another creation boundary
Let a creator subclass choose a product Factory Method A larger workflow needs an overridable creation hook Inheritance or extra implementations
Assemble an object with options and invariants Object literal or factory Construction has enough steps to deserve its own API Mutable builder state
Fit one dependency to a local port Adapter Names, shapes, units, or errors need translation Mapping code to maintain
Protect a domain from another subsystem Anti-Corruption Layer Models and semantics differ across a meaningful boundary Latency, operations, and consistency work
Swap a family of algorithms Function or conditional Algorithms need independent tests, dependencies, or selection More objects or indirection

A small decision tree

<!-- diagram: decision-design-patterns -->
graph TD
    A[Start with the change] --> B{Is creation the problem?}
    B -->|Yes| C{Does a larger workflow need an overridable creation hook?}
    C -->|Yes| D[Factory Method]
    C -->|No| E{Are there many construction steps or invariants?}
    E -->|Yes| F[Builder]
    E -->|No| G[Factory function or constructor]
    B -->|No| H{Are two interfaces or data shapes incompatible?}
    H -->|Local dependency| I[Adapter]
    H -->|Whole subsystem boundary| J[Anti-Corruption Layer]
    H -->|No| K{Does an algorithm vary?}
    K -->|Yes| L[Strategy or function]
    K -->|No| M[Keep the simpler design]

What TypeScript changes

TypeScript makes lightweight designs more expressive, but it does not make patterns unnecessary or safe by default.

  • Use literal unions for closed choices such as notification channels.
  • Use interfaces for ports that should be implemented by unrelated objects.
  • Use generics when an abstraction must preserve a type relationship.
  • Prefer unknown and runtime validation at external boundaries over any.
  • Remember that assertions are removed at compile time and do not validate data.

The language gives you tools to state a boundary. The architecture still has to decide where that boundary belongs.

Final rule

Pick the pattern that isolates the change you actually have.

Use a factory for creation choices, a Factory Method for an overridable creation hook, a Builder for involved assembly, an Adapter for a local interface mismatch, an Anti-Corruption Layer for a semantic subsystem boundary, and a Strategy for replaceable algorithms.

If a function or object literal solves the problem, use it. A pattern should make the next change smaller, not make the current code more impressive.

References

Share this article