Перейти к основному содержимому

Паттерны в 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(" ");
}
}

Разбор:

  • this return 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);
}
}

Связь с 18.md, 26.md.


Примеси (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
Строковые коды ошибок без uniontype ErrorCode = "..." | "..."
God-object type на 50 полейразбить DTO / domain
as вездеguards, Zod
Дублировать union вручнуюas const + typeof10.md

Частые ошибки

ОшибкаЧто делать
Union без дискриминантадобавить поле type / kind
Забыли never в switchexhaustive check
Branded type без factoryодин вход userId()
Mapper в UI-компонентеслой api/ или mappers/

Практика

  1. Опишите 3 команды Command и handleCommand с never.
  2. Введите UserId / OrderId branded и поймайте ошибку присваивания.
  3. Сделайте fromDto / toDto для одной сущности.
  4. Добавьте TypedEmitter с двумя событиями.
  5. Вынесите Repository interface и in-memory реализацию.

Смежные статьи