In classical object-oriented design, the Builder pattern is used to construct complex objects step-by-step with diverse optional and mandatory properties. However, traditional Builder implementations suffer from a critical architectural flaw: the compiler cannot tell when all mandatory parameters have been properly initialized.
Invoking terminal methods like .build() or .send() on an incompletely configured object can only be caught at runtime with defensive checks:
// Runtime failure that could have been prevented at compile time:
new HttpRequestBuilder().setMethod('POST').send();
// Uncaught Error: "URL is required to dispatch an HTTP request"The Typestate pattern (originating from linear and session type theory in languages like Rust and Idris) shifts initialization validation entirely into TypeScript's static analysis phase.
Every configuration step yields a new immutable builder instance stamped with an updated phantom state vector. Terminal methods like .build() are physically absent or disallowed by the compiler until every mandatory invariant is fulfilled.
+-------------------------------------------------------------------------+
| TYPESTATE TRANSITION MATRIX |
+-------------------------------------------------------------------------+
| [State 0: Empty builder] -> { hasUrl: false, hasMethod: false } |
| | |
| |--> .setUrl('https://api.agenticlab.dev') |
| v |
| [State 1: URL provided] -> { hasUrl: true, hasMethod: false } |
| | |
| |--> .setMethod('POST') |
| v |
| [State 2: Ready for dispatch]-> { hasUrl: true, hasMethod: true } |
| | |
| `--> .execute() available ONLY when { hasUrl: true, hasMethod: true }|
+-------------------------------------------------------------------------+In TypeScript, the Typestate pattern relies on three core mechanisms:
Builder<TState extends RequestState>).build(this: Builder<{ hasAll: true }>).Type Evolution Across Method Chaining:
Builder<{ hasUrl: false, hasPayload: false }>
|
| .setUrl("...")
v
Builder<{ hasUrl: true, hasPayload: false }>
|
| .setPayload({ id: 1 })
v
Builder<{ hasUrl: true, hasPayload: true }>
|
| .send() -> Unlocked! Compilation succeeds.
v
Promise<Response>.execute() without a defined table in .from(), or disallowing .where() clauses after .limit().Extract -> Transform -> Validate -> Load, preventing transposed execution stages.this Typing#Let's build a production-grade HTTP client requiring both a URL and a method before invocation:
// 1. Type-level state vector representation
export interface RequestState {
readonly hasUrl: boolean;
readonly hasMethod: boolean;
readonly hasBody: boolean;
}
// Initial unconfigured state
export type InitialState = {
readonly hasUrl: false;
readonly hasMethod: false;
readonly hasBody: false;
};
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
export interface HttpRequestConfig {
readonly url: string;
readonly method: HttpMethod;
readonly headers: Readonly<Record<string, string>>;
readonly body?: unknown;
}
/**
* Immutable Typestate HTTP Request Builder.
*/
export class HttpRequestBuilder<TState extends RequestState = InitialState> {
private constructor(
private readonly config: Partial<HttpRequestConfig> = { headers: {} }
) {}
public static create(): HttpRequestBuilder<InitialState> {
return new HttpRequestBuilder<InitialState>();
}
/**
* Assign target URL. Transitions hasUrl to true.
*/
public setUrl(
url: string
): HttpRequestBuilder<Omit<TState, 'hasUrl'> & { hasUrl: true }> {
return new HttpRequestBuilder({
...this.config,
url,
});
}
/**
* Assign HTTP verb. Transitions hasMethod to true.
*/
public setMethod(
method: HttpMethod
): HttpRequestBuilder<Omit<TState, 'hasMethod'> & { hasMethod: true }> {
return new HttpRequestBuilder({
...this.config,
method,
});
}
/**
* Append an HTTP header (does not alter required state flags)
*/
public addHeader(key: string, value: string): HttpRequestBuilder<TState> {
return new HttpRequestBuilder({
...this.config,
headers: {
...this.config.headers,
[key]: value,
},
});
}
/**
* Attach request payload. Transitions hasBody to true.
*/
public setBody<TBody>(
body: TBody
): HttpRequestBuilder<Omit<TState, 'hasBody'> & { hasBody: true }> {
return new HttpRequestBuilder({
...this.config,
body,
});
}
/**
* Terminal build method.
* CRITICAL: Available ONLY when hasUrl === true AND hasMethod === true!
*/
public build(
this: HttpRequestBuilder<RequestState & { hasUrl: true; hasMethod: true }>
): HttpRequestConfig {
return this.config as HttpRequestConfig;
}
/**
* Terminal execution method.
*/
public async execute<TResponse>(
this: HttpRequestBuilder<RequestState & { hasUrl: true; hasMethod: true }>
): Promise<TResponse> {
const config = this.build();
const response = await fetch(config.url, {
method: config.method,
headers: config.headers,
body: config.body ? JSON.stringify(config.body) : undefined,
});
if (!response.ok) {
throw new Error(`[HttpError] HTTP ${response.status}: ${response.statusText}`);
}
return (await response.json()) as TResponse;
}
}Demonstrating compile-time rejection of invalid configurations:
// ❌ SCENARIO 1: Omitted setMethod() call
const badRequest1 = HttpRequestBuilder.create()
.setUrl('https://api.agenticlab.dev/v1/users');
// COMPILE ERROR TS2684:
// The 'this' context of type 'HttpRequestBuilder<{ hasUrl: true; hasMethod: false }>'
// is not assignable to method's 'this' of type 'HttpRequestBuilder<... & { hasMethod: true }>'.
badRequest1.build();
// ❌ SCENARIO 2: Omitted URL
const badRequest2 = HttpRequestBuilder.create()
.setMethod('GET');
// COMPILE ERROR TS2684: hasUrl is missing!
badRequest2.execute();
// ✅ SCENARIO 3: Complete, valid configuration chain
const validRequest = HttpRequestBuilder.create()
.setUrl('https://api.agenticlab.dev/v1/users')
.setMethod('POST')
.setBody({ name: 'Alexander' })
.addHeader('Authorization', 'Bearer token_xyz');
// Compiles cleanly!
const config = validRequest.build();Instantiate with phantom type { hasUrl: false, hasMethod: false }. Terminal methods (.build(), .execute()) are locked.
Each setter returns a new instance overriding vector flags: Omit<TState, 'K'> & { K: true }.
The compiler matches active this against the method requirement: this: Builder<{ hasUrl: true; hasMethod: true }>. Missing flags trigger TS2684.
Runtime code has zero defensive if (!this.url) checks. The build() method directly returns a 100% valid configuration.
export interface SqlBuilderState {
readonly hasTable: boolean;
readonly hasColumns: boolean;
}
export interface SqlInitialState {
readonly hasTable: false;
readonly hasColumns: false;
}
export class SqlSelectQueryBuilder<TState extends SqlBuilderState = SqlInitialState> {
private constructor(
private readonly table: string = '',
private readonly columns: ReadonlyArray<string> = [],
private readonly whereClauses: ReadonlyArray<string> = [],
private readonly limitCount?: number
) {}
public static select(): SqlSelectQueryBuilder<SqlInitialState> {
return new SqlSelectQueryBuilder();
}
public columns<TCols extends string[]>(
...cols: TCols
): SqlSelectQueryBuilder<Omit<TState, 'hasColumns'> & { hasColumns: true }> {
return new SqlSelectQueryBuilder(
this.table,
cols,
this.whereClauses,
this.limitCount
);
}
public from(
tableName: string
): SqlSelectQueryBuilder<Omit<TState, 'hasTable'> & { hasTable: true }> {
return new SqlSelectQueryBuilder(
tableName,
this.columns,
this.whereClauses,
this.limitCount
);
}
public where(condition: string): SqlSelectQueryBuilder<TState> {
return new SqlSelectQueryBuilder(
this.table,
this.columns,
[...this.whereClauses, condition],
this.limitCount
);
}
public limit(count: number): SqlSelectQueryBuilder<TState> {
return new SqlSelectQueryBuilder(
this.table,
this.columns,
this.whereClauses,
count
);
}
/**
* Render raw SQL string.
* Available ONLY when both table and columns are specified!
*/
public toSql(
this: SqlSelectQueryBuilder<{ hasTable: true; hasColumns: true }>
): string {
const cols = this.columns.join(', ');
let sql = `SELECT ${cols} FROM ${this.table}`;
if (this.whereClauses.length > 0) {
sql += ` WHERE ${this.whereClauses.join(' AND ')}`;
}
if (typeof this.limitCount === 'number') {
sql += ` LIMIT ${this.limitCount}`;
}
return `${sql};`;
}
}Mutating this internally and returning this as any breaks branching code where developers reuse a base builder across multiple requests.
// ❌ ANTIPATTERN: Shared instance mutation breaks branching
class BadBuilder<T> {
private config: any = {};
setField(v: any) {
this.config.field = v; // In-place mutation!
return this;
}
}
// ✅ CORRECT: Immutable instantiation
setField(v: FieldType) {
return new GoodBuilder({ ...this.config, field: v });
}Assigning an intermediate builder with let builder = Builder.create(); without explicit generic parameters widens the type to InitialState, permanently blocking subsequent .build() calls.
// ❌ ANTIPATTERN: let assignment causes type widening
let builder = HttpRequestBuilder.create();
builder = builder.setUrl('https://api.dev') as any; // Type system bypassed!
// ✅ CORRECT: Fluent method chaining
const response = await HttpRequestBuilder.create()
.setUrl('https://api.dev')
.setMethod('GET')
.execute();Attempting to track state via Partial<Config> allows undefined properties to satisfy presence checks, causing runtime defects.
// ❌ ANTIPATTERN: Presence check on { url?: string }
// Passing { url: undefined } satisfies the structural constraint!
// ✅ CORRECT: Explicit boolean flags { readonly hasUrl: true }If step B must strictly follow step A, but both allow execution from InitialState, developers can invoke B prematurely. Typestate requires explicit incoming this constraints.
// ✅ CORRECT: setPassword requires prior setEmail execution
public setPassword(this: AccountBuilder<{ hasEmail: true }>, pwd: string) { ... }Identify mandatory and optional parameters. Map out the prerequisite sequence prior to finalization.
Establish a BuilderState interface with boolean flags { hasA: boolean; hasB: boolean } and an InitialState with false flags.
Make the constructor private, provide a static create() factory, and return Omit<TState, 'K'> & { K: true } from setters.
Apply the guard signature: build(this: Builder<{ hasA: true; hasB: true }>).
Author type tests (tsd or vitest typecheck) verifying compile errors when mandatory setters are omitted.