Раздел 46 · Effect: Тихая Академия

Размеченные объединения

middle~30 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

По проводу ходят сообщения четырёх видов: текст, подтверждение принятого кадра, пинг «я на связи» и отметка «потеряно, начиная с такого-то кадра». У каждого вида свои поля. Ринцвальд сложил их в один тип, где все поля необязательные, и теперь вид сообщения угадывает по тому, какие поля есть.

В прошлом уроке ты сужал unknown до кадра проверками typeof и in. Здесь сужать надо внутри своего же типа: из «какого-то сообщения» получить «подтверждение» и прочитать его номер. Этот урок о том, как устроить тип, чтобы гадать не пришлось, и как заставить компилятор следить, что ни один вид сообщения не забыт.

Мешок полей

Вот тип Ринцвальда и его сводка для журнала башни:

type Bag = {
  readonly body?: string;
  readonly of?: number;
  readonly since?: number;
};

const summary = (message: Bag): string => {
  if (message.body !== undefined) return `текст: ${message.body}`;
  if (message.of !== undefined) return `принят кадр ${message.of}`;
  return 'башня на связи';
};

console.log(summary({ body: 'гроза у моста', of: 12 }));
console.log(summary({ since: 40 }));
текст: гроза у моста
башня на связи

Посчитай по-библиотечному. Каждое из трёх полей либо есть, либо нет, то есть у Bag восемь форм. Осмысленных из них четыре: только body, только of, только since и пустой пинг. Остальные четыре формы тип разрешает, хотя таких сообщений не бывает. Поэтому сводке приходится решать, что важнее, и решает она порядком if: { body, of } стал текстом, потому что проверка body стоит первой. А { since: 40 }, отметка о потере связи, проскочил все проверки и записан в журнал как «башня на связи». Башня потеряла сорок кадров и отчиталась, что всё хорошо.

Дело не в порядке проверок. Полка Bag больше, чем множество настоящих сообщений, и в лишней части живут формы, которые никто не посылал.

Ярлык на полке

Библиотекарь решает такое по-своему: каждую полку подписывает. Не «книги, у которых есть номер», а ярлык Ack, и на эту полку попадает только то, на чём написано Ack. Четыре вида сообщения становятся четырьмя отдельными полками:

export type Text = { readonly _tag: 'Text'; readonly body: string };
export type Ack = { readonly _tag: 'Ack'; readonly of: number };
export type Ping = { readonly _tag: 'Ping' };
export type Lost = { readonly _tag: 'Lost'; readonly since: number };
export type Message = Text | Ack | Ping | Lost;

const both: Message = { _tag: 'Text', body: 'гроза', of: 12 };
messages.ts(7,54): error TS2353: Object literal may only specify known properties, and 'of' does not exist in type 'Text'.

У каждого вида есть поле _tag, и его тип литеральный, полка из одной строки. 'Text' и 'Ack' не пересекаются, поэтому и полки Text и Ack не пересекаются: сообщение не бывает текстом и подтверждением сразу. Message это объединение четырёх полок без общих книг, у каждого вида только свои поля. Такое объединение называют размеченным, а общее поле с литералом тегом.

Ошибка выше показывает, как компилятор пользуется тегом. Объект с _tag: 'Text' он сразу сверяет с полкой Text, а не со всем объединением, и поле of для текста оказывается лишним. Сочетание «текст с номером кадра» больше не выразить.

Имя тега может быть любым: type, kind, _tag. В Effect выбрали _tag, и он встречается там везде: Option это Some или None, Result и Exit это Success или Failure, у каждой ошибки свой _tag. Протокол shifr берёт то же имя, чтобы в разделах про Effect эти типы читались как знакомые.

Создают сообщения конструкторы: text('гроза') возвращает { _tag: 'Text', body: 'гроза' }, и тег руками больше никто не пишет. У пинга полей нет, поэтому ему хватает одного готового значения ping.

Разбор по тегу

Читать сообщение теперь можно, спросив его тег:

import type { Message } from './shifr';

const summary = (message: Message): string => {
  switch (message._tag) {
    case 'Text':
      return `текст: ${message.body}`;
    case 'Ack':
      return `принят кадр ${message.body}`;
    default:
      return 'башня на связи';
  }
};
wrongcase.ts(8,37): error TS2339: Property 'body' does not exist on type 'Ack'.

Внутри case 'Text' компилятор сузил message до Text: сравнение тега с литералом оставило в объединении один член, и message.body читается. Внутри case 'Ack' остался Ack, и у него нет body, о чём ошибка и говорит, называя полку по имени. Это то же сужение, что в прошлом уроке, только проверяет оно не typeof, а значение тега.

Ни одного забытого

Поправь body на of, и сводка выше соберётся, но её default держится на честном слове: в него попадают и Ping, и Lost, и всё, что добавят завтра. Убери default и разбери четыре тега по одному. После case 'Text', 'Ack' и 'Ping' компилятор вычел из Message три полки. Если разобраны все четыре, в оставшейся ветке тип message это пустое объединение, то есть never из урока про множества. Этим и пользуются:

export const absurd = (value: never): never => {
  throw new Error(`непредусмотренное сообщение: ${JSON.stringify(value)}`);
};

Функция absurd принимает только never. Поставь её вызов в default, и он соберётся, только когда в default не осталось ни одного вида сообщения. Пустая полка, которая в уроке 05 казалась курьёзом, проверяет, что разобрано всё.

Перевод с Ук

Сводка, в которой забыт Lost:

import { absurd, type Message } from './shifr';

const summary = (message: Message): string => {
  switch (message._tag) {
    case 'Text':
      return `текст: ${message.body}`;
    case 'Ack':
      return `принят кадр ${message.of}`;
    case 'Ping':
      return 'башня на связи';
    default:
      return absurd(message);
  }
};
exhaust.ts(12,21): error TS2345: Argument of type 'Lost' is not assignable to parameter of type 'never'.

Строка 12, колонка 21: аргумент absurd. TS2345 это «аргумент не помещается в параметр». Строка одна, и читать её стоит с конца. Параметр absurd ждёт never, пустую полку. Аргумент message в этой точке имеет тип Lost: компилятор вычел Text, Ack и Ping, а Lost никто не разобрал, и он остался. Lost не помещается в пустое множество, потому что в нём есть значения. Ошибка звучит как жалоба на типы, а говорит конкретное: «ты забыл Lost, и он вот здесь». Чинится добавлением case 'Lost', после которого в default не остаётся ничего.

Вызов absurd стоит в default ещё по одной причине. Типы говорят, что туда не попасть, но значение могло прийти мимо типов: через as, через непроверенный провод. Тогда absurd бросает с понятным текстом, и сломанное сообщение видно сразу, а не через неделю в журнале.

Есть способ короче: аннотировать результат : string и обойтись без default. Компилятор тоже заметит дыру, но скажет о ней хуже:

noreturn.ts(3,37): error TS2366: Function lacks ending return statement and return type does not include 'undefined'.

Эта ошибка указывает на сигнатуру и сообщает, что функция может кончиться без return. Какой тег забыт, из неё не узнать. absurd показывает на точное место и называет полку.

Под капотом

export type Text = { readonly _tag: string; readonly body: string };
export type Ping = { readonly _tag: string };
export type Message = Text | Ping;

const summary = (message: Message): string => {
  if (message._tag === 'Text') return message.body;
  return 'башня на связи';
};
stringtag.ts(6,47): error TS2339: Property 'body' does not exist on type 'Message'.
  Property 'body' does not exist on type 'Ping'.

Сравнивая message._tag === 'Text', компилятор проходит по членам объединения и оставляет те, у которых тип тега пересекается с 'Text'. Когда тег литеральный, пересекается ровно один член. Когда тег string, 'Text' лежит внутри string у обоих членов, выкинуть некого, и message остаётся всем объединением. Нижняя строка ошибки называет член, который не ушёл. Отсюда правило: у тега в каждом члене свой литерал, иначе он не тег, а обычное поле.

Где тут дыра

import { lost, type Message } from './shifr';

const summary = (message: Message): string => {
  switch (message._tag) {
    case 'Text':
      return `текст: ${message.body}`;
    case 'Ack':
      return `принят кадр ${message.of}`;
    case 'Ping':
      return 'башня на связи';
    default:
      return 'что-то пришло';
  }
};

console.log(summary(lost(40)));
что-то пришло

Ринцвальд написал сводку, когда сообщений было три. Потом в протокол добавили Lost, и компилятор промолчал: default принимает что угодно, и Lost туда помещается. В журнале башни потеря сорока кадров записана как «что-то пришло». Смотритель читает журнал, видит, что сообщения приходят, и идёт спать. «Не ругается никогда» здесь и есть беда: компилятор не ругается, потому что ему не дали шанса.

Правда, которая прячет потерю, хуже ошибки. Так выглядят зелёные отчёты Магистрали.

Шифр, этап 3

Протокол учится различать сообщения. Ты опишешь четыре вида по тегу _tag, конструкторы для них, absurd и сводку для журнала башни, которая разбирает каждый тег и ловит забытые через never. Тексты сводок проверяются дословно, они в карточке.

Практика

Глубже в архиве

Размеченные объединения пришли из языков, где они встроены: там это тип-сумма, и разбор по вариантам компилятор проверяет на полноту сам. Как в Haskell из сумм и произведений собирают модель предметной области, где невозможные состояния не записать, разбирает урок «Алгебраические типы и моделирование домена».

Дальше

Сводка разбирает всё, Библиотекарь клеит на полки ярлыки Text, Ack, Ping и Lost. В дверях стоит Рыдкулл.

Писать четыре одинаковых канала, по одному на тег, не хочется, а общий канал на Message пустит пинг куда угодно. Как описать канал один раз и всё равно знать, что в нём лежит, разберём в следующем уроке.