В классическом объектно-ориентированном проектировании паттерн 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>Определите обязательные и опциональные параметры сущности. Зафиксируйте, какие шаги должны предшествовать финализации.
Создайте интерфейс BuilderState с булевыми флагами { hasA: boolean; hasB: boolean } и тип InitialState со всеми false.
Сделайте конструктор приватным, объявите фабрику create() и реализуйте методы с возвратом Omit<TState, 'K'> & { K: true }.
Задайте сигнатуру терминального метода build(this: Builder<{ hasA: true; hasB: true }>).
Напишите тесты типов (tsd или vitest typecheck), верифицирующие генерацию ошибок при пропуске обязательных шагов.
Как в TypeScript запретить вызов терминального метода .execute() до тех пор, пока не будут вызваны все обязательные шаги строителя?
.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};`;
}
}Почему при реализации Typestate Builder каждый метод-сеттер обязан возвращать НОВЫЙ экземпляр (new Builder(...)), а не мутировать текущий this?
Если внутри методов строителя изменять поля текущего объекта 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) { ... }