Паттерны в TypeScript
Дальше: Дженерики · Обработка ошибок · Объекты и классы · Справочник — распространённые паттерны
Учебное раскрытие Справочник — распространённые паттерны с примерами кода.
Паттерны проектирования в TypeScript усиливаются типами: компилятор проверяет исчерпывающие switch, контракты фабрик и форму DTO. Ниже — приёмы, которые чаще всего встречаются в frontend и Node, без формального каталога GoF.
Маршрут: Типы и типизация → классы → паттерны → Обработка ошибок.
Таблицы advanced — Справочник — распространённые паттерны.
Discriminated union (команды и состояния)
type Command =
| { type: "create-user"; email: string; name: string }
| { type: "ban-user"; userId: string }
| { type: "send-email"; to: string; subject: string };
function handleCommand(command: Command): void {
switch (command.type) {
case "create-user":
console.log(command.email, command.name);
return;
case "ban-user":
console.log(command.userId);
return;
case "send-email":
console.log(command.to, command.subject);
return;
default: {
const _exhaustive: never = command;
return _exhaustive;
}
}
}
Разбор:
- Поле
type— дискриминант; в ветке доступны только нужные поля. neverвdefault— страховка при расширении union — 12.md.
Применение: Redux actions, очереди сообщений, UI state — 17.md, 21.md.
Branded types (номинальность поверх structural)
type UserId = string & { readonly __brand: "UserId" };
type OrderId = string & { readonly __brand: "OrderId" };
function userId(raw: string): UserId {
return raw as UserId;
}
function loadOrder(id: OrderId): void {
console.log(id);
}
const u = userId("u-1");
loadOrder(u); // ошибка: OrderId ≠ UserId
Разбор:
- Оба в runtime —
string, но TS запрещает перепутать. - Валидацию формата (UUID) делайте в функции-конструкторе
userId.
Factory по интерфейсу
interface Logger {
log(message: string): void;
}
class ConsoleLogger implements Logger {
log(message: string): void {
console.log(message);
}
}
class JsonLogger implements Logger {
log(message: string): void {
console.log(JSON.stringify({ message }));
}
}
type LoggerKind = "console" | "json";
function createLogger(kind: LoggerKind): Logger {
switch (kind) {
case "console":
return new ConsoleLogger();
case "json":
return new JsonLogger();
}
}
Разбор:
- Код зависит от
Logger, не от конкретного класса — проще тесты (mock). - Structural typing: любой объект с
logподойдёт, если не нужна номинальность.
DTO + mapper между слоями
type UserDto = { id: string; name: string; created_at: string };
type User = { id: string; name: string; createdAt: Date };
function fromDto(dto: UserDto): User {
return {
id: dto.id,
name: dto.name,
createdAt: new Date(dto.created_at),
};
}
function toDto(user: User): UserDto {
return {
id: user.id,
name: user.name,
created_at: user.createdAt.toISOString(),
};
}
Разбор:
- API (snake_case, string date) ≠ domain (
Date). - Один mapper на границе — не размазывать преобразования — 22.md.
Type-safe event bus
type Events = {
CART_ITEM_ADDED: { productId: string; price: number };
USER_AUTHENTICATED: { userId: string; role: "admin" | "customer" };
PAYMENT_COMPLETED: { orderId: string; currency: "USD" | "EUR" };
};
class TypedEmitter {
private handlers: {
[K in keyof Events]?: Array<(payload: Events[K]) => void>;
} = {};
on<K extends keyof Events>(event: K, fn: (payload: Events[K]) => void): void {
const list = this.handlers[event] ?? [];
list.push(fn);
this.handlers[event] = list;
}
emit<K extends keyof Events>(event: K, payload: Events[K]): void {
for (const fn of this.handlers[event] ?? []) {
fn(payload);
}
}
}
const bus = new TypedEmitter();
bus.emit("CART_ITEM_ADDED", { productId: "p_001", price: 49.99 });
// bus.emit("PAYMENT_COMPLETED", {
// orderId: "o_456",
// currency: "GBP",
// });
// ошибка: "GBP" не входит в "USD" | "EUR"
bus.on("PAYMENT_COMPLETED", ({ orderId, currency }) => {
console.log(orderId, currency);
});
// bus.on("PAYMENT_COMPLETED", ({ userId }) => {
// console.log(userId);
// });
// ошибка: userId есть у USER_AUTHENTICATED, не у PAYMENT_COMPLETED
Разбор:
K extends keyof Eventsсвязывает имя события и форму payload — тот же приём, что у updater: первый аргумент сужает второй.- Literal union в
currencyиroleловит опечатки и неверные коды до запуска. - В колбэке
onдоступны только поля выбранного события; деструктуризация чужого поля — ошибка компиляции. - В продакшене — typed wrappers над EventEmitter или фреймворк.
- Тот же механизм
PayloadMap[E]для отдельных handler-функций и константas const— дженерики §сопоставленные типы.
Builder с цепочкой
class QueryBuilder {
private parts: string[] = [];
select(fields: string[]): this {
this.parts.push(`SELECT ${fields.join(", ")}`);
return this;
}
from(table: string): this {
this.parts.push(`FROM ${table}`);
return this;
}
build(): string {
return this.parts.join(" ");
}
}
Разбор:
thisreturn type сохраняет fluent API в подклассах.- Для SQL в продакшене — query builder библиотеки, не строки вручную.
Repository (интерфейс + реализация)
interface Repository<T extends { id: string }> {
findById(id: string): Promise<T | null>;
save(entity: T): Promise<void>;
}
class InMemoryUserRepo implements Repository<User> {
private store = new Map<string, User>();
async findById(id: string): Promise<User | null> {
return this.store.get(id) ?? null;
}
async save(entity: User): Promise<void> {
this.store.set(entity.id, entity);
}
}
Примеси (mixins)
В TypeScript нет ключевого слова mixin, но примесь — это функция, которая принимает класс-конструктор и возвращает расширенный класс. Так комбинируют поведение без глубокого наследования.
type Constructor<T = object> = new (...args: unknown[]) => T;
function Timestamped<TBase extends Constructor>(Base: TBase) {
return class extends Base {
readonly createdAt = new Date();
};
}
function Identifiable<TBase extends Constructor<{ id?: string }>>(Base: TBase) {
return class extends Base {
constructor(...args: unknown[]) {
super(...args);
if (!("id" in this) || !this.id) {
(this as { id: string }).id = crypto.randomUUID();
}
}
};
}
class User {
constructor(public name: string) {}
}
const UserEntity = Timestamped(Identifiable(User));
const u = new UserEntity("Анна");
console.log(u.name, u.id, u.createdAt);
Разбор:
TimestampedиIdentifiable— примеси; порядок обёртки влияет на цепочкуsuper.- Типы параметров конструктора в реальных проектах сужают через
Constructorи дженерики — 24.md. - В React чаще используют композицию хуков, а не классовые mixins; в Nest/ORM — декораторы и базовые классы — 23.md.
Альтернатива примесям для объектов без классов: пересечение типов + spread ({ ...a, ...b }) или отдельные функции-хелперы — 18.md §композиция.
Anti-patterns
| Плохо | Лучше |
|---|---|
any на границе модуля | unknown + mapper |
| Строковые коды ошибок без union | type ErrorCode = "..." | "..." |
| God-object type на 50 полей | разбить DTO / domain |
as везде | guards, Zod |
| Дублировать union вручную | as const + typeof — 10.md |
Частые ошибки
| Ошибка | Что делать |
|---|---|
| Union без дискриминанта | добавить поле type / kind |
Забыли never в switch | exhaustive check |
| Branded type без factory | один вход userId() |
| Mapper в UI-компоненте | слой api/ или mappers/ |
Практика
- Опишите 3 команды
CommandиhandleCommandсnever. - Введите
UserId/OrderIdbranded и поймайте ошибку присваивания. - Сделайте
fromDto/toDtoдля одной сущности. - Добавьте
TypedEmitterс двумя событиями. - Вынесите
Repositoryinterface и in-memory реализацию.