En el diseño orientado a objetos clásico, el patrón Builder (Constructor) se utiliza para la construcción progresiva de objetos complejos con múltiples parámetros opcionales y obligatorios. Sin embargo, la implementación tradicional de Builder adolece de un defecto arquitectónico crítico: el compilador no puede determinar en qué momento se han inicializado todos los campos obligatorios.
Invocar métodos terminales como .build() o .send() sobre un objeto incompleto solo se detecta en tiempo de ejecución mediante comprobaciones defensivas:
// Error en runtime que podría haberse evitado en compilación:
new HttpRequestBuilder().setMethod('POST').send();
// Uncaught Error: "La URL es obligatoria para ejecutar una petición de red"El patrón arquitectónico Typestate (heredado de la teoría de tipos lineales y de sesión en lenguajes como Rust e Idris) traslada la verificación del contrato de inicialización a la fase de análisis estático de TypeScript.
Cada paso de construcción devuelve una nueva instancia inmutable del constructor con un tipo fantasma de estado actualizado, y el método terminal .build() no está disponible en el autocompletado del IDE ni lo permite el compilador hasta que se satisfagan todos los invariantes obligatorios.
+-------------------------------------------------------------------------+
| MATRIZ DE TRANSICIÓN DE ESTADOS TYPESTATE |
+-------------------------------------------------------------------------+
| [Estado 0: Constructor vacío] -> { hasUrl: false, hasMethod: false } |
| | |
| |--> .setUrl('https://api.agenticlab.dev') |
| v |
| [Estado 1: URL fijada] -> { hasUrl: true, hasMethod: false } |
| | |
| |--> .setMethod('POST') |
| v |
| [Estado 2: Listo para envío] -> { hasUrl: true, hasMethod: true } |
| | |
| `--> .execute() disponible ÚNICAMENTE con { hasUrl: true, hasMethod: true }|
+-------------------------------------------------------------------------+En TypeScript, el patrón Typestate se apoya en tres mecanismos fundamentales:
Builder<TState extends RequestState>).build(this: Builder<{ hasAll: true }>).Evolución del tipo en el encadenamiento de métodos:
Builder<{ hasUrl: false, hasPayload: false }>
|
| .setUrl("...")
v
Builder<{ hasUrl: true, hasPayload: false }>
|
| .setPayload({ id: 1 })
v
Builder<{ hasUrl: true, hasPayload: true }>
|
| .send() -> ¡Desbloqueado! Compilación permitida.
v
Promise<Response>.execute() sin definir la tabla en .from() o prohibición de cláusulas .where() tras un .limit().Extract -> Transform -> Validate -> Load, impidiendo la alteración de la secuencia.this#Creemos un cliente HTTP que obligue a proporcionar la URL y el método antes de la ejecución:
// 1. Vector de estado a nivel de tipos
export interface RequestState {
readonly hasUrl: boolean;
readonly hasMethod: boolean;
readonly hasBody: boolean;
}
// Estado inicial vacío
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;
}
/**
* Constructor HTTP inmutable con Typestate.
*/
export class HttpRequestBuilder<TState extends RequestState = InitialState> {
private constructor(
private readonly config: Partial<HttpRequestConfig> = { headers: {} }
) {}
public static create(): HttpRequestBuilder<InitialState> {
return new HttpRequestBuilder<InitialState>();
}
/**
* Asignar dirección URL de destino. Cambia hasUrl a true.
*/
public setUrl(
url: string
): HttpRequestBuilder<Omit<TState, 'hasUrl'> & { hasUrl: true }> {
return new HttpRequestBuilder({
...this.config,
url,
});
}
/**
* Asignar método HTTP. Cambia hasMethod a true.
*/
public setMethod(
method: HttpMethod
): HttpRequestBuilder<Omit<TState, 'hasMethod'> & { hasMethod: true }> {
return new HttpRequestBuilder({
...this.config,
method,
});
}
/**
* Añadir cabecera HTTP (no altera los estados obligatorios)
*/
public addHeader(key: string, value: string): HttpRequestBuilder<TState> {
return new HttpRequestBuilder({
...this.config,
headers: {
...this.config.headers,
[key]: value,
},
});
}
/**
* Asignar cuerpo de la petición. Cambia hasBody a true.
*/
public setBody<TBody>(
body: TBody
): HttpRequestBuilder<Omit<TState, 'hasBody'> & { hasBody: true }> {
return new HttpRequestBuilder({
...this.config,
body,
});
}
/**
* Método terminal de compilación.
* MOMENTO CRÍTICO: ¡Disponible ÚNICAMENTE si hasUrl === true Y hasMethod === true!
*/
public build(
this: HttpRequestBuilder<RequestState & { hasUrl: true; hasMethod: true }>
): HttpRequestConfig {
return this.config as HttpRequestConfig;
}
/**
* Método terminal de ejecución de la petición.
*/
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;
}
}Demostremos cómo el compilador bloquea código incompleto:
// ❌ ESCENARIO 1: Omisión de llamada a setMethod()
const badRequest1 = HttpRequestBuilder.create()
.setUrl('https://api.agenticlab.dev/v1/users');
// ERROR DE COMPILACIÓN 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();
// ❌ ESCENARIO 2: Omisión de URL
const badRequest2 = HttpRequestBuilder.create()
.setMethod('GET');
// ERROR DE COMPILACIÓN TS2684: ¡falta hasUrl!
badRequest2.execute();
// ✅ ESCENARIO 3: Cadena completa y correcta
const validRequest = HttpRequestBuilder.create()
.setUrl('https://api.agenticlab.dev/v1/users')
.setMethod('POST')
.setBody({ name: 'Alexander' })
.addHeader('Authorization', 'Bearer token_xyz');
// ¡Compilación impecable!
const config = validRequest.build();Se genera la instancia con el tipo fantasma { hasUrl: false, hasMethod: false }. Los métodos terminales (.build(), .execute()) están bloqueados.
Cada método devuelve una nueva instancia con el indicador actualizado: Omit<TState, 'K'> & { K: true }.
El compilador coteja el this actual con el contrato exigido: this: Builder<{ hasUrl: true; hasMethod: true }>. Si falta un indicador, emite TS2684.
El runtime carece de comprobaciones if (!this.url). El método build() devuelve directamente una configuración 100% válida.
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
);
}
/**
* Generación del SQL final.
* ¡Disponible ÚNICAMENTE si se han especificado la tabla y las columnas!
*/
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};`;
}
}Modificar los campos del objeto actual this e invocar return this as any rompe los escenarios de ramificación donde distintos módulos reutilizan un constructor base.
// ❌ ANTIPATRÓN: La mutación de una instancia compartida altera otras ramas
class BadBuilder<T> {
private config: any = {};
setField(v: any) {
this.config.field = v; // ¡Mutación interna!
return this;
}
}
// ✅ CORRECTO: Creación inmutable de una nueva instancia
setField(v: FieldType) {
return new GoodBuilder({ ...this.config, field: v });
}Declarar constructores intermedios con let builder = Builder.create(); sin anotar los genéricos amplía el tipo al valor inicial InitialState, bloqueando las llamadas posteriores a .build().
// ❌ ANTIPATRÓN: El uso de let provoca la ampliación del tipo
let builder = HttpRequestBuilder.create();
builder = builder.setUrl('https://api.dev') as any; // ¡Sistema de tipos vulnerado!
// ✅ CORRECTO: Encadenamiento fluido directo
const response = await HttpRequestBuilder.create()
.setUrl('https://api.dev')
.setMethod('GET')
.execute();Intentar representar el estado mediante Partial<Config> permite que propiedades con valor undefined satisfagan las comprobaciones de presencia estructural, introduciendo fallos en tiempo de ejecución.
// ❌ ANTIPATRÓN: Validación según la presencia de { url?: string }
// ¡Pasar { url: undefined } satisface la restricción estructural!
// ✅ CORRECTO: Indicadores booleanos explícitos { readonly hasUrl: true }Si el paso B debe ejecutarse estrictamente tras el paso A, pero ambos permiten invocarse desde InitialState, los desarrolladores podrán invocar B prematuramente. Typestate exige restringir la firma de this.
// ✅ CORRECTO: setPassword requiere haber invocado previamente setEmail
public setPassword(this: AccountBuilder<{ hasEmail: true }>, pwd: string) { ... }Identifique los parámetros obligatorios y opcionales. Defina la secuencia de prerequisitos requeridos antes de la finalización.
Cree una interfaz BuilderState con indicadores booleanos { hasA: boolean; hasB: boolean } y un InitialState con valores false.
Establezca el constructor como privado, añada la factoría create() y devuelva Omit<TState, 'K'> & { K: true } en cada paso.
Aplique la restricción de firma: build(this: Builder<{ hasA: true; hasB: true }>).
Escriba pruebas de tipado (tsd o vitest typecheck) que verifiquen errores de compilación al omitir pasos indispensables.