A production-ready TypeScript Builder pattern implementation using generics to track set fields and enforce required properties at compile time, ensuring objects are only buildable when all required fields are provided.
// Define the shape of our target object
interface User {
readonly id: string;
readonly email: string;
readonly name: string;
readonly age?: number;
readonly role?: 'admin' | 'user' | 'guest';
readonly metadata?: Record<string, unknown>;
}
// Track which required fields have been set
type RequiredFields = 'id' | 'email' | 'name';
// Builder class with generic tracking of set fields
class UserBuilder<SetFields extends string = never> {
private readonly data: Partial<User> = {};
// Required field setters - each returns a new type with the field marked as set
setId(id: string): UserBuilder<SetFields | 'id'> {
this.data.id = id;
return this as unknown as UserBuilder<SetFields | 'id'>;
}
setEmail(email: string): UserBuilder<SetFields | 'email'> {
if (!this.isValidEmail(email)) {
throw new Error(`Invalid email format: ${email}`);
}
this.data.email = email;
return this as unknown as UserBuilder<SetFields | 'email'>;
}
setName(name: string): UserBuilder<SetFields | 'name'> {
if (name.trim().length === 0) {
throw new Error('Name cannot be empty');
}
this.data.name = name.trim();
return this as unknown as UserBuilder<SetFields | 'name'>;
}
// Optional field setters - don't affect the type parameter
setAge(age: number): this {
if (age < 0 || age > 150) {
throw new Error(`Invalid age: ${age}`);
}
this.data.age = age;
return this;
}
setRole(role: User['role']): this {
this.data.role = role;
return this;
}
setMetadata(metadata: Record<string, unknown>): this {
this.data.metadata = { ...metadata };
return this;
}
// Build method - only available when all required fields are set
build(this: UserBuilder<RequiredFields>): User {
return Object.freeze({ ...this.data }) as User;
}
// Reset builder to initial state
reset(): UserBuilder<never> {
const newBuilder = new UserBuilder<never>();
return newBuilder;
}
// Clone current builder state
clone(): UserBuilder<SetFields> {
const cloned = new UserBuilder<SetFields>();
Object.assign(cloned.data, this.data);
return cloned;
}
private isValidEmail(email: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
}
// Factory function for cleaner instantiation
function createUserBuilder(): UserBuilder<never> {
return new UserBuilder<never>();
}
// Director class for common configurations
class UserDirector {
static createAdminUser(
builder: UserBuilder<never>,
id: string,
email: string,
name: string
): User {
return builder
.setId(id)
.setEmail(email)
.setName(name)
.setRole('admin')
.build();
}
static createGuestUser(builder: UserBuilder<never>, id: string): User {
return builder
.setId(id)
.setEmail(`guest-${id}@temp.local`)
.setName(`Guest ${id}`)
.setRole('guest')
.build();
}
}
// Usage examples
const user1 = createUserBuilder()
.setId('usr_123')
.setEmail('john@example.com')
.setName('John Doe')
.setAge(30)
.setRole('user')
.build();
console.log('User 1:', user1);
// Using the director for common patterns
const adminUser = UserDirector.createAdminUser(
createUserBuilder(),
'adm_001',
'admin@company.com',
'System Admin'
);
console.log('Admin User:', adminUser);
const guestUser = UserDirector.createGuestUser(createUserBuilder(), 'guest_42');
console.log('Guest User:', guestUser);
// Clone and modify
const baseBuilder = createUserBuilder()
.setId('usr_base')
.setEmail('base@example.com');
const modifiedUser = baseBuilder
.clone()
.setName('Modified User')
.setAge(25)
.build();
console.log('Modified User:', modifiedUser);
// This would cause a compile-time error - uncomment to verify:
// const incomplete = createUserBuilder()
// .setId('usr_456')
// .setEmail('test@example.com')
// .build(); // Error: Property 'build' does not existThe Builder pattern is essential when constructing objects with many parameters, especially when some are optional. This implementation goes beyond the basic pattern by leveraging TypeScript's type system to enforce that all required fields are set before the build method can be called.
The key innovation here is the generic type parameter SetFields that tracks which fields have been set during the building process. Each required field setter method returns a new builder type that includes that field in the union. For example, calling setId() transforms UserBuilder<never> into UserBuilder<'id'>. The build method uses a this parameter type constraint requiring UserBuilder<RequiredFields>, which means TypeScript will only allow calling build() when all required fields ('id', 'email', 'name') are present in the type parameter.
The implementation includes several production-ready features: input validation in setters (email format, name non-empty, age range), immutable output via Object.freeze(), a clone() method for creating variations from a base configuration, and a reset() method for reusing builder instances. The Director class demonstrates how to encapsulate common object configurations, following the traditional Builder pattern structure where directors orchestrate builders for specific use cases.
One important consideration is that the type casting (as unknown as UserBuilder<SetFields | 'field'>) is necessary because TypeScript cannot automatically narrow the return type based on method calls. This is a well-known limitation when implementing fluent interfaces with progressive type narrowing. The runtime behavior remains correct; the casting simply helps TypeScript understand our intent.
Use this pattern when you have objects with many configuration options, especially with a mix of required and optional fields. It's particularly valuable in APIs where compile-time guarantees prevent runtime errors from missing required data. Avoid it for simple objects with few fields where a plain constructor or factory function would suffice, as the ceremony of the builder adds unnecessary complexity in those cases.