В прошлом уроке ночь разбиралась целиком, и у каждой ошибки появился тег. Тегов теперь много: четыре у сообщений, семь у ошибок башни, и по каждому кто-то где-то решает, что делать. Это урок о том, как решать по тегу так, чтобы компилятор помнил все варианты за тебя, а само решение было значением, которое можно передать и переиспользовать.
Десять switch
Вот журнал Ринцвальда. Он пишет строку про каждое сообщение:
export const logMessage = (message: Message): void => {
switch (message._tag) {
case 'Text': {
console.log(`текст: ${message.body}`);
break;
}
case 'Ack': {
console.log(`подтверждён ${message.of}`);
break;
}
case 'Ping': {
console.log('пинг');
break;
}
}
};
logMessage({ _tag: 'Lost', since: 1 });
console.log('после лога Lost');
после лога Lost
Собирается без единого замечания, и на Lost молчит. Почему компилятор не заметил? switch это инструкция, а не выражение: ему не нужно ничего вернуть, и никто не требует, чтобы он разобрал все случаи. Функция возвращает void, и «ничего не сделал» для неё такой же законный исход, как «записал строку».
Ты знаешь лекарство из урока 07: default: return absurd(message). В default попадают варианты, которые никто не разобрал, а absurd принимает только never, пустую полку. Забыл Lost, и вызов absurd не соберётся. Лекарство работает, но его надо не забыть в каждом из десяти switch. Рыдкулл нашёл два, где его забыли.
Беда не в самом switch, а в том, что полнота у него держится на дисциплине. Хочется, чтобы она была свойством по умолчанию.
Сопоставление как значение
В effect для этого есть модуль Match. Вот что башня делает с сообщением, записанное через него:
import { Match } from 'effect';
export const outcomeFor: (message: Message) => Outcome = Match.type<Message>().pipe(
Match.tag('Text', () => 'acked' as const),
Match.tag('Ping', () => 'ponged' as const),
Match.tag('Ack', () => 'confirmed' as const),
Match.tag('Lost', () => 'noted' as const),
Match.exhaustive,
);
Читаем по строкам.
Match.type<Message>()заводит сопоставитель для входа типаMessage. Пока в нём нет ни одного случая..pipe(...)пропускает его через функции по очереди, какpipeиз урока 37.Match.tag('Text', обработчик)добавляет случай: если_tagравен'Text', позвать обработчик. Обработчик получает уже суженный вариант, с полемbody. Главное происходит в типе:Textвычитается из того, что осталось разобрать.Match.exhaustiveзакрывает сопоставитель и превращает его в функцию(message: Message) => Outcome. Закрыть он соглашается, только если разбирать больше нечего.
Получилось не действие, а значение. outcomeFor это обычная функция: её можно передать, положить в объект, позвать в Effect.map. И она не умеет забыть вариант.
const planned = Effect.map(decodeFrame('T-3->T-1|2|Lost|1'), (frame) => outcomeFor(frame.message));
console.log(Effect.runSync(planned));
noted
Сопоставитель ничего не знает ни про квитанции, ни про провод. Это вычисление в смысле урока 17: на одно и то же сообщение он всегда отвечает одно и то же и ничего не трогает снаружи. Поэтому его зовут внутри Effect.map, а проверяют без башни и без запуска, одной строкой на вариант: outcomeFor({ _tag: 'Ping' }) даёт 'ponged'. Так устроены и тесты этапа. А решение «отвечать ли на пинг пингом» стоит в одном месте, а не в десяти.
Несколько тегов с одним ответом пишутся в одном случае: Match.tag('Ping', 'Lost', () => 'служебный'). Так же устроен catchTag с массивом тегов из урока 39: там ловят по тегу ошибки, здесь сопоставляют по тегу любые значения.
Перевод с Ук
Убери из outcomeFor строку с Lost:
export const outcomeFor: (message: Message) => Outcome = Match.type<Message>().pipe(
Match.tag('Text', () => 'acked' as const),
Match.tag('Ping', () => 'ponged' as const),
Match.tag('Ack', () => 'confirmed' as const),
Match.exhaustive,
);
outcome.ts(15,3): error TS2345: Argument of type '<I, F, A, Pr, Ret, Args extends Array<any>>(self: Matcher<I, F, never, A, Pr, Ret, Args>) => [Pr] extends [never] ? [Args] extends [[]] ? (u: I) => Unify<...> : (...args: Args) => Unify<...> : Unify<...>' is not assignable to parameter of type '(_: Matcher<Message, Without<{ readonly _tag: "Text"; readonly body: string; } | { readonly _tag: "Ack"; readonly of: number; } | { readonly _tag: "Ping"; }>, { readonly _tag: "Lost"; readonly since: number; }, "acked" | ... 1 more ... | "confirmed", never, any, []>) => (u: Message) => "acked" | ... 1 more ... | "co...'.
Types of parameters 'self' and '_' are incompatible.
Type 'Matcher<Message, Without<{ readonly _tag: "Text"; readonly body: string; } | { readonly _tag: "Ack"; readonly of: number; } | { readonly _tag: "Ping"; }>, { readonly _tag: "Lost"; readonly since: number; }, "acked" | ... 1 more ... | "confirmed", never, any, []>' is not assignable to type 'Matcher<Message, Without<{ readonly _tag: "Text"; readonly body: string; } | { readonly _tag: "Ack"; readonly of: number; } | { readonly _tag: "Ping"; }>, never, "acked" | "ponged" | "confirmed", never, any, []>'.
Type 'TypeMatcher<Message, Without<{ readonly _tag: "Text"; readonly body: string; } | { readonly _tag: "Ack"; readonly of: number; } | { readonly _tag: "Ping"; }>, { readonly _tag: "Lost"; readonly since: number; }, "acked" | ... 1 more ... | "confirmed", any, []>' is not assignable to type 'TypeMatcher<Message, Without<{ readonly _tag: "Text"; readonly body: string; } | { readonly _tag: "Ack"; readonly of: number; } | { readonly _tag: "Ping"; }>, never, "acked" | "ponged" | "confirmed", any, []>'.
Type '{ readonly _tag: "Lost"; readonly since: number; }' is not assignable to type 'never'.
Пять строк, и каждая длиннее экрана. Пугаться не надо: читаем снизу, и хватит последней. { readonly _tag: "Lost"; readonly since: number; } не помещается в never. Это та же жалоба, что у absurd в уроке 07: в пустую полку положили вариант, а у варианта есть значения.
Где эта полка? Поднимись на строку выше. Там два типа TypeMatcher<...>, и различаются они третьим параметром. У твоего сопоставителя третьим стоит { _tag: "Lost"; ... }, у того, которого ждёт exhaustive, стоит never. Третий параметр сопоставителя это «что осталось разобрать». Второй, Without<Text | Ack | Ping>, это «что уже разобрано», он у обоих одинаковый.
Самая верхняя строка называет место: строка 15 (выше в файле импорт и объявления Message и Outcome), аргумент pipe, то есть Match.exhaustive. Перевод целиком: «exhaustive закрывает только сопоставитель, у которого ничего не осталось, а у тебя остался Lost». Чинится строкой Match.tag('Lost', ...). Если Магистраль придумает пятый тег и он появится в Message, такая же ошибка встанет в каждом сопоставителе, который о нём не знает.
Обработчики объектом
Когда у каждого тега свой ответ, короче записать их объектом. Строка журнала про ошибку башни:
export const explain: (error: TowerError) => string = Match.type<TowerError>().pipe(
Match.tags({
GarbledFrame: (error) => `мусор на проводе: ${error.line}`,
BadRoute: (error) => `нет такого маршрута: ${error.route}`,
UnknownTag: (error) => `неизвестный тег: ${error.tag}`,
BadPayload: (error) => `битые данные: ${error.payload}`,
NotForUs: (error) => `кадр для ${error.to}`,
PhantomAck: (error) => `призрак: подтверждён кадр ${error.of}`,
SendFailed: (error) => `провод не принял: ${error.reason}`,
}),
Match.exhaustive,
);
Match.tags берёт объект «тег → обработчик», и каждый обработчик получает свой вариант: у GarbledFrame есть line, у PhantomAck есть of, и компилятор это знает. Ключ, которого нет среди тегов, не соберётся: опечатка BadRout вместо BadRoute даст Type '() => string' is not assignable to type 'never' на строке с опечаткой. Забытый тег поймает exhaustive, как в «Переводе с Ук».
Есть и сокращение Match.tagsExhaustive({ ... }), которое сразу требует все теги и закрывает сопоставитель. На вход сопоставитель берёт не только тип: Match.value(x) сопоставляет одно значение здесь и сейчас, а Match.when проверяет произвольный образец или предикат. Для башни хватит type и tag.
Литералы
Состояние соседней башни это не объект с тегом, а строка: 'ok' | 'busy' | 'down'. Для строковых и числовых литералов в Effect 4.1 появился Match.literals:
Нестабильно.
Match.literalsиMatch.literalsExhaustiveпомечены в исходникеeffectметкой@stability unstableи появились в версии 4.1.0. Имя, форма объекта и правила вывода типа могут поменяться в минорной версии. Урок написан наeffect@4.1.0; если у тебя версия новее, сверь сигнатуру с документацией.
export type Status = 'ok' | 'busy' | 'down';
export const statusWord: (status: Status) => string = Match.type<Status>().pipe(
Match.literals({ ok: () => 'на связи', busy: () => 'занята' }),
Match.orElse(() => 'молчит'),
);
console.log(statusWord('busy'));
console.log(statusWord('down'));
занята
молчит
Match.orElse закрывает сопоставитель запасным ответом для всего, что осталось. Здесь он честный: «всё, что не на связи и не занято, молчит» это правило Академии, а не забывчивость. Чем orElse опасен, увидишь ниже.
Под капотом
В типах сопоставитель несёт параметры, которые ты видел в ошибке: вход, «что разобрано», «что осталось» и объединение ответов. Каждый Match.tag вычитает свой вариант из «что осталось», почти как Exclude из урока 11, и добавляет тип ответа обработчика к объединению. exhaustive требует, чтобы «что осталось» было never. Вся проверка полноты происходит при компиляции.
В рантайме сопоставитель это список случаев: проверка и обработчик. Готовая функция проходит их по порядку, и первый подошедший отвечает. Если не подошёл ни один, а по типам такого быть не могло, exhaustive бросает ошибку с текстом effect/match/Match/exhaustive: absurd. Так бывает, когда в функцию попали непроверенные данные, например строка с провода, приведённая через as. Поэтому сопоставление стоит после разбора кадра в decodeFrame, а не вместо него: Match верит типам и проверять вход не обещал.
Отсюда и ответ на вопрос «а почему не switch с absurd?». Полнота у них одинаковая, и switch чуть быстрее: он сравнивает строки сам, а сопоставитель вызывает функцию-проверку на каждый случай. Match выигрывает в другом. Он выражение, а не инструкция, поэтому всегда что-то возвращает. Сопоставитель это значение, его собирают из частей и передают. И полнота в нём по умолчанию: чтобы её выключить, надо написать orElse, а не забыть default.
Где тут дыра
type TowerError = GarbledFrame | BadRoute | SendFailed | UnknownTower;
export const explainLoose = Match.type<TowerError>().pipe(
Match.tag('GarbledFrame', (error) => `мусор на проводе: ${error.line}`),
Match.tag('BadRoute', (error) => `нет такого маршрута: ${error.route}`),
Match.tag('SendFailed', (error) => `провод не принял: ${error.reason}`),
Match.orElse(() => 'что-то сломалось'),
);
console.log(explainLoose(new UnknownTower({ code: 'T-9' })));
что-то сломалось
На его трёх ошибках всё работает. Но UnknownTower из прошлого урока прибавилась в TowerError, и сопоставитель её проглотил: собрался без замечаний и пишет в отчёт «что-то сломалось» вместо «нет в книге: T-9». Код не пришлось трогать, когда ошибок прибавилось, ровно как Ринцвальд и хотел. Поэтому никто и не узнал, что их прибавилось.
orElse выключает проверку полноты целиком: всё, что осталось, теперь и впредь, уходит в один ответ. Это тот же catch {} Магистрали, только в отчёте: ошибка есть, а её имени не видно. Где варианты закрыты и значат разное, пиши exhaustive, и новый тег сам покажет все места, где про него надо подумать. orElse оставь для случаев, где «всё остальное» это правило, как у statusWord.
Башня, этап 8
Башня учится отвечать по тегу без switch: что делать с каждым сообщением, какую строку писать в журнал про каждую ошибку и как назвать состояние соседней башни. Тексты строк журнала тесты проверяют дословно, они в карточке заказа.
Практика
Дальше
Сопоставители собраны, Lost больше не пропадает, а новый тег покажет все места, где о нём надо подумать. Под утро Рыдкулл приносит журнал провода.
Почему два одинаковых кадра для JavaScript разные и как научить башню узнавать кадр по значению, разберём в следующем уроке.