TypeScript builder pattern — type-safe step-by-step construction
The builder pattern shines when an object requires many optional parameters, some of which depend on others. In TypeScript, a naive builder loses type safety — the build() method returns an object that may have required fields undefined. A well-typed builder can enforce at the type level that required fields are set, that dependent fields are not set before prerequisites, and that build() is only callable in a valid state.
The simple builder — good for optional configuration
interface HttpClientConfig {
baseUrl: string;
timeout: number;
retries: number;
headers: Record<string, string>;
auth?: { type: 'bearer'; token: string } | { type: 'basic'; user: string; pass: string };
}
class HttpClientBuilder {
private config: HttpClientConfig = {
baseUrl: '',
timeout: 5000,
retries: 3,
headers: {},
};
withBaseUrl(url: string): this {
this.config.baseUrl = url;
return this;
}
withTimeout(ms: number): this {
this.config.timeout = ms;
return this;
}
withRetries(count: number): this {
this.config.retries = count;
return this;
}
withHeader(name: string, value: string): this {
this.config.headers[name] = value;
return this;
}
withBearerAuth(token: string): this {
this.config.auth = { type: 'bearer', token };
return this;
}
withBasicAuth(user: string, pass: string): this {
this.config.auth = { type: 'basic', user, pass };
return this;
}
build(): HttpClient {
if (!this.config.baseUrl) {
throw new Error('baseUrl is required');
}
return new HttpClient(this.config);
}
}
// Usage
const client = new HttpClientBuilder()
.withBaseUrl('https://api.example.com')
.withTimeout(10000)
.withBearerAuth(token)
.withHeader('X-Request-ID', requestId)
.build();
The type-safe builder — enforce required fields at compile time
The simple builder validates required fields at runtime. A more sophisticated approach uses the type system to make build() only callable when required fields are set:
// Track which required fields have been set as type parameters
class QueryBuilder<
TTable extends string | never = never,
TColumns extends string[] | never = never,
> {
private _table?: string;
private _columns?: string[];
private _where: string[] = [];
private _limit?: number;
private _orderBy?: { column: string; direction: 'ASC' | 'DESC' };
from<T extends string>(table: T): QueryBuilder<T, TColumns> {
const builder = this as unknown as QueryBuilder<T, TColumns>;
builder._table = table;
return builder;
}
select<C extends string[]>(...columns: C): QueryBuilder<TTable, C> {
const builder = this as unknown as QueryBuilder<TTable, C>;
builder._columns = columns;
return builder;
}
where(condition: string): this {
this._where.push(condition);
return this;
}
limit(n: number): this {
this._limit = n;
return this;
}
orderBy(column: string, direction: 'ASC' | 'DESC' = 'ASC'): this {
this._orderBy = { column, direction };
return this;
}
// build() is ONLY available when both table and columns have been set
// TypeScript will error if you call build() before .from() and .select()
build(
this: QueryBuilder<string, string[]> // narrows: both must be non-never
): string {
const cols = this._columns!.join(', ');
let query = `SELECT ${cols} FROM ${this._table!}`;
if (this._where.length) query += ` WHERE ${this._where.join(' AND ')}`;
if (this._orderBy) query += ` ORDER BY ${this._orderBy.column} ${this._orderBy.direction}`;
if (this._limit !== undefined) query += ` LIMIT ${this._limit}`;
return query;
}
}
// This works — both .from() and .select() are called
new QueryBuilder()
.from('users')
.select('id', 'name', 'email')
.where('active = true')
.limit(10)
.build(); // ✓ TypeScript accepts this
// This fails at compile time — .from() was not called
new QueryBuilder()
.select('id', 'name')
.build(); // ✗ TypeScript error: build() not available without table
Step builder — enforce a specific sequence
For workflows that must happen in a specific order, use a step builder where each step returns a different type:
// An email builder that enforces: recipient → subject → body → send
interface Step1 {
to(email: string): Step2;
}
interface Step2 {
subject(subject: string): Step3;
}
interface Step3 {
body(html: string): FinalStep;
}
interface FinalStep {
withAttachment(filename: string, content: Buffer): FinalStep;
send(): Promise<void>;
}
class EmailBuilder implements Step1, Step2, Step3, FinalStep {
private email: {
to?: string;
subject?: string;
body?: string;
attachments: Array<{ filename: string; content: Buffer }>;
} = { attachments: [] };
to(address: string): Step2 {
this.email.to = address;
return this;
}
subject(text: string): Step3 {
this.email.subject = text;
return this;
}
body(html: string): FinalStep {
this.email.body = html;
return this;
}
withAttachment(filename: string, content: Buffer): FinalStep {
this.email.attachments.push({ filename, content });
return this;
}
async send(): Promise<void> {
await emailService.send(this.email);
}
}
// Factory function returns Step1 — the first step only
function createEmail(): Step1 {
return new EmailBuilder();
}
// Usage — TypeScript enforces the order
await createEmail()
.to('user@example.com') // returns Step2
.subject('Welcome!') // returns Step3
.body('<p>Hi there</p>') // returns FinalStep
.send(); // only available after all required steps
// Error: .send() not available before .body()
createEmail().to('user@example.com').send(); // TypeScript error ✗
When to use the builder pattern
- Objects with more than 4-5 optional configuration fields
- When different field combinations are valid (and you want to enforce this in types)
- When construction involves a sequence of steps with dependencies
- Test data builders:
userBuilder.withAdmin().withVerifiedEmail().build()
The step builder interface pattern — returning different types at each step — is one of the most underused techniques for making invalid states unrepresentable at the type level. When the compiler prevents you from calling .send() without a recipient, the bug cannot exist.