Q27 of 38 · TypeScript

How do you implement a type-safe builder pattern for test fixtures in TypeScript?

TypeScriptSeniortypescriptbuilder-patterngenericsphantom-typesfixturestest-data

Short answer

Short answer: A type-safe builder uses method chaining where each setter returns `this` (or a new builder instance), and a final `build()` method returns the fully typed object. Using TypeScript's `this` return type and generics, the builder can enforce that all required fields are set before `build()` is callable.

Detail

The builder pattern is valuable in test automation for constructing complex test objects (users, orders, configs) without large constructor parameter lists and with readable, explicit field-setting.

Basic builder: A class with setter methods returning this for chaining, and a build() method. TypeScript's this return type ensures subclass builders also chain correctly.

Type-safe required fields (phantom types): By tracking which fields have been set in the generic parameter, you can make build() only available when all required fields are provided. This uses TypeScript's type system to enforce completeness at compile time.

Functional builder (object-return pattern): Instead of a class, each setter returns a new object with the updated type. More immutable; more complex types.

In Playwright Page Object tests: Builders are ideal for TestUser, CreateOrderRequest, and BrowserOptions. They replace scattered fixture factories with a consistent, discoverable API.

// EXAMPLE

// Type-safe builder with phantom type tracking
type Unset = { readonly _brand: unique symbol };

class UserBuilder<T extends Partial<User> = {}> {
  private data: T = {} as T;

  withName(name: string): UserBuilder<T & { name: string }> {
    return Object.assign(
      new UserBuilder<T & { name: string }>(),
      { data: { ...this.data, name } }
    );
  }

  withEmail(email: string): UserBuilder<T & { email: string }> {
    return Object.assign(
      new UserBuilder<T & { email: string }>(),
      { data: { ...this.data, email } }
    );
  }

  // build() only available when name and email are set
  build(this: UserBuilder<{ name: string; email: string } & T>): User {
    return this.data as unknown as User;
  }
}

// Usage
const user = new UserBuilder()
  .withName("Alice")
  .withEmail("alice@example.com")
  .build(); // compiles — both required fields set

// new UserBuilder().build(); // Error: 'build' not callable without name+email

// WHAT INTERVIEWERS LOOK FOR

Method chaining with `this` return type. The phantom-type approach to enforce completeness is a strong senior signal — most candidates implement a basic builder but miss the compile-time completeness enforcement. Connecting to test fixture construction.

// COMMON PITFALL

Returning `this` without the phantom type tracking — callers can call `build()` without setting required fields, and only get a runtime error. The value of a type-safe builder is catching this at compile time.