Home / Articles / Choosing TypeScript Patterns by the Complexity They Remove

This article is published in English.

Choosing TypeScript Patterns by the Complexity They Remove

A tour of classic design patterns and type-level TypeScript techniques, with clear guidance on when each one earns its place and when plain code wins.

5991 words

Most teams adopt TypeScript for autocomplete and typo detection, then discover its real value: it lets you encode design decisions so the compiler enforces them. This guide walks through the classic object-oriented patterns and the type-level techniques that matter most in large frontend and full-stack codebases, and shows how to decide when a pattern is worth its cost.

Think of the type system as something that can answer architectural questions:

  • Is this combination of state values actually possible?
  • Could this API hand back a shape the rest of the code does not expect?
  • Can a ProductId slip into a function that wants a UserId?
  • Can a component be given props that contradict each other?
  • If a new state is added, will every consumer be forced to handle it?
  • Can an implementation be swapped without touching its callers?
  • Can an abstraction be reused without falling back to any?

A pattern is not a feature you bolt on. It is a name for a solution you recognize once a recurring problem shows up.

Mapping the landscape

The classic split covers creational, structural and behavioral patterns; TypeScript adds a fourth family of type-level techniques built on generics, unions and safety tools.

                     TYPESCRIPT PATTERNS
                                │
        ┌───────────────────────┼────────────────────────┐
        │                       │                        │
        ▼                       ▼                        ▼
   CREATIONAL              STRUCTURAL                BEHAVIORAL
        │                       │                        │
        ├─ Factory              ├─ Adapter              ├─ Strategy
        ├─ Builder              ├─ Facade               ├─ Observer
        ├─ Singleton            ├─ Decorator            ├─ Command
        └─ Abstract Factory     ├─ Repository           └─ State
                                └─ Composition

                                +

                    TYPESCRIPT TYPE PATTERNS
                                │
        ┌───────────────────────┼────────────────────────┐
        │                       │                        │
        ▼                       ▼                        ▼
    Generics                Unions                  Type Safety
        │                       │                        │
        ├─ Constraints          ├─ Discriminated         ├─ Type Guards
        ├─ keyof                  Unions                ├─ Branded Types
        ├─ typeof               ├─ Result Types          ├─ Exhaustiveness
        ├─ infer                └─ State Modeling        └─ satisfies
        └─ Mapped Types

Real applications rarely use one pattern alone. A typical React data flow stacks several of them, and every boundary can carry precise types:

React Component
      │
      ▼
Custom Hook
      │
      ▼
Service
      │
      ▼
Repository
      │
      ▼
API Client
      │
      ▼
Result<T, E>
      │
      ▼
Discriminated Union

When types flow through that whole chain, TypeScript becomes a description of how the system is allowed to behave.

Start from the problem, not the pattern

A common trap is reasoning in the wrong direction:

"I know Factory Pattern.
Where can I use Factory?"

Picking a pattern first produces abstractions nobody needed. Let the problem drive the choice instead:

What problem do I have?
        ↓
Where is the complexity?
        ↓
What is changing frequently?
        ↓
What should remain stable?
        ↓
What abstraction reduces that complexity?
        ↓
Is a known pattern appropriate?

Consider a payment flow that has grown a series of checks on the payment method:

if (paymentMethod === "card") {
  // ...
}

if (paymentMethod === "paypal") {
  // ...
}
if (paymentMethod === "upi") {
  // ...
}

The reflex response is often this:

Don’t immediately think:

"This needs Strategy." Before reaching for it, ask whether the branches really are interchangeable algorithms behind one contract. If they are, Strategy fits. If the real problem is that some fields only make sense for some methods, a discriminated union that makes invalid combinations unrepresentable may be the better tool. Knowing the catalog is easy; matching an entry to a specific pressure is the skill.

Creational patterns

Singleton: one shared instance

A Singleton ensures a class has exactly one instance. The private constructor blocks outside new, and a static method lazily creates and caches the instance:

class Logger {
  private static instance: Logger;

private constructor() {}
  static getInstance(): Logger {
    if (!Logger.instance) {
      Logger.instance = new Logger();
    }
    return Logger.instance;
  }
  log(message: string) {
    console.log(message);
  }
}

Callers ask for the shared object instead of constructing one:

const logger = Logger.getInstance();
logger.log("Application started");

Every consumer ends up pointing at the same object:

                  Logger
                    │
             getInstance()
                    │
                    ▼
             ┌───────────┐
             │  Logger   │
             │ Instance  │
             └───────────┘
               ▲       ▲
               │       │
          Service A  Service B

Reasonable candidates include:

  • logging
  • analytics managers
  • configuration holders
  • some connection managers
  • other cross-cutting infrastructure services

The catch is that a Singleton is global access in disguise: tests become harder to isolate, dependencies vanish from signatures, lifecycle gets murky, and shared mutable state changes in untraceable ways. In a modern frontend, an instance exported from an ES module is already shared, and dependency injection, React Context or a state library give the same guarantee with visible wiring.

Factory: hide which class gets built

A Factory takes the decision of which concrete class to instantiate away from the consumer. Compare direct construction:

const payment = new StripePayment();

with delegated construction:

const payment = PaymentFactory.create("stripe");

Both providers implement one interface, and the factory maps a string literal union to the right class. Because the parameter type is "stripe" | "paypal", an unsupported name fails to compile:

interface PaymentProvider {
  pay(amount: number): Promise<void>;
}

class StripePayment implements PaymentProvider {
  async pay(amount: number) {
    console.log("Stripe:", amount);
  }
}
class PayPalPayment implements PaymentProvider {
  async pay(amount: number) {
    console.log("PayPal:", amount);
  }
}
class PaymentFactory {
  static create(
    provider: "stripe" | "paypal"
  ): PaymentProvider {
    switch (provider) {
      case "stripe":
        return new StripePayment();
      case "paypal":
        return new PayPalPayment();
    }
  }
}

Visually, the factory is a fork keyed by the provider name:

              PaymentFactory
                       │
          ┌────────────┴────────────┐
          │                         │
       "stripe"                  "paypal"
          │                         │
          ▼                         ▼
     StripePayment             PayPalPayment

A factory pays off when:

  • construction involves real logic
  • several implementations share one contract
  • consumers should not know concrete classes
  • implementations must evolve independently of callers

For trivial construction like the line below, it only adds a layer to read through:

new User();

Abstract Factory: families of related objects

Abstract Factory extends the idea to groups of objects that must match. In a UI kit targeting several platforms, a web button should never be paired with a mobile modal, so each factory produces one consistent family:

interface Button {
  render(): void;
}

interface Modal {
  open(): void;
}

interface UIFactory {
  createButton(): Button;
  createModal(): Modal;
}
                  UIFactory
                    │
           ┌────────┴────────┐
           ▼                 ▼
     WebUIFactory       MobileUIFactory
           │                 │
      ┌────┴────┐       ┌────┴────┐
      ▼         ▼       ▼         ▼
   Button     Modal   Button     Modal

It is powerful and easy to overbuild. In most frontend applications, plain component composition gets you there with less ceremony.

Builder: controlled, step-by-step construction

Builder helps when an object has many optional settings or is configured in stages. Each setter updates private config and returns this, enabling chaining:

class RequestBuilder {
  private config: RequestInit = {};

setMethod(method: string) {
    this.config.method = method;
    return this;
  }
  setHeaders(headers: HeadersInit) {
    this.config.headers = headers;
    return this;
  }
  setBody(body: BodyInit) {
    this.config.body = body;
    return this;
  }
  build() {
    return this.config;
  }
}

Assembling a request reads as explicit steps:

const request = new RequestBuilder()
  .setMethod("POST")
  .setHeaders({
    "Content-Type": "application/json"
  })
  .setBody(JSON.stringify(data))
  .build();

Fluent syntax is a side effect, not the goal. The point is to keep complicated construction explicit and in one place, where build() can also validate, for example refusing a body on a GET request.

Structural patterns

Adapter: a stable contract around someone else's API

Adapter is one of the most frequently used patterns in application code. Suppose your code expects this internal contract:

interface PaymentGateway {
  pay(amount: number): Promise<void>;
}

while a third-party SDK exposes a differently named method:

class LegacyPaymentSDK {
  makePayment(value: number) {
    // third-party implementation
  }
}

A thin adapter implements your interface and translates the calls:

class PaymentAdapter implements PaymentGateway {
  constructor(
    private readonly sdk: LegacyPaymentSDK
  ) {}

async pay(amount: number) {
    this.sdk.makePayment(amount);
  }
}
Application
     │
     ▼
PaymentGateway
     ▲
     │
PaymentAdapter
     │
     ▼
Third-party SDK

The application now depends on one interface you own:

PaymentGateway

rather than on every vendor behind it:

Stripe
PayPal
LegacySDK
SomeFutureProvider

Supporting a new provider means writing another adapter.

Facade: one call for a multi-step workflow

A Facade puts a simple entry point in front of a complicated subsystem. Logging in might touch several services:

Authentication
     +
User Service
     +
Permissions
     +
Notification
     +
Analytics

Without a facade, every component that logs a user in orchestrates the sequence itself:

auth.login();
user.load();
permission.load();
analytics.track();

A facade wraps that orchestration once:

class AppFacade {
  async login(username: string, password: string) {
    const token = await auth.login(username, password);

const user = await userService.getUser(token);
    await permissionService.load(user);
    analytics.track("login");
    return user;
  }
}

and the component shrinks to one call:

await appFacade.login(username, password);
               Component
                     │
                     ▼
                 AppFacade
                     │
       ┌─────────────┼─────────────┐
       ▼             ▼             ▼
     Auth          User        Permission
    Service       Service       Service

This shines when step order matters.

Decorator: add behavior by wrapping

A Decorator adds behavior without changing the original implementation, because wrapper and wrapped object share an interface. The contract:

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

A plain implementation:

class ConsoleLogger implements Logger {
  log(message: string) {
    console.log(message);
  }
}

A decorator that holds any Logger and prefixes messages with a timestamp:

class TimestampLogger implements Logger {
  constructor(
    private readonly logger: Logger
  ) {}

log(message: string) {
    this.logger.log(
      `[${new Date().toISOString()}] ${message}`
    );
  }
}

Wrapping is just construction:

const logger = new TimestampLogger(
  new ConsoleLogger()
);

Since each decorator is itself a Logger, they stack:

Logger
  │
  ▼
ConsoleLogger
  │
  ▼
TimestampLogger
  │
  ▼
AdditionalDecorator

The same idea appears under other names:

  • middleware chains
  • wrapper functions
  • React Higher-Order Components
  • logging
  • caching layers
  • authorization checks
  • instrumentation and tracing

Behavioral patterns

Strategy: interchangeable algorithms

Strategy removes branching business rules. Take a customer tier type:

type CustomerType =
  | "regular"
  | "premium"
  | "enterprise";

A conditional implementation puts every rule in one function:

function calculateDiscount(
  type: CustomerType,
  price: number
) {
  if (type === "regular") {
    return price;
  }

if (type === "premium") {
    return price * 0.9;
  }
  return price * 0.8;
}

With Strategy, each rule becomes a class behind a shared interface:

interface DiscountStrategy {
  calculate(price: number): number;
}

class RegularDiscount implements DiscountStrategy {
  calculate(price: number) {
    return price;
  }
}
class PremiumDiscount implements DiscountStrategy {
  calculate(price: number) {
    return price * 0.9;
  }
}
class EnterpriseDiscount implements DiscountStrategy {
  calculate(price: number) {
    return price * 0.8;
  }
}
                  Order
                      │
                      ▼
              DiscountStrategy
                      │
          ┌───────────┼───────────┐
          ▼           ▼           ▼
       Regular      Premium    Enterprise
       Strategy     Strategy    Strategy

Adding a tier now usually means adding a strategy rather than editing existing logic, which is the Open/Closed Principle in practice. For three tiny rules the conditional is fine; Strategy pays off once rules grow their own dependencies or tests.

Observer: one-to-many notifications

Observer lets one subject notify any number of listeners when something changes:

                 Subject
                     │
          ┌──────────┼──────────┐
          ▼          ▼          ▼
      Observer A Observer B Observer C

A small typed emitter stores listeners in a Set and returns a cleanup function when a listener is registered:

type Listener<T> = (value: T) => void;

class EventEmitter<T> {
  private listeners = new Set<Listener<T>>();
  subscribe(listener: Listener<T>) {
    this.listeners.add(listener);
    return () => {
      this.listeners.delete(listener);
    };
  }
  emit(value: T) {
    this.listeners.forEach(listener => {
      listener(value);
    });
  }
}
const emitter = new EventEmitter<string>();

const unsubscribe = emitter.subscribe(message => {
  console.log(message);
});
emitter.emit("Hello");
unsubscribe();

That cleanup function is lifecycle management. Forgetting to call it leads to:

  • memory leaks from listeners that outlive their owners
  • duplicated handling when a listener is attached twice
  • stale closures reading outdated values
  • side effects firing after a component is gone

In React, it is exactly what you return from a useEffect callback.

Command: actions as objects

Command turns an action into an object with a uniform interface:

interface Command {
  execute(): void;
}

Concrete commands implement it:

class SaveCommand implements Command {
  execute() {
    console.log("Saving...");
  }
}

class UndoCommand implements Command {
  execute() {
    console.log("Undo");
  }
}

Treating actions as values helps when you need:

  • a history of what happened
  • undo and redo
  • queued execution
  • retries
  • deferred execution
  • audit logging

Real undo usually requires each command to reverse itself, so production versions often add an undo() method next to execute().

User Action
    │
    ▼
 Command
    │
    ├── execute()
    │
    ▼
 Receiver

Data and composition patterns

Repository: isolate data access

Once data access becomes non-trivial, a Repository places a contract between business logic and whatever stores or fetches the data:

UI
 │
 ▼
Hook / Controller
 │
 ▼
Service
 │
 ▼
Repository
 │
 ├── REST
 ├── GraphQL
 ├── IndexedDB
 └── Cache

The contract describes what the app can ask for:

interface UserRepository {
  getUser(id: string): Promise<User>;
  getUsers(): Promise<User[]>;
}

One implementation talks to a REST API:

class ApiUserRepository implements UserRepository {
  async getUser(id: string) {
    const response = await fetch(`/users/${id}`);

return response.json();
  }
  async getUsers() {
    const response = await fetch("/users");
    return response.json();
  }
}

Business code depends on the interface:

UserRepository

not on a transport:

fetch()
axios()
graphqlClient()

This matters when the source changes: REST to GraphQL, an added IndexedDB cache, or an in-memory fake for tests. Note that response.json() returns an untyped value, so the repository is also the right place to validate responses.

Composition over inheritance

In frontend work, composition matters more than any inheritance-based pattern. Instead of one component that owns everything:

MegaComponent
 ├── Authentication
 ├── Table
 ├── Filters
 ├── Modal
 ├── Notifications
 ├── API calls
 └── Business logic

split responsibilities into focused pieces:

Dashboard
 ├── Header
 ├── Sidebar
 ├── FilterPanel
 ├── DataTable
 └── NotificationPanel

In React this is simply nesting components:

<Dashboard>
  <Header />
  <Sidebar />
  <MainContent />
</Dashboard>

Each piece can be understood, tested and replaced on its own. For more on breaking up bloated components, see fixing React prop overload with composition and slots.

Modelling state with the type system

From here on, TypeScript itself is the architectural tool.

Discriminated unions for request state

A request moves through phases that carry different data. A discriminated union gives each phase its own shape, tied together by a literal status field:

type RequestState =
  | {
      status: "idle";
    }
  | {
      status: "loading";
    }
  | {
      status: "success";
      data: User[];
    }
  | {
      status: "error";
      error: string;
    };

Switching on the discriminant narrows the type in each branch:

function render(state: RequestState) {
  switch (state.status) {
    case "idle":
      return "Nothing started";

    case "loading":
      return "Loading...";
    case "success":
      return state.data;
    case "error":
      return state.error;
  }
}

The compiler tracks which fields exist after each check:

status = "success"
        ↓
data exists

status = "error"
        ↓
error exists

Compare the common boolean-and-optionals model:

interface State {
  loading: boolean;
  data?: User[];
  error?: string;
}

Nothing stops it from describing a state that should never occur:

{
  loading: true,
  data: [...],
  error: "Something failed"
}

The union makes such combinations very hard to express. Model the valid states instead of scattering optional properties and trusting everyone to combine them correctly.

Result types for explicit failure

Many operations have exactly two outcomes:

Success
   OR
Failure

A Result type spells both out with a boolean discriminant:

type Result<T, E> =
  | {
      success: true;
      data: T;
    }
  | {
      success: false;
      error: E;
    };

A function returning it:

function getUser(): Result<User, string> {
  return {
    success: true,
    data: user
  };
}

The caller must check success before touching data or error:

const result = getUser();

if (result.success) {
  console.log(result.data);
} else {
  console.error(result.error);
}
             Service
                │
                ▼
          Result<T, E>
           /       \
          /         \
         ▼           ▼
      Success      Failure
        │             │
       data          error

This fits expected business failures such as "email already registered". Exceptions still suit genuine bugs and infrastructure failures; the Result type just makes anticipated failures visible in the signature.

Generics and type-level tools

Generics preserve the link between input and output

Using any throws information away:

function identity(value: any): any {
  return value;
}

A type parameter keeps it:

function identity<T>(value: T): T {
  return value;
}

The inferred result follows the argument:

const a = identity("hello");
// string

const b = identity(100);
// number

The relationship between input and output survives:

Input T
  │
  ▼
Function<T>
  │
  ▼
Output T

Generics also make shared contracts reusable. One response envelope:

interface ApiResponse<T> {
  data: T;
  status: number;
  message: string;
}

describes many payloads:

type UserResponse =
  ApiResponse<User>;

type ProductResponse =
  ApiResponse<Product>;

Constraints: requiring a shape

This fails to compile:

function getId<T>(item: T) {
  return item.id;
}

because nothing tells TypeScript that T has an id. A constraint adds that guarantee:

function getId<T extends { id: string }>(
  item: T
) {
  return item.id;
}

Any object with a string id is accepted, extra fields included:

getId({
  id: "123",
  name: "Hareesh"
});

Read the constraint like this:

T can be anything
BUT
T must have id: string

keyof and indexed access

Given an interface:

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

keyof produces the union of its property names:

type UserKey = keyof User;
"id" | "name" | "age"

Combining keyof with a second type parameter and the indexed access type T[K] yields an accessor whose return type matches the key:

function getProperty<T, K extends keyof T>(
  object: T,
  key: K
): T[K] {
  return object[key];
}
const user = {
  id: "1",
  name: "Hareesh",
  age: 30
};

getProperty(user, "name");

A real key compiles:

getProperty(user, "name");

A missing key is a compile error:

getProperty(user, "salary");

The strength comes from three tools working together:

Generics
   +
keyof
   +
Indexed Access

Mapped types: transform an existing type

Mapped types iterate over a type's keys to build a new type. Starting from:

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

you can derive an all-optional version:

type OptionalUser = {
  [K in keyof User]?: User[K];
};

conceptually equal to writing:

{
  id?: string;
  name?: string;
  email?: string;
}

This is how built-ins such as Partial are defined.

Conditional types: decisions at the type level

Conditional types choose between two types based on assignability:

T extends U ? X : Y

This one unwraps array element types and leaves everything else alone:

type Flatten<T> =
  T extends Array<infer U>
    ? U
    : T;
type A = Flatten<string[]>;
// string

type B = Flatten<number>;
// number

At this point the type system behaves like a small compile-time language, which calls for restraint.

infer: extract part of a type

Inside a conditional type, infer declares a type variable that TypeScript fills in by matching. This re-implements the built-in ReturnType:

type MyReturnType<T> =
  T extends (...args: any[]) => infer R
    ? R
    : never;

Pair it with typeof to derive a type from an existing function, so it can never drift from the implementation:

function getUser() {
  return {
    id: "1",
    name: "Hareesh"
  };
}

type User = MyReturnType<typeof getUser>;

Library type definitions rely heavily on this.

Built-in utility types first

Before writing clever helpers, know what ships with the language:

Partial
Required
Readonly
Pick
Omit
Record
Exclude
Extract
NonNullable
ReturnType
Parameters
InstanceType
Awaited

Removing a sensitive field, for example, is one line instead of a duplicated interface that will fall out of sync:

interface User {
  id: string;
  name: string;
  email: string;
  password: string;
}

type PublicUser =
  Omit<User, "password">;

The guide to TypeScript's built-in utility types covers each of these in depth.

Record with a closed set of keys

Record suits lookups and configuration, especially when keys come from a literal union:

type Permission =
  "read" |
  "write" |
  "delete";

type PermissionMap =
  Record<Permission, boolean>;
const permissions: PermissionMap = {
  read: true,
  write: false,
  delete: false
};

Leave out a permission and TypeScript reports the missing key. A wide key type loses that guarantee:

const permissions: Record<string, boolean>

because Record<string, boolean> accepts practically any string key, so omissions and typos go unnoticed.

Safety patterns for identifiers and untrusted data

Branded types for domain identifiers

Two identifiers can both be strings yet mean different things:

const userId: string;
const productId: string;

Structurally, TypeScript cannot tell them apart. Intersecting string with a phantom property creates distinct types:

type UserId =
  string & {
    readonly __brand: "UserId";
  };

type ProductId =
  string & {
    readonly __brand: "ProductId";
  };

Functions can then demand the right kind of ID:

function getUser(id: UserId) {}

function getProduct(id: ProductId) {}

Passing a ProductId where a UserId is expected becomes a compile error. The brand never exists at runtime; you create branded values through one small constructor or validation function that casts in a single place. Conceptually:

string
  │
  ├── UserId
  ├── ProductId
  ├── OrderId
  └── TransactionId

This pays off in large systems where dozens of identifiers share one primitive type.

Type guards for unknown input

Data from the network, storage or user input should enter as unknown:

const data: unknown = await response.json();

A cast is the tempting shortcut:

const user = data as User;

Narrow the value with a user-defined type guard instead; its value is User return type tells the compiler what a true result proves:

function isUser(
  value: unknown
): value is User {
  return (
    typeof value === "object" &&
    value !== null &&
    "id" in value &&
    "name" in value
  );
}
if (isUser(data)) {
  console.log(data.name);
}

Remember that types are erased at runtime. An interface like this:

interface User {
  id: string;
}

validates nothing the API sends. The guard above only checks that keys exist, not their types, so for untrusted input pair TypeScript with a runtime schema validator.

Exhaustive checking with never

One of the most effective combinations in the language:

Discriminated Union
        +
never
        +
switch

Take a status union:

type Status =
  | "loading"
  | "success"
  | "error";

and a helper that only accepts never:

function assertNever(
  value: never
): never {
  throw new Error(
    `Unexpected value: ${value}`
  );
}

When every member is handled, the default branch sees type never, so the call compiles:

function render(status: Status) {
  switch (status) {
    case "loading":
      return "Loading";
    case "success":
      return "Success";
    case "error":
      return "Error";
    default:
      return assertNever(status);
  }
}

Now a teammate extends the union:

Now imagine someone adds:"cancelled" to Status.

The default branch receives "cancelled", which is not assignable to never, and the build fails until the case is handled. The compiler becomes a design reviewer that remembers every consumer.

Template literal types

TypeScript can compose string literal types from other types:

type Entity =
  "user" |
  "order" |
  "product";

type Event =
  `${Entity}:created` |
  `${Entity}:updated` |
  `${Entity}:deleted`;

The resulting union contains every combination:

user:created
user:updated
user:deleted

order:created
order:updated
order:deleted

product:created
product:updated
product:deleted

Practical uses include:

  • event names
  • analytics event keys
  • permission strings
  • route patterns
  • feature flag names
  • design system tokens

as const when values are the source of truth

An array literal of strings is widened:

const roles = [
  "admin",
  "editor",
  "viewer"
];

Its type is string[]. Adding as const produces a readonly tuple of literals:

const roles = [
  "admin",
  "editor",
  "viewer"
] as const;

from which a union type follows directly:

type Role =
  typeof roles[number];

becomes:


"admin" |
"editor" |
"viewer"

Define the values once and never maintain a parallel union by hand.

satisfies for checked configuration

satisfies validates an expression against a type without widening it to that type:

type Config = {
  retries: number;
  environment:
    | "development"
    | "production";
};

const config = {
  retries: 3,
  environment: "production"
} satisfies Config;

The object is checked against Config, yet config.environment keeps the literal type "production", which a plain annotation would lose. It suits:

  • route configuration
  • feature flags
  • design tokens
  • static settings objects
  • permission maps

Dependency injection

Creating dependencies inside a class welds it to them:

class UserService {
  private api = new ApiClient();
}

Receiving them through the constructor keeps the class agnostic:

class UserService {
  constructor(
    private readonly api: ApiClient
  ) {}
}

Production passes the real client:

const service =
  new UserService(apiClient);

and tests a mock:

const service =
  new UserService(mockApiClient);
                UserService
                      ▲
                      │
                 Dependency
                      │
             ┌────────┴────────┐
             ▼                 ▼
        ApiClient         MockApiClient
       Production            Testing

It is one of the cheapest routes to testable code, and constructor parameters are all it needs.

Telling similar patterns apart

State pattern or discriminated union?

Given an order lifecycle:

Draft
Paid
Shipped
Cancelled

a discriminated union may be all you need:

type Order =
  | { status: "draft" }
  | { status: "paid" }
  | { status: "shipped" }
  | { status: "cancelled" };

But when every state carries substantial behavior:

Draft
 ├── edit()
 ├── submit()

Paid
 ├── refund()
 ├── ship()
Shipped
 ├── track()
 └── deliver()

a State pattern, where each state object implements the allowed operations, may fit better. Decide by how much complexity each state holds, not by terminology.

Strategy or State?

A frequent interview topic. With Strategy, the client picks the algorithm:

Order
  │
  ▼
Strategy
  ├── CreditCard
  ├── PayPal
  └── UPI

With State, the object changes its own behavior as it moves through its lifecycle:

Order
  │
  ├── Draft
  ├── Paid
  └── Shipped

Strategy varies how a task is performed; State varies what an object does at its current stage.

Factory or Strategy?

A Factory answers "which object should be created?":

PaymentFactory.create("stripe");

A Strategy answers "which behavior should run?":

new Order(discountStrategy);

They combine naturally, a factory producing the strategy that does the work:

Factory
   ↓
creates
   ↓
Strategy
   ↓
executes behavior

Applying the patterns in React

Variant props instead of boolean flags

Independent booleans allow nonsense such as a button that is both primary and danger:

interface ButtonProps {
  primary?: boolean;
  danger?: boolean;
  loading?: boolean;
}

A union of variants lets each declare its own requirements:

type ButtonProps =
  | {
      variant: "primary";
      loading?: boolean;
    }
  | {
      variant: "danger";
      confirmationRequired: boolean;
    };

The danger variant must now specify confirmationRequired.

Component APIs that reject contradictions

A component that renders either a link or a button with optional href and onClick would allow both or neither. The snippet below shows the loose version, the discriminated alternative and a valid usage:

{
  href?: string;
  onClick?: () => void;
}

use:
type ActionProps =
  | {
      type: "link";
      href: string;
    }
  | {
      type: "button";
      onClick: () => void;
    };


Now:

<Action
  type="link"
  href="/users"
/>

A link variant given a click handler instead of an href is rejected:

<Action
  type="link"
  onClick={...}
/>

The supported modes, stated plainly:

Link
Button

TypeScript is now part of the component architecture rather than documentation that goes stale.

A type-safe event bus

Start with a map from event names to payload types:

type Events = {
  "user:created": User;
  "user:deleted": UserId;
  "order:created": Order;
};

A bus generic over that map ties each name to its payload through keyof and T[K]:

class EventBus<T extends Record<string, unknown>> {
  on<K extends keyof T>(
    event: K,
    handler: (data: T[K]) => void
  ) {
    // implementation
  }

emit<K extends keyof T>(
    event: K,
    data: T[K]
  ) {
    // implementation
  }
}

The correct payload compiles:

bus.emit("user:created", user);

The wrong one does not:

bus.emit("user:created", order);

Several tools meet here:

Generics
+
keyof
+
Indexed Access
+
Mapped Type thinking

Types across the whole API layer

A mature frontend often layers data access like this:

Component
     ↓
Hook
     ↓
Service
     ↓
Repository
     ↓
API Client
     ↓
HTTP

with types carried through every step. A repository can accept branded IDs and return a Result:

type ApiResponse<T> = {
  data: T;
  status: number;
};

interface UserRepository {
  getUser(id: UserId):
    Promise<Result<User, ApiError>>;
}

The component no longer deals with this at every layer:

any

The signatures themselves say what can succeed, what can fail and with which types.

Anti-patterns to avoid

any everywhere

function process(data: any) {}

An any parameter switches off checking for everything it touches. Prefer:

function process(data: unknown) {}

and narrow before use.

Assertions as validation

const user =
  response.data as User;

An as cast checks nothing; it only asks the compiler to trust you. Validate untrusted data at runtime.

Generics for their own sake

Avoid signatures like:

function transform<
  T,
  U,
  V,
  R
>(...) {}

unless each type parameter expresses a real relationship. A type parameter used once is usually unnecessary.

Enormous unions

A discriminated union with a hundred members is hard to navigate and evolve. The domain may need a different abstraction, such as nested unions or moving variation into data.

Type-level cleverness

When a type is harder to follow than the business logic it describes, step back:

Type complexity
       │
       ▼
Developer complexity
       │
       ▼
Maintenance cost

Type safety has a cost. Aim for the most useful safety per unit of complexity, not the most sophisticated types.

A decision tree for choosing

This tree maps common pressures to candidate patterns:

PROBLEM
   │
   ├── Need to create objects?
   │       ├── Simple creation → Constructor
   │       ├── Complex creation → Builder
   │       ├── Multiple implementations → Factory
   │       └── Families of objects → Abstract Factory
   │
   ├── Need to integrate another system?
   │       └── Adapter
   │
   ├── Complex subsystem?
   │       └── Facade
   │
   ├── Add behavior without modifying object?
   │       └── Decorator
   │
   ├── Multiple interchangeable algorithms?
   │       └── Strategy
   │
   ├── Subscribers react to changes?
   │       └── Observer
   │
   ├── Need actions/history/undo?
   │       └── Command
   │
   ├── Data-access abstraction?
   │       └── Repository
   │
   ├── Complex state lifecycle?
   │       └── State / Discriminated Union
   │
   └── Type-level problem?
           ├── Reuse → Generics
           ├── Transform → Mapped Types
           ├── Decision → Conditional Types
           ├── Extract → infer
           ├── Property safety → keyof
           ├── Literal safety → as const
           ├── Contract validation → satisfies
           └── Domain safety → Branded Types

Treat it as a starting point for discussion; "start simple" still applies at every leaf.

What to learn first

For frontend engineers preparing for senior or lead roles, memorizing every Gang of Four pattern is poor use of time. A practical order:

The essentials:

Discriminated Unions
Generics
Type Guards
keyof
Mapped Types
Utility Types
Composition
Strategy
Repository
Factory

Strongly recommended next:

Result Type
Branded Types
Conditional Types
infer
Exhaustive Checking
Dependency Injection
Adapter
Facade
Observer
Decorator

Worth understanding conceptually:

Builder
Command
State
Abstract Factory
Singleton

Day-to-day frontend work leans far more on this combination:

Generics
+
Discriminated Unions
+
Composition
+
Strategy
+
Repository

than on any textbook Abstract Factory.

How judgement shifts with experience

Early on, engineers ask "which pattern should I use here?" With experience on large systems, the question becomes "what is the smallest abstraction that solves this without making the system harder to understand?" The pattern-first workflow looks like this:

Problem
  ↓
Pattern
  ↓
More classes
  ↓
More abstractions

The problem-first workflow looks like this:

Problem
  ↓
Understand volatility
  ↓
Identify boundary
  ↓
Start simple
  ↓
Introduce abstraction only where repetition/change justifies it

Good patterns emerge as the architecture's pressures become clear; they are not imposed ahead of time.

Interview questions that probe real understanding

"What is the Factory pattern?" tests recall. These test judgement.

Architecture:

  1. Faced with a 2,000-line React component, how do you choose which abstraction to introduce?
  2. When would you turn down a pattern that seems to fit?
  3. How can you tell an abstraction is premature?
  4. When is composition preferable to inheritance?
  5. How do you keep a Singleton from becoming global mutable state?

TypeScript fundamentals:

  1. How would you model a request with loading, success, error and retry states?
  2. How do you stop invalid combinations of React props?
  3. How do unknown, any and never differ?
  4. When would you pick type over interface, and vice versa?
  5. How does keyof interact with generics?

Advanced TypeScript:

  1. What is a concrete use case for conditional types?
  2. What does infer do?
  3. What is a mapped type?
  4. What are distributive conditional types?
  5. When are branded types worth it?
  6. Which problem does satisfies solve?
  7. How does as const change inference?

Real-world design:

  1. Design a type-safe event bus.
  2. Design a type-safe API client.
  3. Design a permission system using TypeScript.
  4. Design a payment abstraction supporting Stripe, PayPal and a third provider.
  5. How would you add a Repository layer to an existing React app without a rewrite?
  6. How would you type WebSocket events with different payloads?
  7. How would you stop a ProductId being passed where a UserId is required?

A revealing one: "Describe a time you deliberately chose not to use a design pattern." A strong answer explains the trade-off: with a single implementation and no likely change to guard against, the indirection would not have reduced complexity, so the code stayed simple until a second use case justified it.

A final mental model

Start from the business problem, find where the complexity lives, separate behavioral change from structural change, then let type safety tighten the result.

                 BUSINESS PROBLEM
                        │
                        ▼
                Identify Complexity
                        │
                        ▼
             What is likely to change?
                        │
             ┌──────────┴──────────┐
             ▼                     ▼
          Behavior              Structure
             │                     │
        Strategy/State       Adapter/Facade
             │                     │
             └──────────┬──────────┘
                        ▼
                   Type Safety
                        │
        ┌───────────────┼────────────────┐
        ▼               ▼                ▼
     Generics        Unions           Utilities
        │               │                │
        ▼               ▼                ▼
     keyof          Result Type      Mapped Types
     infer          State Model      Conditional
     satisfies      Exhaustive       Record
        │               │                │
        └───────────────┼────────────────┘
                        ▼
                 SIMPLEER CODE

Key takeaways

Patterns work best as shared vocabulary: "this is an adapter", "these behaviors are interchangeable", "these states belong in a union", "brand these IDs", and, most valuably, "this abstraction has not earned its complexity yet." Good TypeScript is judged by whether the system has these properties, not by how clever its types are:

Invalid states
     ↓
become difficult to represent

Changing implementations
     ↓
don't break consumers
Business rules
     ↓
are visible in the types
Shared behavior
     ↓
is reusable without duplication
Complexity
     ↓
is isolated behind clear boundaries
  • Pick patterns by the complexity they remove, not by familiarity.
  • Prefer unions, Result types and exhaustive checks to optional fields and hope.
  • Validate data at runtime boundaries; types alone do not protect you there.
  • Reach for the simplest abstraction that handles the change you actually expect.