У класичному об'єктно-орієнтованому проєктуванні патерн Builder (Будівельник) використовується для покрокового конструювання складних об'єктів із безліччю опціональних та обов'язкових параметрів. Однак традиційна реалізація Builder страждає від критичного недоліку: компілятор не знає, в який момент усі обов'язкові поля ініціалізовані.
Виклик термінального методу .build() або .send() на незавершеному об'єкті відстежується лише в рантаймі за допомогою примітивних перевірок:
// Помилка рантайму, якої можна було уникнути на етапі збірки:
new HttpRequestBuilder().setMethod('POST').send();
// Uncaught Error: "URL обов'язковий для виконання мережевого запиту"Архітектурний патерн Typestate (що прийшов із теорії лінійних і сесійних типів у мовах Rust та Idris) переносить перевірку контракту ініціалізації на етап статичного аналізу TypeScript.
Кожен крок конструювання повертає новий типобезпечний інстанс будівельника з оновленим фантомним типом стану, а термінальний метод .build() фізично відсутній в автодоповненні IDE та заборонений компілятором доти, доки всі обов'язкові інваріанти не будуть задоволені.
+-------------------------------------------------------------------------+
| МАТРИЦЯ ПЕРЕХОДІВ СТАНІВ TYPESTATE |
+-------------------------------------------------------------------------+
| [State 0: Порожній будівельник] -> { hasUrl: false, hasMethod: false } |
| | |
| |--> .setUrl('https://api.agenticlab.dev') |
| v |
| [State 1: Задано URL] -> { hasUrl: true, hasMethod: false } |
| | |
| |--> .setMethod('POST') |
| v |
| [State 2: Готовий до відправки] -> { hasUrl: true, hasMethod: true } |
| | |
| `--> .execute() доступний ТІЛЬКИ при { hasUrl: true, hasMethod: true}|
+-------------------------------------------------------------------------+У TypeScript патерн Typestate спирається на три ключові механізми:
Builder<TState extends RequestState>).build(this: Builder<{ hasAll: true }>).Схема еволюції типу в ланцюжку викликів:
Builder<{ hasUrl: false, hasPayload: false }>
|
| .setUrl("...")
v
Builder<{ hasUrl: true, hasPayload: false }>
|
| .setPayload({ id: 1 })
v
Builder<{ hasUrl: true, hasPayload: true }>
|
| .send() -> Доступний! Компіляція дозволена.
v
Promise<Response>.execute() без вказання таблиці в .from() або заборона виклику .where() після .limit().Extract -> Transform -> Validate -> Load із блокуванням перестановки кроків місцями.this#Створимо HTTP-клієнт, що гарантує вказання URL та методу до виконання виклику:
// 1. Опис вектора стану на рівні типів
export interface RequestState {
readonly hasUrl: boolean;
readonly hasMethod: boolean;
readonly hasBody: boolean;
}
// Початковий порожній стан
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;
}
/**
* Незмінний Typestate HTTP-будівельник.
*/
export class HttpRequestBuilder<TState extends RequestState = InitialState> {
private constructor(
private readonly config: Partial<HttpRequestConfig> = { headers: {} }
) {}
public static create(): HttpRequestBuilder<InitialState> {
return new HttpRequestBuilder<InitialState>();
}
/**
* Встановлення цільової адреси. Переводить hasUrl у true.
*/
public setUrl(
url: string
): HttpRequestBuilder<Omit<TState, 'hasUrl'> & { hasUrl: true }> {
return new HttpRequestBuilder({
...this.config,
url,
});
}
/**
* Встановлення методу запиту. Переводить hasMethod у true.
*/
public setMethod(
method: HttpMethod
): HttpRequestBuilder<Omit<TState, 'hasMethod'> & { hasMethod: true }> {
return new HttpRequestBuilder({
...this.config,
method,
});
}
/**
* Додавання HTTP-заголовка (не впливає на обов'язкові стани)
*/
public addHeader(key: string, value: string): HttpRequestBuilder<TState> {
return new HttpRequestBuilder({
...this.config,
headers: {
...this.config.headers,
[key]: value,
},
});
}
/**
* Встановлення тіла запиту. Переводить hasBody у true.
*/
public setBody<TBody>(
body: TBody
): HttpRequestBuilder<Omit<TState, 'hasBody'> & { hasBody: true }> {
return new HttpRequestBuilder({
...this.config,
body,
});
}
/**
* Термінальний метод збірки.
* КРИТИЧНИЙ МОМЕНТ: Метод доступний ТІЛЬКИ якщо hasUrl === true ТА hasMethod === true!
*/
public build(
this: HttpRequestBuilder<RequestState & { hasUrl: true; hasMethod: true }>
): HttpRequestConfig {
return this.config as HttpRequestConfig;
}
/**
* Термінальний метод виконання мережевого запиту.
*/
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;
}
}Продемонструємо, як компілятор блокує невалідний код:
// ❌ СЦЕНАРІЙ 1: Забуто виклик setMethod()
const badRequest1 = HttpRequestBuilder.create()
.setUrl('https://api.agenticlab.dev/v1/users');
// ПОМИЛКА КОМПІЛЯЦІЇ 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();
// ❌ СЦЕНАРІЙ 2: Забуто URL
const badRequest2 = HttpRequestBuilder.create()
.setMethod('GET');
// ПОМИЛКА КОМПІЛЯЦІЇ TS2684: hasUrl відсутній!
badRequest2.execute();
// ✅ СЦЕНАРІЙ 3: Повний коректний ланцюжок
const validRequest = HttpRequestBuilder.create()
.setUrl('https://api.agenticlab.dev/v1/users')
.setMethod('POST')
.setBody({ name: 'Alexander' })
.addHeader('Authorization', 'Bearer token_xyz');
// Компіляція проходить ідеально!
const config = validRequest.build();Створюється екземпляр із фантомним типом { hasUrl: false, hasMethod: false }. Термінальні методи .build() заблоковані.
Кожен метод повертає новий інстанс із перевизначеним ключем вектора: Omit<TState, 'K'> & { K: true }.
Компілятор звіряє поточний тип this із необхідним контрактом методу: this: Builder<{ hasUrl: true; hasMethod: true }>. При невідповідності генерується TS2684.
У рантаймі відсутні громіздкі перевірки if (!this.url). Метод build() миттєво повертає 100% валідний конфіг.
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
);
}
/**
* Збірка сирого SQL.
* Доступна ТІЛЬКИ якщо вказані і таблиця, і колонки!
*/
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};`;
}
}Якщо всередині методів будівельника змінювати поля поточного об'єкта this і робити return this as any, різні гілки коду, що перевикористовують базовий конфіг будівельника, почнуть непередбачувано перезаписувати параметри одна одної.
// ❌ АНТИПАТЕРН: Мутація спільного інстанса ламає розгалуження
class BadBuilder<T> {
private config: any = {};
setField(v: any) {
this.config.field = v; // Мутація!
return this;
}
}
// ✅ ПРАВИЛЬНО: Незмінне створення нового екземпляра
setField(v: FieldType) {
return new GoodBuilder({ ...this.config, field: v });
}Якщо оголосити проміжний будівельник через const b = Builder.create(); без вказання точного типу або переприсвоїти його в let, TypeScript може розширити тип до дефолтного InitialState, заблокувавши наступний виклик .build().
// ❌ АНТИПАТЕРН: Застосування let з розширенням типів
let builder = HttpRequestBuilder.create();
builder = builder.setUrl('https://api.dev') as any; // Злам системи типів!
// ✅ ПРАВИЛЬНО: Безперервний method chaining
const response = await HttpRequestBuilder.create()
.setUrl('https://api.dev')
.setMethod('GET')
.execute();Спроба кодувати стан через Partial<Config> часто призводить до того, що компілятор дозволяє виклик методу, якщо поле присутнє в типі як undefined, що пропускає баг у рантайм.
// ❌ АНТИПАТЕРН: Перевірка за наявністю ключа { url?: string }
// Якщо передати { url: undefined }, компілятор вважатиме контракт виконаним!
// ✅ ПРАВИЛЬНО: Суворі булеві маркери { readonly hasUrl: true }Якщо метод B допустимий лише строго після методу A, але обидва дозволяють виклик від InitialState, розробник зможе викликати B раніше часу. Typestate вимагає суворого опису вхідного this.
// ✅ ПРАВИЛЬНО: Метод setPassword доступний ТІЛЬКИ після setEmail
public setPassword(this: AccountBuilder<{ hasEmail: true }>, pwd: string) { ... }Визначте обов'язкові та опціональні параметри сутності. Зафіксуйте, які кроки мають передувати фіналізації.
Створіть інтерфейс BuilderState з булевими прапорцями { hasA: boolean; hasB: boolean } та тип InitialState з усіма false.
Зробіть конструктор приватним, оголосіть фабрику create() та реалізуйте методи з поверненням Omit<TState, 'K'> & { K: true }.
Задайте сигнатуру термінального методу build(this: Builder<{ hasA: true; hasB: true }>).
Напишіть тести типів (tsd або vitest typecheck), що верифікують генерацію помилок при пропуску обов'язкових кроків.