The Builder Pattern in TypeScript With Type Safety
Implement the builder pattern in TypeScript with compile-time enforcement of required fields using generics and conditional types.
What you'll learn
- ✓How to implement a fluent builder with method chaining
- ✓Tracking set fields at the type level with generics
- ✓Preventing build() until all required fields are provided
- ✓Practical builders for HTTP requests and query construction
Prerequisites
None — this post is self-contained.
The builder pattern constructs complex objects step by step through a fluent API. In most languages, builders rely on runtime checks to ensure all required fields are set before building. TypeScript can do better. With generics, the type system tracks which fields have been provided and only allows build() when every required field is present.
A Basic Builder
Start with a simple builder that uses method chaining:
interface UserConfig {
name: string;
email: string;
age: number;
role: 'admin' | 'member';
}
class UserBuilder {
private config: Partial<UserConfig> = {};
setName(name: string): this {
this.config.name = name;
return this;
}
setEmail(email: string): this {
this.config.email = email;
return this;
}
setAge(age: number): this {
this.config.age = age;
return this;
}
setRole(role: 'admin' | 'member'): this {
this.config.role = role;
return this;
}
build(): UserConfig {
if (!this.config.name || !this.config.email
|| !this.config.age || !this.config.role) {
throw new Error('Missing required fields');
}
return this.config as UserConfig;
}
}
const user = new UserBuilder()
.setName('Alice')
.setEmail('alice@example.com')
.setAge(30)
.setRole('admin')
.build();
This works, but the error is at runtime. You can call build() without setting any fields and only discover the problem when the code runs.
Type-Safe Builder With Generics
The key insight is to track which fields have been set as a type parameter. Each setter method returns a builder with an updated type that records the newly set field.
interface UserConfig {
name: string;
email: string;
age: number;
role: 'admin' | 'member';
}
type RequiredFields = 'name' | 'email' | 'age' | 'role';
class TypeSafeBuilder<Set extends string = never> {
private config: Partial<UserConfig> = {};
setName(name: string): TypeSafeBuilder<Set | 'name'> {
this.config.name = name;
return this as any;
}
setEmail(email: string): TypeSafeBuilder<Set | 'email'> {
this.config.email = email;
return this as any;
}
setAge(age: number): TypeSafeBuilder<Set | 'age'> {
this.config.age = age;
return this as any;
}
setRole(role: 'admin' | 'member'): TypeSafeBuilder<Set | 'role'> {
this.config.role = role;
return this as any;
}
build(
this: TypeSafeBuilder<RequiredFields>
): UserConfig {
return this.config as UserConfig;
}
}
The Set type parameter is a union of field names that have been provided. Each setter adds its field name to the union. The build method uses a this parameter to require that Set equals RequiredFields. If any field is missing, build() produces a compile error:
// This compiles
const user = new TypeSafeBuilder()
.setName('Alice')
.setEmail('alice@example.com')
.setAge(30)
.setRole('admin')
.build();
// This fails at compile time
const bad = new TypeSafeBuilder()
.setName('Alice')
.setEmail('alice@example.com')
.build(); // Error: 'age' and 'role' not set
A Generic Builder Factory
You can generalize this pattern into a reusable factory:
type Builder<T, Set extends keyof T = never> = {
[K in keyof T as `set${Capitalize<string & K>}`]:
(value: T[K]) => Builder<T, Set | K>;
} & {
build: [Exclude<keyof T, Set>] extends [never]
? () => T
: never;
};
function createBuilder<T>(): Builder<T> {
const config: Partial<T> = {};
const handler: ProxyHandler<any> = {
get(_, prop: string) {
if (prop === 'build') {
return () => ({ ...config }) as T;
}
if (prop.startsWith('set')) {
const field = prop[3].toLowerCase() + prop.slice(4);
return (value: any) => {
(config as any)[field] = value;
return new Proxy({}, handler);
};
}
return undefined;
},
};
return new Proxy({}, handler) as Builder<T>;
}
Usage:
interface DatabaseConfig {
host: string;
port: number;
database: string;
ssl: boolean;
}
const config = createBuilder<DatabaseConfig>()
.setHost('localhost')
.setPort(5432)
.setDatabase('myapp')
.setSsl(true)
.build();
The build property is typed as never until all keys of T are present in Set. This means calling .build() on an incomplete builder is a type error.
Builder With Optional Fields
Real objects often have both required and optional fields. Separate them in the type:
interface HttpRequest {
url: string;
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
headers: Record<string, string>;
body?: string;
timeout?: number;
}
type Required_Fields = 'url' | 'method' | 'headers';
class RequestBuilder<Set extends string = never> {
private request: Partial<HttpRequest> = {};
url(url: string): RequestBuilder<Set | 'url'> {
this.request.url = url;
return this as any;
}
method(
method: HttpRequest['method']
): RequestBuilder<Set | 'method'> {
this.request.method = method;
return this as any;
}
headers(
headers: Record<string, string>
): RequestBuilder<Set | 'headers'> {
this.request.headers = headers;
return this as any;
}
body(body: string): RequestBuilder<Set> {
this.request.body = body;
return this as any;
}
timeout(ms: number): RequestBuilder<Set> {
this.request.timeout = ms;
return this as any;
}
build(this: RequestBuilder<Required_Fields>): HttpRequest {
return this.request as HttpRequest;
}
}
const req = new RequestBuilder()
.url('https://api.example.com/users')
.method('POST')
.headers({ 'Content-Type': 'application/json' })
.body(JSON.stringify({ name: 'Alice' }))
.timeout(5000)
.build();
Optional setters like body() and timeout() return the same Set type without adding new fields. They can be called or skipped without affecting whether build() is available.
Builder for SQL Queries
A practical use case is a type-safe query builder:
type QueryState = {
table: boolean;
columns: boolean;
};
class SelectBuilder<S extends Partial<QueryState> = {}> {
private parts = {
table: '',
columns: [] as string[],
conditions: [] as string[],
orderBy: '',
limit: 0,
};
from(table: string): SelectBuilder<S & { table: true }> {
this.parts.table = table;
return this as any;
}
select(
...columns: string[]
): SelectBuilder<S & { columns: true }> {
this.parts.columns = columns;
return this as any;
}
where(condition: string): this {
this.parts.conditions.push(condition);
return this;
}
orderBy(column: string): this {
this.parts.orderBy = column;
return this;
}
toSQL(
this: SelectBuilder<{ table: true; columns: true }>
): string {
const cols = this.parts.columns.join(', ');
let sql = `SELECT ${cols} FROM ${this.parts.table}`;
if (this.parts.conditions.length > 0) {
sql += ` WHERE ${this.parts.conditions.join(' AND ')}`;
}
if (this.parts.orderBy) {
sql += ` ORDER BY ${this.parts.orderBy}`;
}
if (this.parts.limit > 0) {
sql += ` LIMIT ${this.parts.limit}`;
}
return sql;
}
}
const query = new SelectBuilder()
.select('id', 'name', 'email')
.from('users')
.where('active = true')
.orderBy('name')
.toSQL();
// "SELECT id, name, email FROM users WHERE active = true ORDER BY name"
You cannot call toSQL() without first calling both from() and select(). The compiler enforces query construction order.
When to Use This Pattern
The builder pattern shines when constructing objects with many fields, especially when some are required and others optional. It is particularly valuable for configuration objects, request builders, and query constructors. The type-safe variant adds compile-time guarantees that required steps have been completed before the final build step.
For simple objects with two or three fields, a plain constructor or factory function is simpler. Reach for the builder when the construction logic is complex enough that forgetting a step is a realistic mistake.
Wrap Up
The builder pattern in TypeScript goes beyond runtime validation. By tracking set fields as a generic type parameter, each setter call updates the type, and the build() method only becomes callable when all required fields are present. This turns construction errors from runtime exceptions into red squiggles in your editor, catching mistakes at the moment you write them rather than when the code runs.
Related articles
- TypeScript TypeScript Generics: Advanced Patterns and Constraints
Master advanced TypeScript generics patterns including conditional constraints, recursive types, generic factories, and type-safe builder patterns with examples.
- TypeScript TypeScript Utility Types: The Complete Guide
A comprehensive guide to all built-in TypeScript utility types including Partial, Required, Pick, Omit, Record, Extract, Exclude, and more with practical examples.
- TypeScript Variance Annotations (in/out) for TypeScript Generics
Understand covariance, contravariance, and how TypeScript's in and out annotations make generic type relationships explicit.
- TypeScript TypeScript Generics Deep Dive
Go beyond basic generics — constraints, conditional types, mapped types, infer, and real-world patterns for writing type-safe reusable code.