Skip to main content
Technology & EngineeringTypescript Patterns144 lines

Builder Pattern

Implement fluent, type-accumulating builders that enforce required fields at compile time.

Quick Summary11 lines
You are an expert in the Builder Pattern for writing type-safe TypeScript.

## Key Points

- Track set fields as a union in a generic parameter so `build()` can constrain `this` to require all mandatory keys.
- Return `this as any` (or new instances cast appropriately) from each setter to update the generic without breaking the chain.
- Separate optional setters from required ones by only gating `build()` on the required set.
- Forgetting the `this` parameter on `build()` — without it, the compiler cannot enforce that required fields have been set.
- Mutating shared state when builders are reused; prefer immutable builders that return new instances if a builder might be forked into multiple configurations.
skilldb get typescript-patterns-skills/Builder PatternFull skill: 144 lines
Paste into your CLAUDE.md or agent config

Builder Pattern — TypeScript Patterns

You are an expert in the Builder Pattern for writing type-safe TypeScript.

Overview

The builder pattern constructs complex objects step by step through a fluent method-chaining API. In TypeScript, you can make each method return a new builder type that accumulates the fields set so far, allowing the compiler to enforce that all required fields are present before build() is callable. Use this pattern for configuration objects, query builders, HTTP request constructors, and any API where construction order is flexible but completeness is mandatory.

Core Philosophy

The builder pattern solves a fundamental tension in API design: complex objects need many configuration options, but constructors with long parameter lists are error-prone and unreadable. Builders let callers set properties in any order using named methods, making construction self-documenting and resistant to parameter-order bugs.

What makes the TypeScript builder pattern exceptional is that generics can track which fields have been set at the type level. The build() method's this constraint acts as a compile-time checklist — if you forget a required field, the code does not compile. This transforms runtime "missing config" errors into immediate red squiggles in your editor, catching mistakes at the earliest possible moment.

A well-designed builder separates the concerns of "what can be configured" from "what must be configured." Optional setters enrich the builder without affecting the build constraint, while required setters accumulate in the generic parameter. This gives callers maximum flexibility in construction order while maintaining strict completeness guarantees.

Core Concepts

Type-accumulating builder using generics:

interface UserConfig {
  name: string;
  email: string;
  age?: number;
}

type RequiredKeys = "name" | "email";

class UserBuilder<Set extends string = never> {
  private config: Partial<UserConfig> = {};

  name(name: string): UserBuilder<Set | "name"> {
    this.config.name = name;
    return this as any;
  }

  email(email: string): UserBuilder<Set | "email"> {
    this.config.email = email;
    return this as any;
  }

  age(age: number): UserBuilder<Set> {
    this.config.age = age;
    return this as any;
  }

  build(this: UserBuilder<RequiredKeys>): UserConfig {
    return this.config as UserConfig;
  }
}

Usage — compiler enforces required fields:

const user = new UserBuilder()
  .name("Alice")
  .email("alice@example.com")
  .age(30)
  .build();  // OK

new UserBuilder()
  .name("Bob")
  .build();  // Error: 'email' not set

Implementation Patterns

Query builder with chained type narrowing:

interface Query<HasTable extends boolean = false, HasSelect extends boolean = false> {
  from(table: string): Query<true, HasSelect>;
  select(...cols: string[]): Query<HasTable, true>;
  where(condition: string): Query<HasTable, HasSelect>;
  execute(this: Query<true, true>): Promise<unknown[]>;
}

function createQuery(): Query {
  const state = { table: "", cols: [] as string[], conditions: [] as string[] };
  const q: Query<any, any> = {
    from(table: string) { state.table = table; return q; },
    select(...cols: string[]) { state.cols = cols; return q; },
    where(condition: string) { state.conditions.push(condition); return q; },
    async execute() { /* run query using state */ return []; },
  };
  return q;
}

Immutable builder returning new instances:

class RequestBuilder<S extends string = never> {
  private constructor(private readonly opts: Record<string, unknown> = {}) {}

  static create(): RequestBuilder {
    return new RequestBuilder();
  }

  url(url: string): RequestBuilder<S | "url"> {
    return new RequestBuilder<S | "url">({ ...this.opts, url });
  }

  method(method: string): RequestBuilder<S | "method"> {
    return new RequestBuilder<S | "method">({ ...this.opts, method });
  }

  send(this: RequestBuilder<"url" | "method">): Promise<Response> {
    return fetch(this.opts.url as string, { method: this.opts.method as string });
  }
}

Best Practices

  • Track set fields as a union in a generic parameter so build() can constrain this to require all mandatory keys.
  • Return this as any (or new instances cast appropriately) from each setter to update the generic without breaking the chain.
  • Separate optional setters from required ones by only gating build() on the required set.

Common Pitfalls

  • Forgetting the this parameter on build() — without it, the compiler cannot enforce that required fields have been set.
  • Mutating shared state when builders are reused; prefer immutable builders that return new instances if a builder might be forked into multiple configurations.

Anti-Patterns

Omitting the this constraint on build(). Without a this: Builder<RequiredKeys> parameter, the compiler cannot enforce that required fields have been set. The builder becomes a runtime-only check, losing the primary benefit of the pattern in TypeScript.

Making every field required in the builder. If an object has fifteen fields and all are gated by the builder's generic constraint, the type error messages become unreadable. Reserve compile-time enforcement for the truly required fields and validate optional ones at runtime if needed.

Returning this instead of this as any when the generic changes. Returning the original this type from a setter that should widen the generic parameter means the type does not accumulate, and build() will never become callable. The cast is necessary to update the phantom type parameter.

Using builders for simple objects. If an object has two or three fields and no ordering concerns, a plain object literal or factory function is simpler and equally type-safe. Builders add value when construction is complex, staged, or needs to be reused across call sites.

Allowing build() to be called multiple times on a mutable builder. If the builder mutates internal state and build() returns a reference to that state, subsequent mutations corrupt previously built objects. Either make build() produce a frozen copy or use immutable builders that create new instances on each setter call.

Install this skill directly: skilldb add typescript-patterns-skills

Get CLI access →