Классы, ООП и декораторы в TypeScript
До сих пор мы моделировали данные типами и функциями — и это правильный дефолт. Но целые пласты экосистемы построены на классах: NestJS, TypeORM, Angular, MobX, доменный код в стиле DDD. Классы в TypeScript устроены хитрее, чем кажется: часть механики стирается при компиляции, часть существует в рантайме, а декораторов в языке сейчас две несовместимые системы.
Когда класс уместен, а когда нет
Класс оправдан, когда одновременно выполняются три условия: есть состояние, есть поведение, которое это состояние охраняет, и есть идентичность (два объекта с одинаковыми полями — не одно и то же). Это описание доменной сущности или агрегата из тактического DDD.
Класс не нужен, когда:
- логика чистая — берите функцию, её проще тестировать и композировать;
- нужен только контейнер данных — берите
interface+ литерал объекта; - нужен «сервис» без состояния — берите модуль с экспортируемыми функциями;
- вы пишете класс только ради группировки — это неймспейс, а не объект.
Классический антипаттерн — анемичная модель: класс из приватных полей и геттеров/сеттеров, вся логика при этом снаружи. Такой класс не даёт ничего, кроме многословности; замените на тип.
// Чистый расчёт — обычная функция, класс тут не нужен
export const totalPrice = (items: readonly OrderItem[]): number =>
items.reduce((sum, i) => sum + i.price * i.qty, 0);
// Класс: состояние + инвариант, который нельзя нарушить снаружи
export class Order {
#items: OrderItem[] = []; // настоящая приватность на уровне рантайма
add(item: OrderItem): void {
if (this.#items.length >= 50) throw new Error("Слишком много позиций");
this.#items.push(item);
}
get total(): number {
return totalPrice(this.#items);
}
}
Модификаторы доступа: два разных механизма
Это место, где новички обжигаются чаще всего. private из TypeScript и #field
из JavaScript решают похожие задачи на разных этапах.
class Account {
public readonly id: string; // видно всем, менять нельзя после конструктора
protected balance = 0; // видно наследникам
private auditLog: string[] = []; // только компилятор следит за этим
#secret = "s3cr3t"; // настоящее приватное поле рантайма
constructor(id: string) { this.id = id; }
}
const acc = new Account("a1");
// acc.balance; // ошибка компиляции
// acc.auditLog; // ошибка компиляции
(acc as any).auditLog; // РАБОТАЕТ в рантайме: private стирается
// (acc as any)["#secret"]; // не работает никак: #secret недоступен извне класса
Итог: private — договорённость для компилятора, # — гарантия рантайма.
Для доменных инвариантов и секретов берите #, для обычной инкапсуляции внутри
своего кода достаточно private. Важный побочный эффект: класс с private-полем
перестаёт быть структурно совместимым с «таким же» классом — это единственное
место, где TypeScript ведёт себя номинально:
class A { private x = 1 }
class B { private x = 1 }
// let a: A = new B(); // ошибка: типы имеют разные объявления x
Параметр-свойства: краткая запись конструктора
export class RegisterUser {
// модификатор в параметре = объявление поля + присваивание
constructor(
private readonly users: UserRepository,
private readonly mailer: Mailer,
) {}
}
Ровно этот приём мы использовали в
главе про архитектуру — он делает
ручной DI компактным. Оговорка: параметр-свойства запрещены при
erasableSyntaxOnly (режим для рантаймов, которые только стирают типы, вроде Node
с --experimental-strip-types), потому что этот синтаксис генерирует код.
Наследование, abstract и implements
// Абстрактный класс задаёт скелет и общее поведение, но сам не инстанцируется
abstract class Notifier {
abstract readonly channel: string; // обязан объявить наследник
protected abstract deliver(to: string, text: string): Promise<void>;
// шаблонный метод: общий алгоритм с точкой расширения
async notify(to: string, text: string): Promise<void> {
const start = performance.now();
await this.deliver(to, text);
metrics.observe(this.channel, performance.now() - start);
}
}
class EmailNotifier extends Notifier {
readonly channel = "email";
// override обязателен при noImplicitOverride — защита от опечаток в имени
protected override async deliver(to: string, text: string): Promise<void> {
await smtp.send({ to, body: text });
}
}
deliver — точка расширения"
implements ничего не добавляет к типу — он лишь просит компилятор проверить
соответствие интерфейсу. Совместимость остаётся структурной: объект, случайно
совпавший по форме, подойдёт туда, где ждут Notification, и без implements.
Практическое правило то же, что и везде в ООП: композиция важнее наследования —
если нужна переиспользуемая логика, передайте её зависимостью. Каталог классических
структур — в треке паттернов проектирования.
Типизация this
Полиморфный this для fluent-API
Тип this в позиции возврата означает «тип текущего класса», а не того, где
метод объявлен. Это делает цепочки корректными в наследниках:
class QueryBuilder {
protected parts: string[] = [];
where(cond: string): this { // не QueryBuilder, а именно this
this.parts.push(`WHERE ${cond}`);
return this;
}
}
class PgQueryBuilder extends QueryBuilder {
returning(col: string): this {
this.parts.push(`RETURNING ${col}`);
return this;
}
}
// Возврат where() — PgQueryBuilder, поэтому returning() доступен
new PgQueryBuilder().where("id = 1").returning("id");
Потерянный this
Метод, оторванный от объекта, теряет контекст — классическая ошибка JavaScript, которая живёт и в TypeScript:
class Counter {
count = 0;
increment() { this.count++; } // метод на прототипе
decrement = () => { this.count--; }; // поле-стрелка, this «приварен»
}
const counter = new Counter();
const inc = counter.increment;
// inc(); // TypeError: this === undefined
setTimeout(counter.increment.bind(counter), 100); // явный bind — рабочее решение
counter.decrement(); // стрелка работает всегда
// Отдельный приём: объявить требуемый this параметром (стирается при компиляции)
function onClick(this: HTMLButtonElement, e: MouseEvent): void {
this.disabled = true;
}
Trade-off: поле-стрелка создаётся на каждый экземпляр (память, нет
переиспользования через прототип), метод на прототипе — один на класс, но требует
bind. Для доменных сущностей берите методы, для колбэков UI — стрелки. Опция
strictBindCallApply проверяет типы аргументов bind/call/apply, а
noImplicitThis запрещает this неизвестного типа; обе входят в strict.
Класс как значение и как тип
Объявление класса создаёт две сущности: тип экземпляра и значение-конструктор. Это надо различать, когда классы передают как аргументы:
class HttpError extends Error {
constructor(public readonly status: number) { super(`HTTP ${status}`); }
}
type Ctor = typeof HttpError; // тип конструктора
type Instance = InstanceType<typeof HttpError>; // тип экземпляра
// Функция, принимающая КЛАСС, а не объект: конструкторная сигнатура
function register<T extends Error>(ctor: new (...args: never[]) => T): void {
registry.add(ctor.name);
}
// Абстрактный класс описывается только так — обычная сигнатура его не примет
type AbstractCtor<T> = abstract new (...args: never[]) => T;
Миксины
Миксин — функция, принимающая класс и возвращающая расширенный класс. Так добавляют сквозное поведение без иерархии наследования:
type Ctor<T = object> = new (...args: any[]) => T;
// Миксин: добавляет отметки времени любому классу
function Timestamped<TBase extends Ctor>(Base: TBase) {
return class extends Base {
readonly createdAt = new Date();
touchedAt: Date | null = null;
touch(): void { this.touchedAt = new Date(); }
};
}
class Document { constructor(public title: string) {} }
// Композиция поведения вместо длинной цепочки наследования
const SmartDocument = Timestamped(Document);
const doc = new SmartDocument("Отчёт");
doc.touch(); // из миксина
doc.title; // из базового класса
Компилятор корректно выводит объединённый тип. Цена: any в сигнатуре
конструктора (иначе миксины не типизируются) и усложнённые сообщения об ошибках.
Класс как охрана жизненного цикла
Помните урок главы про типы: состояние лучше выражать discriminated union, а класс — оболочкой, запрещающей невалидные переходы.
type OrderState =
| { kind: "draft" }
| { kind: "placed"; placedAt: Date }
| { kind: "paid"; paidAt: Date; txId: string }
| { kind: "cancelled"; reason: string };
export class Order {
#state: OrderState = { kind: "draft" };
get state(): Readonly<OrderState> { return this.#state; }
place(): void {
if (this.#state.kind !== "draft") {
throw new Error(`Нельзя разместить заказ в состоянии ${this.#state.kind}`);
}
this.#state = { kind: "placed", placedAt: new Date() };
}
markPaid(txId: string): void {
if (this.#state.kind !== "placed") throw new Error("Оплатить можно только размещённый");
this.#state = { kind: "paid", paidAt: new Date(), txId };
}
}
Декораторы: две системы, которые нельзя смешивать
Декоратор — функция, которая получает объявление и может его изменить или обернуть. В TypeScript их две реализации, и это главный источник путаницы.
Legacy (experimentalDecorators) |
Стандартные (TC39, TS 5.0+) | |
|---|---|---|
| Статус | эксперимент с 2015 года | stage 3, идут в стандарт JS |
| Флаг | "experimentalDecorators": true |
включены по умолчанию, флаг выключен |
| Сигнатура метода | (target, key, descriptor) |
(value, context) |
| Декораторы параметров | есть | пока нет |
| Метаданные | emitDecoratorMetadata + reflect-metadata |
context.metadata / Symbol.metadata |
| Кто использует | Angular, NestJS, TypeORM, MobX, class-validator | новый код без DI-фреймворков |
Включённый experimentalDecorators переключает компилятор целиком: смешать обе
системы в одном проекте нельзя. Поэтому выбор фреймворка фактически определяет,
какие декораторы вы пишете.
Стандартные декораторы
Сигнатура — (value, context). context описывает, что декорируется: kind
("class" | "method" | "field" | "getter" | "setter" | "accessor"), name,
static, private, addInitializer, metadata.
// Декоратор метода: логирует вызовы и длительность
function logged<T extends (this: any, ...args: any[]) => any>(
target: T,
context: ClassMethodDecoratorContext,
): T {
const name = String(context.name);
// возвращаем ЗАМЕНУ метода — обёртку
return function (this: any, ...args: any[]) {
const t0 = performance.now();
try {
return target.apply(this, args);
} finally {
console.log(`${name} занял ${(performance.now() - t0).toFixed(1)} мс`);
}
} as T;
}
// Декоратор класса: может подменить сам класс
function sealed<T extends new (...args: any[]) => object>(target: T, _ctx: ClassDecoratorContext): T {
Object.seal(target);
Object.seal(target.prototype);
return target;
}
@sealed
class ReportService {
@logged
build(rows: readonly number[]): string {
return rows.join(",");
}
}
Декоратор поля устроен иначе: он получает undefined вместо значения и возвращает
функцию-инициализатор (initial) => newValue. Через context.addInitializer(fn)
регистрируют код, выполняемый при создании экземпляра (так делают автоматический
bind). А ключевое слово accessor превращает поле в пару геттер/сеттер с
приватным хранилищем, к которой можно применить декоратор, — на этом строят
реактивность:
class Store {
// accessor + декоратор = наблюдаемое свойство
@observable accessor items: string[] = [];
}
Порядок применения
Legacy-декораторы и DI-фреймворки
Если вы работаете с NestJS или TypeORM, вы пишете старый вариант. Он опирается на
reflect-metadata: компилятор с emitDecoratorMetadata записывает типы
параметров в метаданные, а контейнер читает их и подставляет зависимости.
// tsconfig: "experimentalDecorators": true, "emitDecoratorMetadata": true
import "reflect-metadata";
@Controller("orders")
export class OrdersController {
// тип параметра попадает в метаданные — контейнер знает, что подставить
constructor(private readonly service: OrdersService) {}
@Post()
@HttpCode(201)
create(@Body() dto: CreateOrderDto) { // декоратор параметра — только в legacy
return this.service.create(dto);
}
}
Честная оценка компромисса: декораторы дают декларативность и убирают шаблонный
код, но взамен — неявность (что делает @Injectable, видно только в доках),
зависимость от рефлексии, проблемы с tree-shaking и сложность отладки. Для
небольших сервисов ручной DI из
главы про архитектуру прозрачнее;
для больших команд с единым каркасом декораторы окупаются.
Классы на границе системы
Класс не переживает сериализацию: JSON.parse не восстановит прототип —
методы исчезнут.
const restored = JSON.parse(raw) as Order; // ЛОЖЬ: это plain object, не Order
// restored.place(); // TypeError в рантайме
Правильный путь — тот же, что и везде в курсе: на границе валидируем схемой и конструируем объект явно через фабрику.
export function orderFromJson(input: unknown): Order {
const dto = OrderDto.parse(input); // рантайм-проверка схемой Zod
return Order.restore(dto); // фабрика восстанавливает инварианты
}
Аналогично instanceof ненадёжен через границы модулей и пакетов (см. dual
package hazard в прошлой главе)
и через границы воркеров. Для классификации ошибок используйте поле-дискриминатор.
Типичные ошибки
- Анемичные классы — данные без поведения; и классы вместо чистых функций в утилитарных модулях. Оба случая — лишний слой.
- Наследование ради переиспользования кода. Появляется
ProductBaseс шестью наследниками иifпо типу внутри — верный признак, что нужна композиция. - Расчёт на
privateкак на защиту (стирается) и забытыйbindпри передаче метода колбэком. - Смешение двух систем декораторов или включение
experimentalDecorators«на всякий случай» — это меняет семантику всего проекта. - Проверка
instanceofна объектах, пришедших из JSON.
Источники
- Handbook: Classes и Mixins.
- TypeScript 5.0 Release Notes: Decorators — стандартные декораторы и их отличия от legacy.
- Предложение TC39 по декораторам — первоисточник со всеми деталями семантики.
- MDN: Private properties —
механика
#-полей; NestJS: Custom decorators — практика legacy-декораторов в DI-фреймворке.
Что дальше
Классы и модули описывают, как устроен код внутри процесса. Следующий шаг — граница между процессами: как сделать так, чтобы типы клиента и сервера не разъезжались, и кто генерирует их из общего контракта.