По проводу ходят сообщения четырёх видов: текст, подтверждение принятого кадра, пинг «я на связи» и отметка «потеряно, начиная с такого-то кадра». У каждого вида свои поля. Ринцвальд сложил их в один тип, где все поля необязательные, и теперь вид сообщения угадывает по тому, какие поля есть.
В прошлом уроке ты сужал 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 пустит пинг куда угодно. Как описать канал один раз и всё равно знать, что в нём лежит, разберём в следующем уроке.