The Typed Builder Pattern
Build fluent, step-by-step construction APIs where TypeScript tracks which fields have been set and prevents calling build() until all required fields are provided.
Overview
Key Points
- •Phantom type parameter: a generic T on the builder that carries "what has been set" at the type level only
- •Accumulating fields: each setter returns `Builder<T & { field: true }>` to add the field to the phantom type
- •`build()` availability: conditional on whether all required fields are present in T
- •The pattern enforces at compile time that required fields are set before building
- •Zero runtime overhead: the phantom types are type-level only and are erased in JavaScript output
- •Fluent API: methods return `this` or a new builder, enabling method chaining
- •Used in: query builders (Drizzle, TypeORM), test fixtures, configuration builders, HTTP request builders
Examples
Simple phantom-type builder for a User object
// Track which fields have been set using phantom type T
class UserBuilder<T extends Partial<Record<'name' | 'email' | 'role', true>> = {}> {
private data: Partial<{ name: string; email: string; role: string }> = {};
setName(name: string): UserBuilder<T & { name: true }> {
this.data.name = name;
return this as unknown as UserBuilder<T & { name: true }>;
}
setEmail(email: string): UserBuilder<T & { email: true }> {
this.data.email = email;
return this as unknown as UserBuilder<T & { email: true }>;
}
setRole(role: string): UserBuilder<T & { role: true }> {
this.data.role = role;
return this as unknown as UserBuilder<T & { role: true }>;
}
// build() is only available when all three fields are set
build(
this: UserBuilder<{ name: true; email: true; role: true }>
): { name: string; email: string; role: string } {
return this.data as { name: string; email: string; role: string };
}
}
const user = new UserBuilder()
.setName('Alice')
.setEmail('alice@example.com')
.setRole('admin')
.build(); // OK - all required fields set
console.log(user); // { name: "Alice", email: "...", role: "admin" }
// new UserBuilder().setName('Bob').build(); // Error - email and role missingGeneric builder factory with required and optional fields
type Required_ = 'host' | 'port';
type Optional_ = 'ssl' | 'timeout';
type AllFields = Required_ | Optional_;
interface DBConfig {
host: string;
port: number;
ssl?: boolean;
timeout?: number;
}
class DBConfigBuilder<Set extends Partial<Record<AllFields, true>> = {}> {
private cfg: Partial<DBConfig> = {};
host(host: string): DBConfigBuilder<Set & { host: true }> {
this.cfg.host = host;
return this as unknown as DBConfigBuilder<Set & { host: true }>;
}
port(port: number): DBConfigBuilder<Set & { port: true }> {
this.cfg.port = port;
return this as unknown as DBConfigBuilder<Set & { port: true }>;
}
ssl(enabled = true): DBConfigBuilder<Set & { ssl: true }> {
this.cfg.ssl = enabled;
return this as unknown as DBConfigBuilder<Set & { ssl: true }>;
}
timeout(ms: number): DBConfigBuilder<Set & { timeout: true }> {
this.cfg.timeout = ms;
return this as unknown as DBConfigBuilder<Set & { timeout: true }>;
}
// Only callable when both required fields are set
build(
this: DBConfigBuilder<{ host: true; port: true } & Partial<Record<Optional_, true>>>
): DBConfig {
return this.cfg as DBConfig;
}
}
// OK - both required fields provided
const config1 = new DBConfigBuilder()
.host('localhost')
.port(5432)
.build();
// OK - with optional fields too
const config2 = new DBConfigBuilder()
.host('db.prod.com')
.port(5432)
.ssl(true)
.timeout(5000)
.build();
// Error - missing port
// new DBConfigBuilder().host('localhost').build();Builder using intersection accumulation for a query builder
// A simplified SQL query builder that tracks clauses via phantom types
type Clause = 'FROM' | 'WHERE' | 'SELECT';
class QueryBuilder<T extends string = never> {
private parts: { select?: string; from?: string; where?: string } = {};
select(cols: string): QueryBuilder<T | 'SELECT'> {
this.parts.select = `SELECT ${cols}`;
return this as unknown as QueryBuilder<T | 'SELECT'>;
}
from(table: string): QueryBuilder<T | 'FROM'> {
this.parts.from = `FROM ${table}`;
return this as unknown as QueryBuilder<T | 'FROM'>;
}
where(condition: string): QueryBuilder<T | 'WHERE'> {
this.parts.where = `WHERE ${condition}`;
return this as unknown as QueryBuilder<T | 'WHERE'>;
}
// Can only build when both SELECT and FROM are present
toSQL(this: QueryBuilder<'SELECT' | 'FROM' | T>): string {
return [this.parts.select, this.parts.from, this.parts.where]
.filter(Boolean)
.join(' ');
}
}
const q1 = new QueryBuilder()
.select('id, name, email')
.from('users')
.where('age > 18')
.toSQL();
console.log(q1); // "SELECT id, name, email FROM users WHERE age > 18"
// Error - no FROM clause
// new QueryBuilder().select('*').toSQL();Common Mistakes
- The `this` parameter narrowing requires the builder instance to match the required phantom type - if you store the builder in a variable with a wide type, the constraint is lost
- Using `return this` instead of `return this as unknown as NewBuilder` causes TypeScript to not narrow the return type; the cast is necessary because TypeScript cannot infer that `this` is the new type
- Making all builder fields required: optional fields should not block `build()` - use `& Partial<Record<OptionalKeys, true>>` in the build constraint so optional fields can be omitted
Note
For simpler use cases, consider a plain object argument with `Required` and `Partial` instead of a builder. Use the phantom-type builder pattern specifically when you need method chaining (fluent API) AND compile-time enforcement of required steps. The complexity is only justified when the API is user-facing and the wrong order or missing steps are a real source of bugs.
Exercises
Write TypeScript code to solve each exercise. Click Run to compile and execute it right here, then mark as done.
Completed: 0 / 2 exercises
Knowledge Check
Question 1 of 4