Advanced TypeScript Patterns for Building Bulletproof Applications

Advanced TypeScript Patterns for Building Bulletproof Applications

Advanced TypeScript Patterns for Building Bulletproof Applications

TypeScript has become the backbone of modern web development. It catches errors at compile time, improves developer experience, and acts as living documentation. However, most projects only use a fraction of the language’s power. Advanced TypeScript patterns can help you model complex domains, prevent invalid states, and build APIs that are safe by construction. In this article, we will explore practical patterns you can adopt today to make your TypeScript code more expressive and robust.

Discriminated Unions for State Modelling

One of the most common sources of bugs is representing a value that can be in multiple states with optional fields. This often leads to impossible states at runtime. Discriminated unions solve this by using a common literal property to distinguish between variants. Every possible state is explicit, and TypeScript narrows the type as soon as you check the discriminant.

type ApiState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; message: string };

function renderState(state: ApiState<string>): string {
  switch (state.status) {
    case 'idle':
      return 'Waiting for request';
    case 'loading':
      return 'Loading...';
    case 'success':
      return 'Data: ' + state.data;
    case 'error':
      return 'Error: ' + state.message;
  }
}

The union makes impossible states impossible. You cannot create an instance that is both loading and success, and you cannot access state.data before TypeScript has confirmed that the status is success.

To make the pattern even safer, you can add an exhaustive check. If you add a new variant, TypeScript will warn you at compile time.

function assertNever(value: never): never {
  throw new Error('Unexpected value: ' + value);
}

function renderExhaustive(state: ApiState<string>): string {
  switch (state.status) {
    case 'idle':
      return 'Waiting for request';
    case 'loading':
      return 'Loading...';
    case 'success':
      return 'Data: ' + state.data;
    case 'error':
      return 'Error: ' + state.message;
  }
  return assertNever(state);
}

The never return type guarantees that the function only returns if the switch is exhaustive. This turns a runtime oversight into a compile-time error.

Conditional Types and Type Inference

Conditional types allow types to be computed from other types. Combined with the infer keyword, they can extract and transform types dynamically. This is particularly useful for utility types that unwrap promises, extract return types, or derive complex type relationships.

type Unwrap<T> = T extends Promise<infer R> ? R : T;

type A = Unwrap<Promise<string>>; // string
type B = Unwrap<number>; // number

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

type C = ReturnTypeOf<(x: number) => string>; // string

These utilities are not just academic. They are the building blocks of popular libraries and framework code. You can use them to design APIs that preserve type information through layers of abstraction. For example, a data-fetching function can return a promise, and a typed wrapper can unwrap it automatically so that consumers work with the resolved data type.

Be careful with conditional types on union inputs. Because conditional types distribute over naked type parameters, a conditional type applied to a union can change the resulting union in powerful but sometimes surprising ways. Test your types thoroughly, especially when you introduce recursion.

Mapped Types for Derived Structures

Mapped types let you iterate over the keys of an existing type and produce a new type. This is how built-in utilities like Partial, Readonly, and Pick are implemented. You can use mapped types to transform every property consistently.

type ReadonlyMap<T> = { readonly [K in keyof T]: T[K] };

type User = { id: number; name: string; email: string };

type ReadonlyUser = ReadonlyMap<User>;

type PartialMap<T> = { [K in keyof T]?: T[K] };

type PartialUser = PartialMap<User>;

The real value appears when you create deeply recursive mapped types. A standard Readonly utility only works on the top level. If you need to freeze an entire configuration object, a deep readonly type can be created quickly.

type DeepReadonly<T> = {
  readonly [K in keyof T]: DeepReadonly<T[K]>;
};

type AppConfig = {
  database: {
    host: string;
    credentials: {
      user: string;
      password: string;
    };
  };
};

type ReadonlyAppConfig = DeepReadonly<AppConfig>;

Now every level of the configuration object is readonly. If a developer accidentally tries to mutate a nested property, the compiler will stop them. The same approach can create deep partial types, deep required types, or any other property transformation you need.

Enforcing Complex Constraints with RequireAtLeastOne

There are cases where a type must have at least one of several properties. For example, a connection configuration should require either a URL or a file path, but not necessarily both. Expressing this constraint with optional properties alone allows invalid empty objects through. You can build a custom utility type to enforce the constraint.

type RequireAtLeastOne<T, Keys extends keyof T = keyof T> =
  Omit<T, Keys> &
  { [K in Keys]-?: Required<Pick<T, K>> & Partial<Omit<T, K>> }[Keys];

type ConnectionConfig = {
  url?: string;
  file?: string;
  timeout?: number;
};

type ValidConnectionConfig = RequireAtLeastOne<ConnectionConfig, 'url' | 'file'>;

The distributive object type at the end iterates over each key in Keys and produces a union of intersections. Each variant requires one specific key and allows the others to be optional. The result is a type that requires at least one of url or file while keeping return type information intact.

This pattern is useful in API clients, feature flag configuration, and any model where multiple sources can satisfy a requirement.

Phantom Types and the Builder Pattern

The builder pattern is popular for constructing complex objects with fluent APIs. Standard builders often fail to catch missing required steps at compile time. By using phantom types, you can encode state in the type parameters of the builder. This makes calling build() without the necessary steps a compile-time error.

type RequestMethods = 'GET' | 'POST' | 'PUT' | 'DELETE';

type HasMethod = { method: RequestMethods };
type HasBody = { body: unknown };
type BuiltRequest = {
  method: RequestMethods;
  body?: unknown;
  headers: Record<string, string>;
};

class RequestBuilder<TState extends object = object> {
  private state: Partial<BuiltRequest> = { headers: {} };

  setMethod(method: RequestMethods): RequestBuilder<TState & HasMethod> {
    this.state.method = method;
    return this as RequestBuilder<TState & HasMethod>;
  }

  setBody(body: unknown): RequestBuilder<TState & HasBody> {
    this.state.body = body;
    return this as RequestBuilder<TState & HasBody>;
  }

  build(): TState extends HasMethod & HasBody ? BuiltRequest : never {
    const method = this.state.method;
    const body = this.state.body;
    if (!method) {
      throw new Error('method is required');
    }
    if (body === undefined) {
      throw new Error('body is required');
    }
    return {
      method,
      body,
      headers: this.state.headers || {}
    };
  }
}

const request = new RequestBuilder()
  .setMethod('POST')
  .setBody({ key: 'value' })
  .build();

In the example above, TState accumulates the builder steps. The build method only has return type BuiltRequest when TState includes both the HasMethod and HasBody phantom types. If you call build() before setting the body, the return type is never, which signals an invalid operation. This moves state validation from runtime to the compiler while preserving a clean developer experience.

Type-Safe Event Emitters

Event emitters are ubiquitous in frontend and backend applications. Without type safety, event names and payloads are open strings, and typos become runtime issues. You can model event maps with a generic class to get autocomplete and payload validation for every event.

type AppEvents = {
  login: { userId: string };
  logout: { userId: string };
  error: { message: string };
};

class TypedEmitter<Events extends Record<string, unknown>> {
  private listeners: { [K in keyof Events]?: Set<(payload: Events[K]) => void> } = {};

  on<K extends keyof Events>(event: K, callback: (payload: Events[K]) => void): () => void {
    const set = this.listeners[event] ?? new Set();
    set.add(callback);
    this.listeners[event] = set;
    return () => {
      set.delete(callback);
    };
  }

  emit<K extends keyof Events>(event: K, payload: Events[K]): void {
    this.listeners[event]?.forEach((callback) => callback(payload));
  }
}

const appBus = new TypedEmitter<AppEvents>();

appBus.on('login', (payload) => {
  const userId: string = payload.userId;
});

appBus.emit('login', { userId: 'user-123' });

This pattern gives you fully typed event handlers and emitters. It also makes refactoring easier. If you rename an event or change its payload shape, the compiler immediately points out every consumer that needs updating.

Typed Function Composition and Pipelines

Functional programming is becoming more common in TypeScript codebases. A typed pipe helper lets you compose functions safely without losing type information. Small generic interfaces can enforce the relationship between the output of one function and the input of the next.

type PipeFn = {
  <A, B>(fn: (a: A) => B): (a: A) => B;
  <A, B, C>(fn1: (a: A) => B, fn2: (b: B) => C): (a: A) => C;
  <A, B, C, D>(
    fn1: (a: A) => B,
    fn2: (b: B) => C,
    fn3: (c: C) => D
  ): (a: A) => D;
};

const pipe = ((...fns: Array<(x: any) => any>) =>
  (input: any) => fns.reduce((acc, fn) => fn(acc), input)) as PipeFn;

const result = pipe(
  (x: number) => x * 2,
  (x: number) => x.toString(),
  (s: string) => s.length
); // result is number

The PipeFn interface uses generic call signatures to model the most common arities. The actual implementation uses any internally, but the public signature remains fully typed. You can extend the interface to support more functions, but in practice a few overloads cover most pipelines.

This pattern is especially valuable when processing data through multiple transformations, such as parsing, validation, normalization, and serialization.

Testing Your Types

Code reviews catch many issues, but type tests are the fastest way to verify that custom types behave as expected. Libraries like expect-type give you assertion helpers that run at compile time. Add these tests to your CI pipeline just like unit tests.

import { expectTypeOf } from 'expect-type';

type UnwrapNumber = Unwrap<Promise<number>>;

expectTypeOf<UnwrapNumber>().toEqualTypeOf<number>();

type ReadonlyUser = ReadonlyMap<User>;

expectTypeOf<ReadonlyUser>().toEqualTypeOf<{ readonly id: number; readonly name: string }>();

Type tests are not a replacement for runtime tests, but they are excellent for preventing regressions in public APIs, complex conditional types, and generic utilities.

Performance and Practical Pitfalls

Advanced type definitions can severely slow down the TypeScript compiler if they are too complex. Deeply recursive conditional types, enormous unions, and overly clever type gymnastics can increase compile times and make the code harder to read. Here are some guidelines:

  • Use simple interfaces where possible; reserve conditional types for true need.
  • Avoid distributing conditional types over large unions unless you understand the expansion cost.
  • Extract complex type aliases and test them separately.
  • Prefer functions with explicit overloads over intricate conditional types when both options are viable.
  • Do not use any as an escape hatch in public APIs. If you must use it internally, isolate it behind a well-typed interface.
  • Remember that maintainability matters. If a type takes more time to read than the code it protects, simplify it.

TypeScript’s type system is Turing-complete, but that does not mean you should use it to build a compiler at runtime. Use these patterns where they reduce bugs and improve developer experience, and always keep readability first.

Conclusion

Advanced TypeScript patterns are powerful tools for building applications that are safe, expressive, and maintainable. Discriminated unions model state machines; conditional types derive new types; mapped types transform structures; phantom types enforce build steps; and generic classes provide type-safe event systems. Add type-level tests to lock in the behavior of your utilities.

The best TypeScript code is not necessarily the most clever code. It is the code that makes the compiler work for you, catches errors before users see them, and lets your team refactor with confidence. Start by introducing one or two of these patterns into a small module, measure the impact, and gradually expand from there.

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply

Your email address will not be published. Required fields are marked *