← Глава 15. Классы в структурном мире Содержание Глава 17. Дженерики как функции над типами →

Глава 16. Композиция против иерархии

Глава 16. Композиция против иерархии

Иерархия, которую можно не строить

Перед нами привычная задача: в магазине есть несколько способов уведомить покупателя о статусе заказа — письмом, SMS, пушем в приложение, — и для каждого нужно собрать сообщение и отправить. Рефлекс, привезённый из мира классов, срабатывает мгновенно. Абстрактная база, в ней общее; три наследника, в каждом — своё; полиморфный вызов сам выберет нужную реализацию.

abstract class Notifier {
  constructor(protected readonly order: Order) {}
  abstract send(): void;
  protected subject(): string {
    return `Заказ ${this.order.id}`; // общий код в базе
  }
}

class EmailNotifier extends Notifier {
  send() {
    sendEmail(this.subject(), `Сумма: ${this.order.total}`);
  }
}

class SmsNotifier extends Notifier {
  send() {
    sendSms(`${this.subject()} оплачен`);
  }
}

Здесь, для полноты, тот самый заказ, к которому мы возвращаемся всю книгу:

type Order = {
  id: string;
  total: number; // сумма в минимальных единицах валюты
  status: "created" | "paid" | "shipped" | "delivered" | "cancelled";
  items: { sku: string; qty: number }[];
};

Код выше компилируется и работает. TypeScript умеет в классы, в abstract, в protected, в наследование — всё на месте. Но если задержаться и спросить, ради чего здесь иерархия, ответов окажется ровно два, и оба при ближайшем рассмотрении рассыпаются.

Первая причина наследовать — переиспользование: subject() написан один раз в базе и достаётся обоим наследникам. Выгода настоящая, но мелкая: это одна функция, которую с тем же успехом можно вынести наружу и звать как subject(order), не таща за собой ни базовый класс, ни protected, ни цепочку конструкторов.

Вторая причина — подставляемость: где-то есть код, который хочет работать с «любым уведомителем», не зная, какой именно, и наследование от общей базы даёт ему общий тип Notifier, под который подходят оба наследника.

function notifyAll(notifiers: Notifier[]) {
  for (const n of notifiers) n.send(); // работает с любым подтипом
}

Вот ради этого — ради того, чтобы EmailNotifier и SmsNotifier были взаимозаменяемы там, где ждут Notifier, — в номинативном мире и строят иерархию. Подставляемость не даётся сама собой: чтобы значение подошло под общий тип, оно должно объявить себя его подтипом, унаследовавшись. Иерархия — это цена входа в полиморфизм.

И вот здесь TypeScript выбивает у конструкции опору. Потому что подставляемость, ради которой возведена вся эта вертикаль, в нём уже есть — и за неё ничего не заплачено.

Подставляемость в TS бесплатна

Вернёмся к notifyAll. Что ему на самом деле нужно от своих аргументов? Ровно одно: чтобы у каждого был метод send(): void. Больше он ничего не трогает. Запишем это требование напрямую, без всякой базы:

type Sendable = { send(): void };

function notifyAll(notifiers: Sendable[]) {
  for (const n of notifiers) n.send();
}

Теперь — ключевой момент. Чтобы значение подошло под Sendable, ему не нужно ни от кого наследоваться и вообще ничего о Sendable знать. Достаточно иметь подходящую форму. Это структурная типизация (structural typing) — совместимость по форме значения, а не по объявленному родству, — и в первых главах книги мы установили её как несущую стену языка. Здесь она впервые работает на проектном масштабе.

const emailNotifier = {
  send() {
    console.log("письмо отправлено");
  },
};

notifyAll([emailNotifier]); // подходит — есть send(): void

Объект-литерал не наследует Sendable, не реализует его, нигде его не упоминает. Он просто имеет нужную форму — и этого довольно. Подставляемость случилась без общего супертипа, без иерархии, без единой строчки extends.

Соберём оба наблюдения вместе. В номинативном мире наследование оплачивает две вещи разом: переиспользование кода и подставляемость. Подставляемость — главная из двух; ради неё и затевают вертикаль. Но в TypeScript подставляемость уже лежит на столе бесплатно, выданная структурной типизацией. Значит, из двух причин наследовать остаётся одна — переиспользование, причём самая слабая её разновидность: пара общих функций.

А за эту единственную оставшуюся выгоду наследование берёт полную цену. Жёсткая вертикаль: чтобы добраться до subject(), нужно стать наследником Notifier и принять всё, что в нём есть, включая конструктор и его аргументы. Хрупкая база: меняешь сигнатуру subject() — чинишь всех потомков сразу. Связность: наследник знает о внутренностях родителя, protected — это публичный интерфейс для своих, и он застывает. В номинативном мире за это платят, потому что иначе не купить подставляемость. Здесь подставляемость даровая — и платить за неё этой ценой больше незачем.

Перепишем исходную задачу так, как просит сам язык. Никакой базы. Данные отдельно, поведение — функцией:

function subject(order: Order): string {
  return `Заказ ${order.id}`; // бывший protected-метод, теперь просто функция
}

const emailNotifier = (order: Order): Sendable => ({
  send: () => sendEmail(subject(order), `Сумма: ${order.total}`),
});

const smsNotifier = (order: Order): Sendable => ({
  send: () => sendSms(`${subject(order)} оплачен`),
});

notifyAll([emailNotifier(order), smsNotifier(order)]);

Та же подставляемость, то же переиспользование subject, тот же полиморфный notifyAll — и ни одной иерархии. Вертикаль из четырёх классов сложилась в три функции и один тип формы. Это не вопрос вкуса или краткости; это направление, в которое тянут волокна языка, как только подставляемость перестаёт стоить денег.

Варианты — это объединения, а не подклассы

Подставляемость — лишь одна из причин, по которым в номинативном мире тянутся к иерархии. Вторая, не менее частая, — моделирование вариантов: «значение бывает одного из нескольких видов, и для каждого вида — своя обработка». Заказ оплачен наличными, картой или подарочным сертификатом; событие в логе — это вход, выход или ошибка; узел дерева — лист или ветвь. Классический ответ — абстрактная база и по наследнику на каждый вид, а разбор случаев — через виртуальную диспетчеризацию или визитор.

В TypeScript «одно из нескольких» записывается прямо, как сумма типов. Возьмём способ оплаты:

type Payment =
  | { method: "cash"; received: number }
  | { method: "card"; last4: string; authCode: string }
  | { method: "voucher"; code: string };

Это discriminated union — размеченное объединение, разобранное в главе 9: общее поле-метка method с разным литеральным значением в каждом варианте. Никакой базы, никаких наследников — три формы, сложенные через |. А разбор случаев, который в иерархии разъехался бы по трём методам accept визитора, здесь — один плоский switch:

function describe(p: Payment): string {
  switch (p.method) {
    case "cash":
      return `Наличные, получено ${p.received}`;
    case "card":
      return `Карта *${p.last4}`;
    case "voucher":
      return `Сертификат ${p.code}`;
  }
}

Внутри каждой ветки тип сужен до конкретного варианта — narrowing (сужение) делает это по метке автоматически, — и доступны ровно те поля, что есть у этого варианта: received в cash, last4 в card. Обратиться к p.authCode в ветке cash компилятор не даст: в этом варианте поля нет.

Но вся выплата — впереди, и видна она только когда добавляешь четвёртый вид оплаты. Дописываем вариант в объединение:

type Payment =
  | { method: "cash"; received: number }
  | { method: "card"; last4: string; authCode: string }
  | { method: "voucher"; code: string }
  | { method: "points"; amount: number }; // новый способ — бонусные баллы

И describe перестаёт компилироваться:

function describe(p: Payment): string {
  switch (p.method) {
    case "cash":
      return `Наличные, получено ${p.received}`;
    case "card":
      return `Карта *${p.last4}`;
    case "voucher":
      return `Сертификат ${p.code}`;
  }
  // Error: Function lacks ending return statement and return
  // type does not include 'undefined'.
}

Компилятор посчитал, что points ни одна ветка не покрыла, проследил, что путь мимо switch возвращает undefined, и упёрся в объявленный : string. Пропущенный случай не «упал в проде» — он отказался собираться. Добавление варианта само подсветило каждое место, где варианты разбираются, и заставило их дописать. Это противоположность тихому провалу в default, которым кончается забытый случай в цепочке диспетчеризации.

Ту же исчерпывающую проверку можно сделать явной, не полагаясь на возвращаемый тип, — через never в default. Этот приём разобран в главе 9; здесь важно, что он работает на любой иерархии вариантов, какой бы широкой та ни была.

Сравним масштабы. В номинативном мире каждый новый способ оплаты — это новый класс-наследник плюс новый метод в визиторе, плюс правка интерфейса визитора, плюс согласование всех его реализаций; вертикаль ширится в обе стороны. Здесь — одна строчка в объединении и подсвеченные компилятором места, которые надо дописать. Целый класс иерархий — тех, что существовали лишь чтобы перечислить варианты и развести их обработку, — растворяется в данных плюс функциях. Это проблема выражения (expression problem) на проектном масштабе: в иерархии дёшево добавить новый вид (новый наследник) и дорого новую операцию (метод во всех); в объединении наоборот — дёшево добавить операцию (ещё одна функция над тем же union), а компилятор стережёт полноту по видам. TypeScript просто кладёт под руку обе стороны размена явно, а не прячет одну за фасадом классов.

Поведение — композицией функций и форм

Остался последний повод тянуться к наследованию: собрать сложный тип, нарастив его на простой. Заказу нужны временные метки — наследуемся от Timestamped. Нужна разметка — подмешиваем Trackable. В номинативном мире наращивание — это extends, и у одиночного наследования оно упирается в потолок: от двух баз сразу не унаследуешься.

В структурном мире наращивание формы — это intersection, пересечение типов, оператор &. Он складывает требования: значение типа A & B обязано удовлетворять и A, и B одновременно.

type Timestamped = { createdAt: number; updatedAt: number };
type Trackable = { trackingId: string };

type ShippedOrder = Order & Timestamped & Trackable;

ShippedOrder — это заказ, у которого вдобавок есть временные метки и трек-номер. Никакого «главного» родителя и «подмешанных» вторых — три формы равноправно сложены в одну. Потолка одиночного наследования здесь нет: пересекай сколько нужно, в любом сочетании. Значение подойдёт под ShippedOrder, если структурно несёт все поля всех трёх форм — и снова без единого объявления родства.

Тот же принцип — на уровне поведения. Вместо того чтобы наследник получал метод от базы, функция получает поведение замыканием. Возьмём логирование, которое в иерархии прописали бы в базовый класс «чтобы досталось всем»:

function withLogging<A extends unknown[]>(
  label: string,
  fn: (...args: A) => void,
): (...args: A) => void {
  return (...args) => {
    console.log(`[${label}]`, ...args);
    fn(...args); // оборачиваем поведение, а не наследуем его
  };
}

const send = withLogging("notify", (order: Order) =>
  sendEmail(subject(order), `Сумма: ${order.total}`),
);

withLogging навешивает логирование на любую функцию подходящей формы — на уведомитель, на обработчик платежа, на что угодно с сигнатурой (...args) => void. Поведение разделено между вызывающими не через общего предка, а через функцию-обёртку, которую применяют точечно. Это композиция: мелкие куски поведения собираются в нужной комбинации на месте, а не спускаются по вертикали всем потомкам разом.

Из этих двух ходов — пересечение форм и композиция функций — складывается главный рабочий принцип. Программируй на форму, а не на класс. Функции, которой нужен только идентификатор, объявляй параметр { id: string }, а не Order и тем более не базовый Entity:

function audit(entity: { id: string }, action: string) {
  console.log(`${action} над ${entity.id}`);
}

const user = { id: "U-77", name: "Аня" };

audit(order, "оплата"); // Order подходит: у него есть id
audit(user, "вход"); // и пользователь подходит — у него тоже есть id

audit не требует, чтобы аргумент был чьим-то потомком. Он требует форму — поле id: string — и принимает всё, что эту форму несёт, независимо от происхождения. Заказ, пользователь, товар, что угодно с идентификатором: ни одному из них не нужно вступать в общую иерархию ради того, чтобы пройти через audit. Зависимость направлена на минимальную форму, которая реально нужна, — и потому максимально широка по применимости и минимальна по связности. Это структурная типизация, доведённая до проектного приёма: тип параметра — это не «к какому классу принадлежит значение», а «что я с ним собираюсь делать».

Миксины и их запах

Иногда поведение всё же хочется разделить между классами — когда классы в коде уже есть и переписывать их в функции дорого. На этот случай в TypeScript есть mixin (миксин, примесь): функция, которая принимает конструктор класса и возвращает новый класс, расширенный поведением. Механика опирается на то, что класс в TS — это значение, и его можно передать и вернуть.

type Constructor<T = {}> = new (...args: any[]) => T;

function Timestamped<TBase extends Constructor>(Base: TBase) {
  return class extends Base {
    createdAt = Date.now();
    touch() {
      // ...обновить метку
    }
  };
}

class OrderEntity {
  constructor(public id: string) {}
}

const TimestampedOrder = Timestamped(OrderEntity);
const o = new TimestampedOrder("A-1");
o.touch(); // поведение примешано, не унаследовано по фиксированной вертикали

Механически это работает и решает проблему одиночного наследования: примесей можно навесить несколько, Timestamped(Trackable(OrderEntity)), складывая поведение, а не выстраивая в одну линию. Но посмотрите на цену входа — на Constructor<T = {}> с new (...args: any[]) => T в основании. Здесь any[] стоит не случайно: типизировать аргументы конструктора базы в общем виде нельзя, и приходится их стереть. Миксин покупает гибкость ценой дыры в проверке конструктора — той самой несостоятельности, которую мы обещали не прятать.

Дальше — хуже. Стоит миксину захотеть прочитать поле базы, и типизация начинает сопротивляться: базовый конструктор приходится ограничивать через Constructor<{ ... }>, сигнатуры разъезжаются, выводимые имена классов превращаются в нечитаемые анонимные типы, а сообщения об ошибках распухают. Механика лёгкая — типы мучительны.

И в этой мучительности есть сигнал. Миксин — это попытка разделить поведение между классами, не имея множественного наследования. Но если поведение само по себе не требует быть классом — не держит приватного состояния с инвариантом, не нуждается в instanceof, — то и оборачивать его в класс незачем. То же логирование, те же временные метки прекрасно живут функцией над формой, как withLogging выше, и не требуют ни Constructor, ни any[], ни борьбы с выводом. Когда типизация миксина становится мучительной, это чаще всего запах (smell): не миксин выбран криво, а сама задача не про классы — и палец, которым типы тычут в any[], указывает назад, на обычную композицию функций и форм.

Глазами джависта

В Java иерархия — не один из инструментов, а главный. Домен моделируется деревом классов: абстрактная база, конкретные наследники, общее поведение — наверху, частное — внизу. Переиспользование — наследованием. Полиморфизм — виртуальной диспетчеризацией по этому же дереву. И, что важнее всего, подставляемость требует общего номинативного супертипа: чтобы передать EmailNotifier туда, где ждут Notifier, класс обязан объявить extends Notifier или implements Notifier. Нет объявленного родства — нет совместимости, как бы похожи ни были формы. Иерархия — это пропуск в полиморфизм, и её строят в первую очередь ради него.

В TypeScript именно эта опора убрана. Подставляемость структурна: значение подходит под Notifier, если несёт его форму, и неважно, от кого оно произошло и слышало ли о Notifier вообще. Главная причина наследовать — купить совместимость — отпадает целиком. Варианты, которые в Java разводят по наследникам и визитору, уходят в размеченные объединения с исчерпывающим switch. Наращивание типа, которое там делают через extends, тут делают пересечением &, и без потолка одиночного наследования.

Любопытнее всего, что сам канон Java давно советует «предпочитай композицию наследованию» — это пункт из Effective Java, общее место. Но язык тянет в другую сторону: подставляемость там без наследования не купить, и совет идёт поперёк грамматики. В TypeScript совет и грамматика наконец смотрят в одну сторону. То, что джавист привык делать через силу, преодолевая инерцию языка, здесь — путь наименьшего сопротивления. Наследование не запрещено и не объявлено грехом; просто исчезла главная причина к нему прибегать.

Где композиция и союзы кусаются

Книга обещала показывать острые края, и у композиции они есть — иначе совет «всегда композиция» был бы такой же догмой, как «всегда наследование».

Композиция расползается. Класс собирает данные и поведение в одном месте; читая его, видишь всё разом. Композиция разносит поведение по функциям и обёрткам, и чтобы понять, что происходит с заказом, приходится прыгать между subject, withLogging, фабриками уведомителей. Локальность класса меняется на индирекцию. Когда кусков немного, выигрыш в гибкости перевешивает; когда их десятки и они переплетены, мелкий честный класс читается легче, чем россыпь функций, которую держит вместе только дисциплина.

Миксины тяжелы, и их перебор хуже мелкого класса. Мы видели цену типизации одного миксина. Стек из трёх-четырёх примесей превращает вывод типов в кашу, а сообщения об ошибках — в простыни. Там, где миксинов набирается много, обычный класс с явными полями почти всегда честнее.

Объединения плохи для открытого расширения. Размеченное объединение — это закрытое множество вариантов: все они перечислены в одном месте, и компилятор стережёт полноту именно потому, что видит их все. Но если случаи должен добавлять сторонний код — плагин, чужой модуль, — объединение не годится: чужак не может дописать вариант в твой type, а ты не можешь перечислить то, чего ещё не существует. Вот здесь иерархия с открытым набором наследников или интерфейс, который реализует кто угодно, честно выигрывает. Объединения — для закрытых множеств, и это надо признавать прямо, а не натягивать их на открытые.

Структурная композиция бывает слишком разрешающей. Раз совместимость идёт по форме, под { id: string } подойдёт что угодно с таким полем — в том числе значение, которое совпало с формой случайно, а не по смыслу. Эта дыра разобрана в главе 7: структурная типизация не отличает «это деньги» от «это просто число» и «это UserId» от «это OrderId», если формы совпали. Когда идентичность важна — когда нужно, чтобы тип значил именно то, что значит, а не «всё похожей формы», — структуры мало. Тогда в ход идут branded-типы или класс ради номинальной метки; это тема главы 29.

never в пересечениях. Пересечение & складывает требования, и если требования несовместимы, результат схлопывается в never — тип, у которого нет ни одного значения:

type Weird = { kind: "a" } & { kind: "b" };
// kind: "a" & "b" → never; такого значения не бывает

const w: Weird = { kind: "a" }; // Error: Type 'string' is not assignable to type 'never'.

Пересекать формы с конфликтующими полями — значит молча получить тип, в который ничего нельзя положить. Беспечная композиция через & способна породить never там, где ждали рабочий тип, и заметить это можно не сразу.

Глубокое наследование не всегда зло. Бывает настоящее «является» — с общим инвариантным поведением, которое подклассы не переопределяют, а наследуют целиком, потому что оно для них одинаково истинно. Если у всех вариантов есть состояние с инвариантом, который держит общий код, и этот код должен исполняться одинаково для всех, — иерархия выражает это честнее, чем россыпь функций, которую надо не забыть позвать. Догма «никогда не наследуй» так же слепа, как догма «всегда наследуй». Вопрос не в том, наследование или композиция вообще, а в том, что именно описывает данная связь.

Спрашивай: вариант, способность или общая идентичность?

Перепрошивка, которую несёт эта глава, умещается в один вопрос, задаваемый перед каждым «не завести ли здесь базовый класс».

Это вариант? «Значение бывает одного из нескольких видов» — бери discriminated union и исчерпывающий switch. Не базу с наследниками, не визитор. Целый класс иерархий, существовавших ради перечисления видов, сворачивается в данные плюс функции, а компилятор стережёт полноту.

Это способность? «Значению нужно уметь то-то» или «такому-то коду нужна от значения такая-то форма» — бери композицию: пересечение мелких форм для данных, функции и обёртки для поведения, минимальную форму в параметре вместо толстого класса. Программируй на { id: string }, а не на базу. Подставляемость, ради которой тянуло к наследованию, уже выдана структурно и даром — брать её ещё и через extends незачем.

Это настоящая общая идентичность? «Это действительно одна сущность с инвариантным поведением, одинаковым для всех» — и тебе нужна рантайм-идентичность (instanceof, приватное состояние с инвариантом, номинальная метка) — тогда, возможно, класс или даже неглубокая иерархия. Но наследуй ради этого — ради идентичности и общего инварианта, — а не ради подставляемости, которая бесплатна и без наследования.

По умолчанию, пока не доказано обратное, ответ — данные, функции и объединения. Собирай типы из мелких форм и дай волокнам языка тянуть, куда они тянут. Иерархию строй не рефлексом, а тогда, когда на третий вопрос честно вышло «да».

Итог

На этом часть III собрана: формы структурны, функции первоклассны, интерфейсы — это формы, классы — гости, — и общий вывод в том, что подставляемость в TypeScript даётся бесплатно, а потому варианты идут в объединения, поведение — в композицию форм и функций, и иерархия остаётся ровно там, где нужна настоящая идентичность, а не там, где по привычке из Java кажется, что без неё нельзя.

← Глава 15. Классы в структурном мире Содержание Глава 17. Дженерики как функции над типами →