🚀AdvancedAdvanced Design Patterns

The Typed Builder Pattern

Build fluent, step-by-step construction APIs where TypeScript tracks which fields have been set and prevents calling build() until all required fields are provided.

Overview

The builder pattern lets you construct complex objects step-by-step using a fluent API. The advanced TypeScript version goes further: it uses the type system to track which fields have been set, making `build()` unavailable until all required fields are provided. This technique uses **phantom types** (generic type parameters that carry state but have no runtime value) combined with conditional types to change what methods are available based on what has been set. There are two common approaches: 1. **Accumulating type approach**: The builder tracks set fields in a type parameter, and the `build()` method is only callable when all required fields appear in that type. 2. **Method-chaining return types approach**: Each setter returns a new type that includes the previous type plus the newly set field, and `build()` appears only on the fully-satisfied type. Both patterns produce zero-runtime-overhead type safety - the phantom type parameters are erased at compile time.

Key Points

  • •Phantom type parameter: a generic T on the builder that carries "what has been set" at the type level only
  • •Accumulating fields: each setter returns `Builder<T & { field: true }>` to add the field to the phantom type
  • •`build()` availability: conditional on whether all required fields are present in T
  • •The pattern enforces at compile time that required fields are set before building
  • •Zero runtime overhead: the phantom types are type-level only and are erased in JavaScript output
  • •Fluent API: methods return `this` or a new builder, enabling method chaining
  • •Used in: query builders (Drizzle, TypeORM), test fixtures, configuration builders, HTTP request builders

Examples

Simple phantom-type builder for a User object

// Track which fields have been set using phantom type T
class UserBuilder<T extends Partial<Record<'name' | 'email' | 'role', true>> = {}> {
  private data: Partial<{ name: string; email: string; role: string }> = {};

  setName(name: string): UserBuilder<T & { name: true }> {
    this.data.name = name;
    return this as unknown as UserBuilder<T & { name: true }>;
  }

  setEmail(email: string): UserBuilder<T & { email: true }> {
    this.data.email = email;
    return this as unknown as UserBuilder<T & { email: true }>;
  }

  setRole(role: string): UserBuilder<T & { role: true }> {
    this.data.role = role;
    return this as unknown as UserBuilder<T & { role: true }>;
  }

  // build() is only available when all three fields are set
  build(
    this: UserBuilder<{ name: true; email: true; role: true }>
  ): { name: string; email: string; role: string } {
    return this.data as { name: string; email: string; role: string };
  }
}

const user = new UserBuilder()
  .setName('Alice')
  .setEmail('alice@example.com')
  .setRole('admin')
  .build(); // OK - all required fields set

console.log(user); // { name: "Alice", email: "...", role: "admin" }

// new UserBuilder().setName('Bob').build(); // Error - email and role missing

Generic builder factory with required and optional fields

type Required_  = 'host' | 'port';
type Optional_ = 'ssl' | 'timeout';
type AllFields = Required_ | Optional_;

interface DBConfig {
  host: string;
  port: number;
  ssl?: boolean;
  timeout?: number;
}

class DBConfigBuilder<Set extends Partial<Record<AllFields, true>> = {}> {
  private cfg: Partial<DBConfig> = {};

  host(host: string): DBConfigBuilder<Set & { host: true }> {
    this.cfg.host = host;
    return this as unknown as DBConfigBuilder<Set & { host: true }>;
  }

  port(port: number): DBConfigBuilder<Set & { port: true }> {
    this.cfg.port = port;
    return this as unknown as DBConfigBuilder<Set & { port: true }>;
  }

  ssl(enabled = true): DBConfigBuilder<Set & { ssl: true }> {
    this.cfg.ssl = enabled;
    return this as unknown as DBConfigBuilder<Set & { ssl: true }>;
  }

  timeout(ms: number): DBConfigBuilder<Set & { timeout: true }> {
    this.cfg.timeout = ms;
    return this as unknown as DBConfigBuilder<Set & { timeout: true }>;
  }

  // Only callable when both required fields are set
  build(
    this: DBConfigBuilder<{ host: true; port: true } & Partial<Record<Optional_, true>>>
  ): DBConfig {
    return this.cfg as DBConfig;
  }
}

// OK - both required fields provided
const config1 = new DBConfigBuilder()
  .host('localhost')
  .port(5432)
  .build();

// OK - with optional fields too
const config2 = new DBConfigBuilder()
  .host('db.prod.com')
  .port(5432)
  .ssl(true)
  .timeout(5000)
  .build();

// Error - missing port
// new DBConfigBuilder().host('localhost').build();

Builder using intersection accumulation for a query builder

// A simplified SQL query builder that tracks clauses via phantom types
type Clause = 'FROM' | 'WHERE' | 'SELECT';

class QueryBuilder<T extends string = never> {
  private parts: { select?: string; from?: string; where?: string } = {};

  select(cols: string): QueryBuilder<T | 'SELECT'> {
    this.parts.select = `SELECT ${cols}`;
    return this as unknown as QueryBuilder<T | 'SELECT'>;
  }

  from(table: string): QueryBuilder<T | 'FROM'> {
    this.parts.from = `FROM ${table}`;
    return this as unknown as QueryBuilder<T | 'FROM'>;
  }

  where(condition: string): QueryBuilder<T | 'WHERE'> {
    this.parts.where = `WHERE ${condition}`;
    return this as unknown as QueryBuilder<T | 'WHERE'>;
  }

  // Can only build when both SELECT and FROM are present
  toSQL(this: QueryBuilder<'SELECT' | 'FROM' | T>): string {
    return [this.parts.select, this.parts.from, this.parts.where]
      .filter(Boolean)
      .join(' ');
  }
}

const q1 = new QueryBuilder()
  .select('id, name, email')
  .from('users')
  .where('age > 18')
  .toSQL();

console.log(q1); // "SELECT id, name, email FROM users WHERE age > 18"

// Error - no FROM clause
// new QueryBuilder().select('*').toSQL();

Common Mistakes

  • The `this` parameter narrowing requires the builder instance to match the required phantom type - if you store the builder in a variable with a wide type, the constraint is lost
  • Using `return this` instead of `return this as unknown as NewBuilder` causes TypeScript to not narrow the return type; the cast is necessary because TypeScript cannot infer that `this` is the new type
  • Making all builder fields required: optional fields should not block `build()` - use `& Partial<Record<OptionalKeys, true>>` in the build constraint so optional fields can be omitted

Note

For simpler use cases, consider a plain object argument with `Required` and `Partial` instead of a builder. Use the phantom-type builder pattern specifically when you need method chaining (fluent API) AND compile-time enforcement of required steps. The complexity is only justified when the API is user-facing and the wrong order or missing steps are a real source of bugs.

Exercises

Write TypeScript code to solve each exercise. Click Run to compile and execute it right here, then mark as done.

Loading editor…

Completed: 0 / 2 exercises

Knowledge Check

Question 1 of 4

What is a "phantom type" in the context of the builder pattern?