Q27 of 38 · TypeScript
How do you implement a type-safe builder pattern for test fixtures in TypeScript?
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