TypeScript builder pattern — type-safe step-by-step construction
← Back
April 4, 2026TypeScript7 min read

TypeScript builder pattern — type-safe step-by-step construction

Published April 4, 20267 min read

The builder pattern shines when an object requires many optional parameters, some of which depend on others. In TypeScript, a naive builder loses type safety — the build() method returns an object that may have required fields undefined. A well-typed builder can enforce at the type level that required fields are set, that dependent fields are not set before prerequisites, and that build() is only callable in a valid state.

The simple builder — good for optional configuration

typescript
interface HttpClientConfig {
  baseUrl: string;
  timeout: number;
  retries: number;
  headers: Record<string, string>;
  auth?: { type: 'bearer'; token: string } | { type: 'basic'; user: string; pass: string };
}

class HttpClientBuilder {
  private config: HttpClientConfig = {
    baseUrl: '',
    timeout: 5000,
    retries: 3,
    headers: {},
  };

  withBaseUrl(url: string): this {
    this.config.baseUrl = url;
    return this;
  }

  withTimeout(ms: number): this {
    this.config.timeout = ms;
    return this;
  }

  withRetries(count: number): this {
    this.config.retries = count;
    return this;
  }

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

  withBearerAuth(token: string): this {
    this.config.auth = { type: 'bearer', token };
    return this;
  }

  withBasicAuth(user: string, pass: string): this {
    this.config.auth = { type: 'basic', user, pass };
    return this;
  }

  build(): HttpClient {
    if (!this.config.baseUrl) {
      throw new Error('baseUrl is required');
    }
    return new HttpClient(this.config);
  }
}

// Usage
const client = new HttpClientBuilder()
  .withBaseUrl('https://api.example.com')
  .withTimeout(10000)
  .withBearerAuth(token)
  .withHeader('X-Request-ID', requestId)
  .build();

The type-safe builder — enforce required fields at compile time

The simple builder validates required fields at runtime. A more sophisticated approach uses the type system to make build() only callable when required fields are set:

typescript
// Track which required fields have been set as type parameters
class QueryBuilder<
  TTable extends string | never = never,
  TColumns extends string[] | never = never,
> {
  private _table?: string;
  private _columns?: string[];
  private _where: string[] = [];
  private _limit?: number;
  private _orderBy?: { column: string; direction: 'ASC' | 'DESC' };

  from<T extends string>(table: T): QueryBuilder<T, TColumns> {
    const builder = this as unknown as QueryBuilder<T, TColumns>;
    builder._table = table;
    return builder;
  }

  select<C extends string[]>(...columns: C): QueryBuilder<TTable, C> {
    const builder = this as unknown as QueryBuilder<TTable, C>;
    builder._columns = columns;
    return builder;
  }

  where(condition: string): this {
    this._where.push(condition);
    return this;
  }

  limit(n: number): this {
    this._limit = n;
    return this;
  }

  orderBy(column: string, direction: 'ASC' | 'DESC' = 'ASC'): this {
    this._orderBy = { column, direction };
    return this;
  }

  // build() is ONLY available when both table and columns have been set
  // TypeScript will error if you call build() before .from() and .select()
  build(
    this: QueryBuilder<string, string[]>  // narrows: both must be non-never
  ): string {
    const cols = this._columns!.join(', ');
    let query = `SELECT ${cols} FROM ${this._table!}`;
    if (this._where.length) query += ` WHERE ${this._where.join(' AND ')}`;
    if (this._orderBy) query += ` ORDER BY ${this._orderBy.column} ${this._orderBy.direction}`;
    if (this._limit !== undefined) query += ` LIMIT ${this._limit}`;
    return query;
  }
}

// This works — both .from() and .select() are called
new QueryBuilder()
  .from('users')
  .select('id', 'name', 'email')
  .where('active = true')
  .limit(10)
  .build();  // ✓ TypeScript accepts this

// This fails at compile time — .from() was not called
new QueryBuilder()
  .select('id', 'name')
  .build();  // ✗ TypeScript error: build() not available without table

Step builder — enforce a specific sequence

For workflows that must happen in a specific order, use a step builder where each step returns a different type:

typescript
// An email builder that enforces: recipient → subject → body → send
interface Step1 {
  to(email: string): Step2;
}

interface Step2 {
  subject(subject: string): Step3;
}

interface Step3 {
  body(html: string): FinalStep;
}

interface FinalStep {
  withAttachment(filename: string, content: Buffer): FinalStep;
  send(): Promise<void>;
}

class EmailBuilder implements Step1, Step2, Step3, FinalStep {
  private email: {
    to?: string;
    subject?: string;
    body?: string;
    attachments: Array<{ filename: string; content: Buffer }>;
  } = { attachments: [] };

  to(address: string): Step2 {
    this.email.to = address;
    return this;
  }

  subject(text: string): Step3 {
    this.email.subject = text;
    return this;
  }

  body(html: string): FinalStep {
    this.email.body = html;
    return this;
  }

  withAttachment(filename: string, content: Buffer): FinalStep {
    this.email.attachments.push({ filename, content });
    return this;
  }

  async send(): Promise<void> {
    await emailService.send(this.email);
  }
}

// Factory function returns Step1 — the first step only
function createEmail(): Step1 {
  return new EmailBuilder();
}

// Usage — TypeScript enforces the order
await createEmail()
  .to('user@example.com')    // returns Step2
  .subject('Welcome!')       // returns Step3
  .body('<p>Hi there</p>') // returns FinalStep
  .send();                   // only available after all required steps

// Error: .send() not available before .body()
createEmail().to('user@example.com').send();  // TypeScript error ✗

When to use the builder pattern

  • Objects with more than 4-5 optional configuration fields
  • When different field combinations are valid (and you want to enforce this in types)
  • When construction involves a sequence of steps with dependencies
  • Test data builders: userBuilder.withAdmin().withVerifiedEmail().build()

The step builder interface pattern — returning different types at each step — is one of the most underused techniques for making invalid states unrepresentable at the type level. When the compiler prevents you from calling .send() without a recipient, the bug cannot exist.

Share this
← All Posts7 min read