Builder Pattern in TypeScript: Method Chaining

Every TypeScript developer eventually encounters the 'telescoping constructor' anti-pattern. You start with a simple class, add a few optional parameters, and suddenly your constructor signature...

Key Insights

  • The Builder pattern eliminates “telescoping constructors” by replacing numerous optional parameters with a fluent, chainable API that makes object construction self-documenting
  • Returning this from setter methods enables method chaining, but TypeScript generics can enforce compile-time safety by requiring specific fields before build() becomes available
  • Builders shine for complex configurations like HTTP requests or query construction, but add unnecessary ceremony for simple objects where plain object literals work fine

The Problem with Complex Object Construction

Every TypeScript developer eventually encounters the “telescoping constructor” anti-pattern. You start with a simple class, add a few optional parameters, and suddenly your constructor signature looks like this:

class User {
  constructor(
    firstName: string,
    lastName: string,
    email?: string,
    phone?: string,
    address?: string,
    dateOfBirth?: Date,
    isActive?: boolean,
    role?: string,
    department?: string,
    managerId?: string
  ) {
    // Assignment nightmare begins...
  }
}

// Calling code becomes unreadable
const user = new User("John", "Doe", undefined, undefined, undefined, undefined, true, "admin");

This code is fragile. You can’t tell what undefined means without checking the signature. Reordering parameters breaks everything. Adding new optional fields means updating every call site.

The Builder pattern solves this by separating object construction from its representation. Instead of a monster constructor, you get a fluent API that reads like natural language.

Basic Builder Implementation

A builder is a separate class responsible for constructing another object step by step. The product class stays clean, and the builder handles the complexity.

interface User {
  readonly firstName: string;
  readonly lastName: string;
  readonly email?: string;
  readonly phone?: string;
  readonly role: string;
  readonly isActive: boolean;
}

class UserBuilder {
  private firstName: string = "";
  private lastName: string = "";
  private email?: string;
  private phone?: string;
  private role: string = "user";
  private isActive: boolean = true;

  setFirstName(firstName: string): UserBuilder {
    this.firstName = firstName;
    return this;
  }

  setLastName(lastName: string): UserBuilder {
    this.lastName = lastName;
    return this;
  }

  setEmail(email: string): UserBuilder {
    this.email = email;
    return this;
  }

  setPhone(phone: string): UserBuilder {
    this.phone = phone;
    return this;
  }

  setRole(role: string): UserBuilder {
    this.role = role;
    return this;
  }

  setActive(isActive: boolean): UserBuilder {
    this.isActive = isActive;
    return this;
  }

  build(): User {
    return {
      firstName: this.firstName,
      lastName: this.lastName,
      email: this.email,
      phone: this.phone,
      role: this.role,
      isActive: this.isActive,
    };
  }
}

The build() method is the exit point. It assembles the final object and returns it. This is where you can add validation, apply defaults, or perform any final transformations.

Method Chaining with return this

The magic of fluent interfaces comes from returning this from each setter method. This simple technique transforms verbose code into something readable:

// Without chaining
const builder = new UserBuilder();
builder.setFirstName("John");
builder.setLastName("Doe");
builder.setEmail("john@example.com");
builder.setRole("admin");
const user = builder.build();

// With chaining
const user = new UserBuilder()
  .setFirstName("John")
  .setLastName("Doe")
  .setEmail("john@example.com")
  .setRole("admin")
  .build();

TypeScript’s type inference handles chained methods seamlessly. Each method returns UserBuilder, so IntelliSense shows available methods at every step. The code becomes self-documenting—you can see exactly what’s being configured without counting parameter positions.

Type Safety and Required Fields

Basic builders have a flaw: nothing prevents you from calling build() before setting required fields. You only discover the problem at runtime. TypeScript’s type system can do better.

The Step Builder pattern uses generics to track which fields have been set, making build() available only when all required fields are present:

interface UserData {
  firstName: string;
  lastName: string;
  email?: string;
  role?: string;
}

type RequiredFields = "firstName" | "lastName";

class TypeSafeUserBuilder<T extends string = never> {
  private data: Partial<UserData> = {};

  setFirstName(value: string): TypeSafeUserBuilder<T | "firstName"> {
    this.data.firstName = value;
    return this as TypeSafeUserBuilder<T | "firstName">;
  }

  setLastName(value: string): TypeSafeUserBuilder<T | "lastName"> {
    this.data.lastName = value;
    return this as TypeSafeUserBuilder<T | "lastName">;
  }

  setEmail(value: string): TypeSafeUserBuilder<T> {
    this.data.email = value;
    return this;
  }

  setRole(value: string): TypeSafeUserBuilder<T> {
    this.data.role = value;
    return this;
  }

  build(this: TypeSafeUserBuilder<RequiredFields>): UserData {
    return this.data as UserData;
  }
}

// This compiles - all required fields present
const validUser = new TypeSafeUserBuilder()
  .setFirstName("John")
  .setLastName("Doe")
  .build();

// This fails at compile time - missing lastName
const invalidUser = new TypeSafeUserBuilder()
  .setFirstName("John")
  .build(); // Error: 'build' does not exist on type 'TypeSafeUserBuilder<"firstName">'

The generic parameter T accumulates field names as you call setters. The build() method’s this constraint requires T to include all required fields. Miss one, and TypeScript refuses to compile.

For simpler cases, runtime validation in build() works fine:

build(): User {
  if (!this.firstName || !this.lastName) {
    throw new Error("firstName and lastName are required");
  }
  return { /* ... */ };
}

Choose based on your needs. Compile-time safety catches errors earlier but adds complexity. Runtime validation is simpler but fails later.

Advanced Patterns: Director and Nested Builders

When you have common configurations, a Director class encapsulates them:

class UserDirector {
  static createAdmin(builder: UserBuilder): User {
    return builder
      .setRole("admin")
      .setActive(true)
      .build();
  }

  static createGuest(builder: UserBuilder): User {
    return builder
      .setRole("guest")
      .setActive(false)
      .build();
  }
}

For complex nested structures, compose builders together. Here’s a query builder with nested clause construction:

interface WhereClause {
  field: string;
  operator: "=" | "!=" | ">" | "<" | "LIKE";
  value: unknown;
}

interface Query {
  table: string;
  fields: string[];
  where: WhereClause[];
  limit?: number;
}

class WhereClauseBuilder {
  private clauses: WhereClause[] = [];

  equals(field: string, value: unknown): WhereClauseBuilder {
    this.clauses.push({ field, operator: "=", value });
    return this;
  }

  greaterThan(field: string, value: unknown): WhereClauseBuilder {
    this.clauses.push({ field, operator: ">", value });
    return this;
  }

  like(field: string, pattern: string): WhereClauseBuilder {
    this.clauses.push({ field, operator: "LIKE", value: pattern });
    return this;
  }

  getClauses(): WhereClause[] {
    return [...this.clauses];
  }
}

class QueryBuilder {
  private table: string = "";
  private fields: string[] = [];
  private whereClauses: WhereClause[] = [];
  private queryLimit?: number;

  from(table: string): QueryBuilder {
    this.table = table;
    return this;
  }

  select(...fields: string[]): QueryBuilder {
    this.fields = fields;
    return this;
  }

  where(builderFn: (wb: WhereClauseBuilder) => WhereClauseBuilder): QueryBuilder {
    const whereBuilder = new WhereClauseBuilder();
    builderFn(whereBuilder);
    this.whereClauses = whereBuilder.getClauses();
    return this;
  }

  limit(count: number): QueryBuilder {
    this.queryLimit = count;
    return this;
  }

  build(): Query {
    if (!this.table) throw new Error("Table is required");
    return {
      table: this.table,
      fields: this.fields.length ? this.fields : ["*"],
      where: this.whereClauses,
      limit: this.queryLimit,
    };
  }
}

// Usage
const query = new QueryBuilder()
  .from("users")
  .select("id", "name", "email")
  .where(w => w.equals("active", true).greaterThan("age", 18))
  .limit(10)
  .build();

The nested builder pattern keeps each builder focused on its domain while composing into complex structures.

Real-World Application: Building HTTP Requests

Let’s combine everything into a practical HTTP request builder:

type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH";

interface HttpRequest {
  url: string;
  method: HttpMethod;
  headers: Record<string, string>;
  queryParams: Record<string, string>;
  body?: unknown;
  timeout: number;
}

class RequestBuilder<T extends string = never> {
  private requestUrl: string = "";
  private requestMethod: HttpMethod = "GET";
  private requestHeaders: Record<string, string> = {};
  private requestParams: Record<string, string> = {};
  private requestBody?: unknown;
  private requestTimeout: number = 30000;

  url(url: string): RequestBuilder<T | "url"> {
    this.requestUrl = url;
    return this as RequestBuilder<T | "url">;
  }

  method(method: HttpMethod): RequestBuilder<T> {
    this.requestMethod = method;
    return this;
  }

  get(): RequestBuilder<T> {
    return this.method("GET");
  }

  post(body?: unknown): RequestBuilder<T> {
    this.requestMethod = "POST";
    this.requestBody = body;
    return this;
  }

  header(key: string, value: string): RequestBuilder<T> {
    this.requestHeaders[key] = value;
    return this;
  }

  bearerToken(token: string): RequestBuilder<T> {
    return this.header("Authorization", `Bearer ${token}`);
  }

  contentType(type: string): RequestBuilder<T> {
    return this.header("Content-Type", type);
  }

  json(): RequestBuilder<T> {
    return this.contentType("application/json");
  }

  query(key: string, value: string): RequestBuilder<T> {
    this.requestParams[key] = value;
    return this;
  }

  timeout(ms: number): RequestBuilder<T> {
    this.requestTimeout = ms;
    return this;
  }

  build(this: RequestBuilder<"url">): HttpRequest {
    return {
      url: this.requestUrl,
      method: this.requestMethod,
      headers: { ...this.requestHeaders },
      queryParams: { ...this.requestParams },
      body: this.requestBody,
      timeout: this.requestTimeout,
    };
  }
}

// Usage
const request = new RequestBuilder()
  .url("https://api.example.com/users")
  .post({ name: "John", email: "john@example.com" })
  .json()
  .bearerToken("abc123")
  .query("notify", "true")
  .timeout(5000)
  .build();

This builder provides a clean API for constructing HTTP requests. Convenience methods like bearerToken() and json() wrap common patterns. The generic constraint ensures you can’t build without setting a URL.

Trade-offs and When to Skip Builders

Builders aren’t always the answer. For simple objects with few properties, they add ceremony without benefit:

// Overkill - just use an object literal
const config = { host: "localhost", port: 3000 };

// Builder makes sense here
const complexQuery = new QueryBuilder()
  .from("orders")
  .select("id", "total", "status")
  .where(w => w.equals("userId", 123).greaterThan("total", 100))
  .limit(50)
  .build();

Consider builders when you have:

  • Many optional parameters (more than 4-5)
  • Complex validation requirements
  • Configurations that benefit from named methods
  • Objects constructed in multiple steps or conditionally

For validation-focused scenarios, libraries like Zod handle schema definition and parsing elegantly. Class-validator works well with decorators if you’re in a NestJS-style architecture. Builders complement these tools rather than replace them.

The Builder pattern trades verbosity for clarity. When your object construction is complex enough, that trade-off pays dividends in readable, maintainable code.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.