TypeScript Классы, ООП и декораторы в TypeScript
0%

Классы, ООП и декораторы в TypeScript

Классы, ООП и декораторы в 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 });
  }
}

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.

Источники

Что дальше

Классы и модули описывают, как устроен код внутри процесса. Следующий шаг — граница между процессами: как сделать так, чтобы типы клиента и сервера не разъезжались, и кто генерирует их из общего контракта.

Типобезопасные API-контракты

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов