TypeScript 47 🔷 Builder Patterns with Types
The builder pattern solves a specific problem: an object with many optional fields, where construction requires a sequence of decisions that should be validated before the object exists. A plain constructor or object literal gives you no way to say “these fields must be set before build() is called.” A builder does. In a typed language, the builder can go further — the type system itself can enforce that the required steps have been completed, that the sequence is correct, and that build() cannot be called until the object is complete. This is the typed builder pattern, and it is one of the places where TypeScript’s type system genuinely earns its keep: the runtime code is straightforward, and the compile-time types prevent an entire class of errors. This chapter covers the basic builder, the fluent interface with method chaining, the phantom-type technique that tracks builder state in the type, the staged builder that enforces order, and the tradeoffs that determine when a typed builder is worth the ceremony.
Key point: A typed builder is a class or interface that accumulates state and exposes a build() method returning the final object. The type-level refinement is that the builder’s type can change as methods are called, so the compiler knows which methods are available at each stage. The phantom type technique uses a type parameter that exists only in the type system — it is never stored at runtime — to track which required fields have been set. A staged builder uses separate interfaces for each stage, with each interface exposing only the methods valid at that stage. Both enforce the invariant at compile time.
The problem a builder solves
Consider a configuration object with a dozen fields, of which three are required and the rest are optional with defaults. A plain interface expresses the shape, but it does not express the process of constructing a valid one.
interface ServerConfig {
host: string;
port: number;
protocol: "http" | "https";
timeout?: number;
retries?: number;
maxConnections?: number;
logLevel?: "debug" | "info" | "warn" | "error";
tls?: { cert: string; key: string };
}
With a plain object literal, every caller has to provide the three required fields and remember the defaults for the rest. There is no way to enforce that the required fields are set before the object is used, and no way to provide defaults without repeating them at every call site.
Why a constructor is not enough. A constructor with a dozen parameters is unusable — the call sites are unreadable, the parameter order is easy to get wrong, and adding a field is a breaking change. Named parameters via an options object help, but the options object is still an object literal with no validation.
Why a builder is the right shape. A builder separates the construction process from the final object. Each method sets one field and returns the builder, so calls chain. The build() method produces the final object, applying defaults and validating the combination. The construction is readable (new ServerBuilder().host("api.example.com").port(8080).build()) and the validation is centralized.
Why types make it better. Without types, the builder trusts the caller to call the right methods in the right order. A typed builder can reject a build() call if a required field is missing, reject a method that does not apply to the current state, and reject a field value of the wrong type. The types encode the process, and the compiler enforces it.
The basic builder
The straightforward typed builder is a class with a method per field and a build() method that returns the configured object. The type is a class, and the methods return this to enable chaining.
interface ServerConfig {
host: string;
port: number;
protocol: "http" | "https";
timeout: number;
retries: number;
}
class ServerConfigBuilder {
private config: Partial<ServerConfig> = {};
host(host: string): this {
this.config.host = host;
return this;
}
port(port: number): this {
this.config.port = port;
return this;
}
protocol(protocol: "http" | "https"): this {
this.config.protocol = protocol;
return this;
}
timeout(seconds: number): this {
this.config.timeout = seconds;
return this;
}
retries(count: number): this {
this.config.retries = count;
return this;
}
build(): ServerConfig {
if (!this.config.host) throw new Error("host is required");
if (!this.config.port) throw new Error("port is required");
if (!this.config.protocol) throw new Error("protocol is required");
return {
host: this.config.host,
port: this.config.port,
protocol: this.config.protocol,
timeout: this.config.timeout ?? 30,
retries: this.config.retries ?? 3,
};
}
}
The builder stores a Partial<ServerConfig> internally and fills it in as methods are called. build() validates the required fields and supplies defaults for the optional ones. The return type of each method is this, which means the chain preserves the subclass type if the builder is extended.
Why this and not the class name. Returning this means the return type is the type of the instance, not the declaring class. If a subclass extends ServerConfigBuilder, the methods return the subclass type, and the chain continues in the subclass. Returning the class name would lose the subclass type and break chaining on subclasses.
Why the validation is in build(). The builder’s methods record the caller’s intent; they do not validate the combination. Validation happens once, in build(), when the full state is known. This is the right place because cross-field rules — “TLS requires a protocol of https,” “retries must be less than 10 when timeout is under 5 seconds” — need to see all the fields at once.
Why the runtime error is a fallback, not the primary check. The throw statements in build() catch the case where a caller bypasses the type system — for example, with a type assertion. In normal use, the type-level enforcement should prevent the error from ever firing. The runtime check is the safety net.
Why this basic builder is often enough. For a small configuration object with a handful of fields, the basic builder with runtime validation is clear, readable, and easy to maintain. The type-level refinements in the next sections are for cases where the process is complex enough that enforcing it at compile time is worth the additional type machinery.
Fluent chaining and the this return type
The fluent interface is what makes the builder pleasant to use. Each method returns the builder, so calls chain in a single expression. The this return type is what preserves the fluent behavior across inheritance.
const config = new ServerConfigBuilder()
.host("api.example.com")
.port(8080)
.protocol("https")
.timeout(60)
.build();
Each call returns this, so the next method is available. The chain is a single expression, and the final call is build(), which produces the configured object. The type of config is ServerConfig, fully inferred from the chain.
Why the order of methods does not matter in the basic builder. The basic builder allows any method at any time — host() then port(), or port() then host(). The order is a matter of the caller’s preference. This flexibility is the point: the builder is a convenient way to set fields, not a state machine.
Why the type of the chain is ServerConfigBuilder. Because each method returns this, the type of the intermediate expression is the builder’s type. The chain is type-safe at every step — calling an unknown method is a compile error, and passing the wrong argument type is a compile error. The builder’s methods are the vocabulary, and the compiler enforces it.
Why the basic builder cannot enforce “required fields are set.” The type of the builder is the same before and after calling .host(). The compiler has no way to know that a required field was set, because the builder’s type does not track which methods have been called. This is the limitation that the phantom type technique addresses.
The phantom type technique
A phantom type is a type parameter that exists only in the type system. It is declared on the class, used in the type of methods or fields, and never has a runtime representation. The builder uses it to track state — usually, which required fields have been set.
interface ServerConfig {
host: string;
port: number;
protocol: "http" | "https";
}
class ServerConfigBuilder<HasHost extends boolean = false, HasPort extends boolean = false> {
private config: Partial<ServerConfig> = {};
host(host: string): ServerConfigBuilder<true, HasPort> {
this.config.host = host;
return this as unknown as ServerConfigBuilder<true, HasPort>;
}
port(port: number): ServerConfigBuilder<HasHost, true> {
this.config.port = port;
return this as unknown as ServerConfigBuilder<HasHost, true>;
}
protocol(protocol: "http" | "https"): this {
this.config.protocol = protocol;
return this;
}
build(this: ServerConfigBuilder<true, true>): ServerConfig {
return {
host: this.config.host!,
port: this.config.port!,
protocol: this.config.protocol ?? "http",
};
}
}
The builder has two type parameters, HasHost and HasPort, both defaulting to false. The host() method returns ServerConfigBuilder<true, HasPort> — it sets HasHost to true while preserving the current HasPort. The port() method does the same for HasPort. The build() method uses a this parameter to require that both are true.
Why the this parameter on build(). The this parameter is a special TypeScript feature that constrains the type of the receiver. build(this: ServerConfigBuilder<true, true>) means “this method can only be called when the builder is fully configured.” Calling build() on a builder where HasHost or HasPort is false is a compile error.
Why the cast is necessary. The runtime object is the same builder regardless of the type parameters. The phantom type parameters have no runtime representation, so the return this cannot satisfy the return type directly. The as unknown as cast tells the compiler “trust me, the runtime is the same; the type parameters have changed.” This is the standard idiom for phantom types, and it is safe because the type parameters genuinely have no runtime effect.
Why this enforces the invariant. After new ServerConfigBuilder(), the type is ServerConfigBuilder<false, false>. Calling .build() on it is a compile error. After .host("x"), the type is ServerConfigBuilder<true, false>. build() is still a compile error. After .port(8080), the type is ServerConfigBuilder<true, true>, and build() compiles. The compiler tracks which required methods have been called, and build() is only available when all required methods have been called.
Why the order still does not matter. The caller can call host() then port() or port() then host(). Both produce ServerConfigBuilder<true, true>. The phantom types track whether each required method was called, not in what order. If order matters, a staged builder is needed.
Why phantom types are worth the casts. The casts are localized to the builder’s own methods — callers never see them. The benefit is that
build()cannot be called prematurely, which is a real class of bug. The tradeoff is that the builder’s type is more complex and the error messages whenbuild()is unavailable are less direct. For builders where the invariant matters, the trade is usually worth it.
Staged builders
A staged builder enforces not just which fields are set, but the order in which they are set. It uses separate interfaces for each stage, with each interface exposing only the methods valid at that stage.
interface ServerConfig {
host: string;
port: number;
protocol: "http" | "https";
}
interface HostStage {
host(host: string): PortStage;
}
interface PortStage {
port(port: number): ProtocolStage;
}
interface ProtocolStage {
protocol(protocol: "http" | "https"): BuildStage;
}
interface BuildStage {
build(): ServerConfig;
}
class ServerConfigBuilder implements HostStage, PortStage, ProtocolStage, BuildStage {
private config: Partial<ServerConfig> = {};
host(host: string): PortStage {
this.config.host = host;
return this;
}
port(port: number): ProtocolStage {
this.config.port = port;
return this;
}
protocol(protocol: "http" | "https"): BuildStage {
this.config.protocol = protocol;
return this;
}
build(): ServerConfig {
return {
host: this.config.host!,
port: this.config.port!,
protocol: this.config.protocol!,
};
}
}
function serverBuilder(): HostStage {
return new ServerConfigBuilder();
}
The entry point is serverBuilder(), which returns a HostStage. The only method available is host(), which returns a PortStage. The only method available on that is port(), which returns a ProtocolStage. And so on until BuildStage, where build() is available.
Why staged builders enforce order. The type returned by each method is the next stage’s interface, which exposes only the next method. There is no way to call port() before host(), because HostStage does not declare port(). The compiler enforces the order through the types.
Why the class implements all stages. The class is the concrete implementation, and it satisfies every stage interface. The stages are views onto the class — each one exposes a subset of the methods. The entry point function hides the class and returns only the first stage, so the caller cannot bypass the sequence.
Why this is stricter than phantom types. Phantom types track which methods have been called; staged builders track which methods are available. A phantom-typed builder allows any order; a staged builder enforces one specific order. Staged builders are the right tool when the process genuinely has a sequence — when a port cannot be set before a host, or when a protocol choice affects which other methods are available.
Why staged builders are verbose. Each stage is a separate interface, and the class implements all of them. The entry point function is a layer of indirection. For a builder with three required fields, this is manageable. For a builder with ten required fields, the number of interfaces becomes unwieldy, and the phantom type approach or a simpler validation approach is better.
Conditional stages
The staged builder can be refined to vary the available methods based on earlier choices. If choosing “https” enables a certificate method and choosing “http” does not, the type of the stage after the protocol choice can differ.
interface HttpStage {
build(): ServerConfig;
}
interface HttpsStage {
cert(cert: string): HttpsStage;
key(key: string): HttpsStage;
build(): ServerConfig;
}
interface ProtocolStage {
protocol(p: "http"): HttpStage;
protocol(p: "https"): HttpsStage;
}
The protocol method is overloaded: it returns HttpStage for “http” and HttpsStage for “https.” The HttpsStage exposes cert() and key(), which HttpStage does not. The compiler selects the return type based on the argument, and the available methods differ accordingly.
Why conditional stages are powerful. They express rules like “TLS requires a certificate,” “the retry policy is only available when retries are enabled,” and “authentication methods depend on the selected auth type.” These are real configuration rules, and enforcing them at compile time prevents invalid configurations from being constructed.
Why conditional stages are the most complex form. The number of stages grows with the number of conditional choices, and the mapping from choice to stage is explicit. For a configuration with three binary choices, there are eight possible paths, and either the stages overlap cleverly or the number of interfaces explodes. The technique is for cases where the conditional rules are both real and stable — where the cost of maintaining the types is repaid by the bugs prevented.
Tradeoffs and when to use
Builders with type-level enforcement are not free. The types are more complex, the errors are less direct, and the maintenance burden is higher. Knowing when the trade is worth it is part of the skill.
When a typed builder is worth it:
- The object has many fields, of which several are required.
- Construction involves decisions that affect which other fields apply.
- The order of configuration matters, or the process has stages.
- The builder is used in a library and exposed to other developers.
- Invalid configurations are a real source of bugs.
When it is not worth it:
- The object has few fields and the shape is simple.
- Every field is required and the object literal is readable.
- The builder is used in one place and the runtime validation is enough.
- The type-level enforcement would be read by one person and maintained by none.
The runtime validation fallback. For most cases, a builder that validates at runtime and throws a clear error is sufficient. The type-level enforcement catches the error at compile time, which is better, but the runtime check catches it at the same place if the type-level machinery is too costly. The choice is between “cannot compile” and “throws at runtime,” and both are better than “silently produces an invalid object.”
Why the builder pattern is more common in libraries than in application code. A library exposes its builder to many callers, and the type-level enforcement benefits all of them. In application code, a builder is often used in a few places, and a plain object with a factory function is simpler. The pattern scales with the number of callers and the complexity of the configuration.
Why the phantom type technique is preferred over runtime state. A builder that tracks state at runtime — “has host been set?” — and validates on every method call is possible, but it moves the check from compile time to runtime and adds overhead to every method. The phantom type tracks the same state in the type system, where the check is free and happens once, at compile time. When the type system can express the invariant, it is the better place to express it.
Complete Example Session
// ============================================
// PART 1: BASIC BUILDER
// ============================================
interface ConnectionConfig {
host: string;
port: number;
protocol: "http" | "https";
timeout: number;
}
class ConnectionBuilder {
private config: Partial<ConnectionConfig> = {};
host(host: string): this {
this.config.host = host;
return this;
}
port(port: number): this {
this.config.port = port;
return this;
}
protocol(protocol: "http" | "https"): this {
this.config.protocol = protocol;
return this;
}
timeout(seconds: number): this {
this.config.timeout = seconds;
return this;
}
build(): ConnectionConfig {
if (!this.config.host) throw new Error("host required");
if (!this.config.port) throw new Error("port required");
if (!this.config.protocol) throw new Error("protocol required");
return {
host: this.config.host,
port: this.config.port,
protocol: this.config.protocol,
timeout: this.config.timeout ?? 30,
};
}
}
const conn = new ConnectionBuilder()
.host("api.example.com")
.port(8080)
.protocol("https")
.build();
// ============================================
// PART 2: PHANTOM TYPE BUILDER
// ============================================
class StrictConnectionBuilder<
HasHost extends boolean = false,
HasPort extends boolean = false,
> {
private config: Partial<ConnectionConfig> = {};
host(host: string): StrictConnectionBuilder<true, HasPort> {
this.config.host = host;
return this as unknown as StrictConnectionBuilder<true, HasPort>;
}
port(port: number): StrictConnectionBuilder<HasHost, true> {
this.config.port = port;
return this as unknown as StrictConnectionBuilder<HasHost, true>;
}
build(this: StrictConnectionBuilder<true, true>): ConnectionConfig {
return {
host: this.config.host!,
port: this.config.port!,
protocol: this.config.protocol ?? "http",
timeout: this.config.timeout ?? 30,
};
}
}
const strictConn = new StrictConnectionBuilder()
.host("api.example.com")
.port(8080)
.build(); // ✅ both required methods called
// new StrictConnectionBuilder().build(); // ❌ HasHost and HasPort are false
// new StrictConnectionBuilder().host("x").build(); // ❌ HasPort is false
// ============================================
// PART 3: STAGED BUILDER
// ============================================
interface HostStage {
host(host: string): PortStage;
}
interface PortStage {
port(port: number): ProtocolStage;
}
interface ProtocolStage {
protocol(p: "http" | "https"): BuildStage;
}
interface BuildStage {
build(): ConnectionConfig;
}
class StagedConnectionBuilder
implements HostStage, PortStage, ProtocolStage, BuildStage
{
private config: Partial<ConnectionConfig> = {};
host(host: string): PortStage {
this.config.host = host;
return this;
}
port(port: number): ProtocolStage {
this.config.port = port;
return this;
}
protocol(p: "http" | "https"): BuildStage {
this.config.protocol = p;
return this;
}
build(): ConnectionConfig {
return {
host: this.config.host!,
port: this.config.port!,
protocol: this.config.protocol!,
timeout: this.config.timeout ?? 30,
};
}
}
function connectionBuilder(): HostStage {
return new StagedConnectionBuilder();
}
const stagedConn = connectionBuilder()
.host("api.example.com")
.port(8080)
.protocol("https")
.build(); // ✅ correct order
// connectionBuilder().port(8080); // ❌ port not on HostStage
// ============================================
// PART 4: CONDITIONAL STAGES
// ============================================
interface HttpStage {
build(): ConnectionConfig;
}
interface HttpsStage {
cert(cert: string): HttpsStage;
key(key: string): HttpsStage;
build(): ConnectionConfig;
}
interface ProtoStage {
protocol(p: "http"): HttpStage;
protocol(p: "https"): HttpsStage;
}
class ConditionalBuilder implements HostStage, PortStage, ProtoStage, HttpStage, HttpsStage {
private config: Record<string, unknown> = {};
host(host: string): PortStage {
this.config["host"] = host;
return this;
}
port(port: number): ProtoStage {
this.config["port"] = port;
return this;
}
protocol(p: "http"): HttpStage;
protocol(p: "https"): HttpsStage;
protocol(p: "http" | "https"): HttpStage | HttpsStage {
this.config["protocol"] = p;
return this;
}
cert(cert: string): HttpsStage {
this.config["cert"] = cert;
return this;
}
key(key: string): HttpsStage {
this.config["key"] = key;
return this;
}
build(): ConnectionConfig {
return this.config as unknown as ConnectionConfig;
}
}
// ============================================
// PART 5: THE SIMPLEST ALTERNATIVE
// ============================================
// For simple cases, a factory function with defaults is enough:
interface SimpleConfig {
host: string;
port?: number;
protocol?: "http" | "https";
timeout?: number;
}
function createConfig(options: SimpleConfig): Required<SimpleConfig> {
return {
host: options.host,
port: options.port ?? 8080,
protocol: options.protocol ?? "http",
timeout: options.timeout ?? 30,
};
}
const simple = createConfig({ host: "api.example.com" });
The five parts show the progression: basic builder with runtime validation, phantom-typed builder with compile-time required-field enforcement, staged builder with order enforcement, conditional stages for branching rules, and the simple factory as the baseline comparison.
Quick Reference
Builder Forms
| Form | Enforces | Complexity |
|---|---|---|
| Basic | Runtime validation | Low |
Fluent (this return) | Type-safe chaining | Low |
| Phantom type | Required methods called | Medium |
| Staged | Method order | High |
| Conditional | Choice-dependent methods | Highest |
Phantom Type Pattern
| Step | Code |
|---|---|
| Declare parameters | class Builder<A extends boolean = false> |
| Set on method call | method(): Builder<true, B> |
| Preserve others | Builder<A, true> |
Constrain build | build(this: Builder<true, true>) |
| Cast | return this as unknown as Builder<true, B> |
Staged Pattern
| Step | Code |
|---|---|
| Define stages | interface Stage1 { m(): Stage2 } |
| Implement all | class Builder implements Stage1, Stage2 |
| Hide class | function builder(): Stage1 { return new Builder(); } |
When to Use
| Situation | Approach |
|---|---|
| Few required fields | Factory function |
| Many fields, runtime validation OK | Basic builder |
| Required fields must be set | Phantom type |
| Order matters | Staged builder |
| Choices affect available methods | Conditional stages |
| Read by one person | Simplest that works |
Best Practices
✅ Do This:
// Return `this` for fluent chaining
host(host: string): this { this.config.host = host; return this; } // ✅
// Validate in build(), not in each method
build(): Config { if (!this.config.host) throw new Error("host required"); ... } // ✅
// Use phantom types to track required methods
host(h: string): Builder<true, B> { ... } // ✅
// Use `this` parameter to constrain build()
build(this: Builder<true, true>): Config { ... } // ✅
// Hide the class behind a stage-returning factory
function builder(): Stage1 { return new Builder(); } // ✅
// Fall back to a factory when the builder is overkill
function createConfig(o: SimpleConfig): Required<SimpleConfig> { ... } // ✅
❌ Don’t Do This:
// Don't return the class name instead of `this`
host(h: string): ServerBuilder { ... } // breaks subclass chaining // ⚠️
// Don't validate cross-field rules in individual methods
host(h: string): this { if (!h) throw new Error(); ... } // too early // ⚠️
// Don't use phantom types for order enforcement
// Phantom types track "called", not "in what order" // ⚠️
// Don't build the most complex form for a simple object
// Start with the basic builder or a factory // ⚠️
// Don't scatter `as` casts in caller code
// The casts belong inside the builder's methods // ⚠️
Common Pitfalls
| Pitfall | Problem | Solution |
|---|---|---|
| Returning class name | Breaks subclass chaining | Return this |
| Validating in setters | Cross-field rules cannot be checked | Validate in build() |
| Phantom type cast in caller | Leaks type internals | Cast inside the builder |
| Too many stage interfaces | Maintenance burden | Use basic or phantom type |
build() without this constraint | Callable before complete | Add this parameter |
| Forgetting default type params | Must specify on every use | = false defaults |
| Over-engineering simple config | Complexity exceeds benefit | Use a factory function |
| Confusing order with presence | Staged vs phantom mismatch | Match the tool to the rule |
Real-World Examples
1. HTTP client configuration
new HttpClientBuilder()
.baseUrl("https://api.example.com")
.timeout(5000)
.retries(3)
.build();
2. Query builder
new QueryBuilder()
.select("id", "name")
.from("users")
.where("active = true")
.build();
3. URL builder
new UrlBuilder()
.protocol("https")
.host("example.com")
.path("/api/users")
.query("page", "1")
.build();
4. Test data builder
new UserBuilder()
.withName("Alice")
.withEmail("alice@example.com")
.build();
5. Phantom-typed required fields
new RequestBuilder()
.method("POST")
.url("/api/users")
.build(); // ✅ both required
6. Staged builder for mandatory order
builder().host("x").port(80).protocol("http").build();
7. Conditional stages for auth
builder().auth("basic").username("u").password("p").build();
builder().auth("token").token("t").build();
8. Builder with defaults
class ConfigBuilder {
private timeout = 30; // default applied unless overridden
}
9. Builder for immutable objects
class FrozenBuilder {
build(): Readonly<Config> { return Object.freeze({...}); }
}
10. Factory as the simpler alternative
function createConfig(o: SimpleConfig): Required<SimpleConfig> { ... }
Visual: Builder Progression
┌──────────────────────────────────────────────────────────┐
│ LEVEL 1: BASIC BUILDER │
│ Chaining, runtime validation in build() │
│ build() callable at any time │
│ │
├──────────────────────────────────────────────────────────┤
│ LEVEL 2: PHANTOM TYPES │
│ Required methods tracked in the type │
│ build() only callable when all required set │
│ Order still free │
│ │
├──────────────────────────────────────────────────────────┤
│ LEVEL 3: STAGED BUILDER │
│ Each method returns the next stage's interface │
│ Order enforced by the types │
│ More interfaces to maintain │
│ │
├──────────────────────────────────────────────────────────┤
│ LEVEL 4: CONDITIONAL STAGES │
│ Available methods depend on earlier choices │
│ Most expressive, most complex │
│ │
└──────────────────────────────────────────────────────────┘
Visual: Phantom Type Flow
┌──────────────────────────────────────────────────────────┐
│ new Builder<false, false>() │
│ │ │
│ │ .host("x") │
│ ▼ │
│ Builder<true, false> │
│ │ │
│ │ .build() ❌ HasPort is false │
│ │ │
│ │ .port(80) │
│ ▼ │
│ Builder<true, true> │
│ │ │
│ │ .build() ✅ │
│ ▼ │
│ Config { host: "x", port: 80 } │
│ │
│ The type parameters track which methods were called. │
│ │
└──────────────────────────────────────────────────────────┘
Visual: Staged Builder Flow
┌──────────────────────────────────────────────────────────┐
│ builder() │
│ │ │
│ ▼ │
│ HostStage { host(): PortStage } │
│ │ │
│ │ .host("x") │
│ ▼ │
│ PortStage { port(): ProtocolStage } │
│ │ │
│ │ .port(80) │
│ ▼ │
│ ProtocolStage { protocol(): BuildStage } │
│ │ │
│ │ .protocol("http") │
│ ▼ │
│ BuildStage { build(): Config } │
│ │ │
│ │ .build() │
│ ▼ │
│ Config │
│ │
│ Each stage exposes only the next valid method. │
│ │
└──────────────────────────────────────────────────────────┘
Visual: When to Use Which
┌──────────────────────────────────────────────────────────┐
│ How complex is the construction? │
│ │ │
│ ├── Simple (few fields, defaults) │
│ │ └── Factory function │
│ │ │
│ ├── Many fields, runtime validation OK │
│ │ └── Basic builder │
│ │ │
│ ├── Required fields, order free │
│ │ └── Phantom type builder │
│ │ │
│ ├── Order matters │
│ │ └── Staged builder │
│ │ │
│ └── Choices affect available methods │
│ └── Conditional stages │
│ │
└──────────────────────────────────────────────────────────┘
Visual: Tradeoff
┌──────────────────────────────────────────────────────────┐
│ Type complexity │
│ ▲ │
│ │ ┌──────────────┐ │
│ │ │ Conditional │ │
│ │ │ stages │ │
│ │ ┌───────────┴──────────────┘ │
│ │ │ Staged builder │
│ │ ┌─────────┴───────────┐ │
│ │ │ Phantom types │ │
│ │ ┌─┴─────────────────────┴──┐ │
│ │ │ Basic builder │ │
│ │ ┌┴──────────────────────────┴┐ │
│ │ │ Factory function │ │
│ │ └────────────────────────────┘ │
│ └──────────────────────────────────────────► Safety │
│ │
│ More safety costs more type complexity. │
│ Choose the lowest level that enforces the invariant. │
│ │
└──────────────────────────────────────────────────────────┘
Summary
| Builder Form | Enforces | Cost |
|---|---|---|
| Factory function | Shape only | Lowest |
| Basic builder | Runtime validation | Low |
Fluent with this | Type-safe chaining | Low |
| Phantom type | Required methods called | Medium |
| Staged | Method order | High |
| Conditional stages | Choice-dependent methods | Highest |
Key takeaways:
- A builder separates construction from the object — methods set fields,
build()produces the final object, and validation is centralized - The fluent interface uses
thisas the return type — this preserves subclass chaining and makes the chain type-safe at every step - Validation belongs in
build(), not in individual setters, because cross-field rules need the full state - Phantom types track which required methods have been called — the builder’s type parameters change as methods are invoked, and
build()is constrained to require the fully-configured type - The
thisparameter onbuild()is how the constraint is enforced —build(this: Builder<true, true>)makes the method unavailable until the required methods have been called - Staged builders enforce order by returning a different interface from each method, exposing only the next valid method
- Conditional stages vary the available methods based on earlier choices, which is the most expressive form and the most complex
- The casts belong inside the builder’s methods, never in caller code
- The simplest form that enforces the invariant is the right choice — a factory function beats a phantom-typed builder when the shape is fixed and the fields are few
- Type-level enforcement catches errors at compile time, which is strictly better than runtime, but the cost is type complexity that must be maintained
Remember: The typed builder pattern is a technique for encoding a construction process in the type system. It solves real problems — required fields that must be set, order that must be respected, choices that affect what is available next. But the type machinery has a cost, and the most common failure mode is over-engineering: building a four-stage conditional builder for an object with two fields. Start with the simplest form, and add type-level enforcement only when the runtime validation has proven insufficient.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!