В прошлом уроке ты вскрывал причину провала и отличал в ней ошибку дела от дефекта. Там ошибки были настоящие, с тегами. Здесь ошибки ещё нет: есть значение, которого нет. Книга башен это словарь «код башни → имя», и поиск в нём отвечает либо именем, либо undefined.
С отсутствием ты уже встречался. В уроке 24 Матушка Воскова дала тебе коробку Option, а в уроке 25 коробку Result с причиной внутри. Тогда коробки были свои, из котла. Теперь они настоящие, из effect, и этот урок о том, как они живут рядом с квитанцией Effect. В Effect 4 коробка и квитанция это разные вещи, и переход между ними пишут явно.
Книга башен
Вот как Ринцвальд ищет имя башни:
const book: ReadonlyMap<string, string> = new Map([
['T-1', 'Академия'],
['T-3', 'Мост'],
]);
const towerName = async (code: string): Promise<string> => book.get(code) as string;
export const report = async (code: string): Promise<string> =>
`кадр от башни ${await towerName(code)}`;
console.log(await report('T-3'));
console.log(await report('T-9'));
кадр от башни Мост
кадр от башни undefined
book.get честно отвечает string | undefined: компилятор знает, что башни может не оказаться. Ринцвальд не захотел возиться с undefined и написал as string, то самое «поверь мне», от которого предостерегал урок 03. Компилятор поверил. undefined прошло через промис, через шаблонную строку и встало в отчёт словом.
Исключения никто не бросил, и ловить нечего. Это та же болезнь, что у catch {} Магистрали, только без catch: беда превратилась в значение, которое выглядит нормальным. Без as пришлось бы проверять undefined в каждом месте, где нужно имя, и рано или поздно одно место забылось бы. Матушка лечила это коробкой.
Коробка из effect
В effect есть модуль Option той же формы, что ты собирал в котле: теги Some и None, поле value. Из мира undefined в мир коробок ведёт Option.fromNullishOr, так в Effect 4 зовут твой fromNullable:
import { Option } from 'effect';
const entry: Option.Option<string> = Option.fromNullishOr(book.get('T-9'));
// { _tag: 'None' }
Отсутствие больше не похоже на имя: это пустая коробка, и открыть её, не проверив, компилятор не даст. Но башня пишет квитанции, а не коробки. Обработчик кадра это Effect.gen из урока 37, и первое, что хочется сделать, это открыть коробку прямо в рецепте через yield*:
import { Effect, Option } from 'effect';
const book: ReadonlyMap<string, string> = new Map([['T-3', 'Мост']]);
export const name = Effect.gen(function* () {
const found = yield* Option.fromNullishOr(book.get('T-3'));
return found;
});
Перевод с Ук
name.ts(5,32): error TS2345: Argument of type '() => Generator<Option.Option<string>, string, any>' is not assignable to parameter of type '() => Generator<Effect<any, any, any>, string, never>'.
Call signature return types 'Generator<Option<string>, string, any>' and 'Generator<Effect<any, any, any>, string, never>' are incompatible.
The types returned by 'next(...)' are incompatible between these types.
Type 'IteratorResult<Option<string>, string>' is not assignable to type 'IteratorResult<Effect<any, any, any>, string>' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the types of the target's properties.
Type 'IteratorYieldResult<Option<string>>' is not assignable to type 'IteratorYieldResult<Effect<any, any, any>>' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the types of the target's properties.
Type 'Option<string>' is not assignable to type 'Effect<any, any, any>' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the types of the target's properties.
Property '[TypeId]' is missing in type 'None<string>' but required in type 'Effect<any, any, any>'.
Читаем снизу, как учил урок 04. Последняя строка: у None<string> нет поля [TypeId], а у Effect оно обязательно. [TypeId] это метка, по которой TypeScript отличает квитанцию от всего остального. Строкой выше то же для целых типов: Option<string> не Effect<any, any, any>. Ещё выше компилятор объясняет, откуда он это взял: генератор отдаёт наружу то, что стоит справа от yield*, твой отдаёт Option<string>, а Effect.gen принимает только генераторы, которые отдают Effect. Самая верхняя строка называет место: аргумент Effect.gen, строка 5.
Слова про exactOptionalPropertyTypes в середине не про тебя. Компилятор сравнивал результаты итератора, у которых есть необязательные поля, и на всякий случай упомянул флаг. Беда в последней строке.
Перевод в одну фразу: «в рецепт Effect.gen отдают только квитанции, а ты отдал коробку». Почему yield* на коробке вообще пишется? Коробка итерируема, но для своего рецепта, Option.gen, такого же, как твой из урока 28. В чужой рецепт она не годится.
Из коробки в квитанцию
Мост из коробки в квитанцию это Effect.fromOption:
const named = Effect.fromOption(entry);
// Effect<string, Cause.NoSuchElementError>
Полная коробка становится успехом, пустая провалом с ошибкой NoSuchElementError. Это уже лучше undefined: провал виден в типе, и yield* его не пропустит. Но имя у ошибки общее, она не скажет, какой башни не нашлось. Отчёту Академии нужна своя ошибка, с кодом башни. Её даёт mapError из урока 39:
import { Data, Effect, Option } from 'effect';
export class UnknownTower extends Data.TaggedError('UnknownTower')<{ readonly code: string }> {}
export const lookupTower = (code: string): Effect.Effect<string, UnknownTower> =>
Effect.mapError(
Effect.fromOption(Option.fromNullishOr(book.get(code))),
() => new UnknownTower({ code }),
);
Короче то же пишется вторым аргументом: Effect.fromOption(коробка, () => new UnknownTower({ code })) сразу даёт Effect<string, UnknownTower>. Есть и путь из undefined без коробки: Effect.fromNullishOr(book.get(code)), он падает с той же NoSuchElementError.
Отчёт Ринцвальда переписывается так:
const report = (code: string) =>
Effect.gen(function* () {
const name = yield* lookupTower(code);
return `кадр от башни ${name}`;
});
console.log(Effect.runSync(report('T-3')));
console.log(Effect.runSync(Effect.flip(report('T-9'))));
кадр от башни Мост
{ code: 'T-9', _tag: 'UnknownTower' }
Effect.flip меняет успех и ошибку местами: так ошибку видно без вскрытия Exit. Тип report теперь Effect<string, UnknownTower>. Слово undefined в отчёт не попадёт, а «нет в книге» стало ошибкой с тегом и кодом. Её можно поймать catchTag, описать в отчёте и сосчитать.
С Result всё так же: Effect.fromResult превращает Success в успех квитанции, а Failure в провал с той же ошибкой. Пригодится, когда разбор уже написан на коробках, как твой журнал в котле, а дальше нужна квитанция. Вот все мосты урока на одной карточке:
| Откуда | Куда | Чем |
|---|---|---|
A | undefined | Effect<A, NoSuchElementError> | Effect.fromNullishOr |
Option<A> | Effect<A, NoSuchElementError> | Effect.fromOption |
Option<A> | Effect<A, E> | Effect.fromOption(коробка, () => ошибка) |
Result<A, E> | Effect<A, E> | Effect.fromResult |
Effect<A, E> | Effect<Result<A, E>, never> | Effect.result |
Effect<A, E> | Effect<Option<A>, never> | Effect.option |
Две последние строки ведут обратно, из квитанции в коробку. О них дальше.
Под капотом
В Effect 3 Option и Either (так раньше звали Result) были подтипами Effect. yield* на коробке внутри Effect.gen проходил, а пустая коробка становилась провалом NoSuchElementException. Писать было коротко, читать труднее: по строке yield* entry не понять, ищешь ты в книге или идёшь по проводу, и откуда в ошибках квитанции взялось «элемента нет». В Effect 4 коробки снова стали данными, а переход из данных в действие пишется словом fromOption, с ошибкой, которую ты выбрал сам.
У нас в подвале так было с самого начала. Коробка котла и свиток Гекса были разными типами, и чтобы положить одно в другое, надо было что-то написать. Коробку проверяют сразу, она ничего не делает и ничего не ждёт. Свиток исполняет Гекс, его можно запустить, повторить и отменить. Смешать их значит спрятать, где кончается расчёт и начинается действие, а различать их Матушка Воскова учила весь EF2.
Из квитанции в коробку
Бывает нужно обратное. Ночью на провод приходит сотня строк, часть из них мусор. Promise.all упал бы на первой битой строке и потерял всю ночь, поэтому Ринцвальд взял Promise.allSettled:
export const decode = async (line: string): Promise<string> => {
if (!line.includes('|')) throw new Error(`мусор: ${line}`);
return line;
};
const night = ['T-3->T-1|1|Ping|', 'мусор', 'T-3->T-1|2|Lost|1'];
const results = await Promise.allSettled(night.map(decode));
for (const result of results) {
if (result.status === 'rejected') console.log(result.reason.message.toUpperCase());
}
МУСОР: МУСОР
Ночь не теряется: каждая строка отчитывается, удалась она или нет. Зато теряются типы. result.reason у отклонённого промиса это any, и компилятор пропустит reason.message.toUpperCase(), хотя никто не обещал, что там Error. Какие ошибки бывают при разборе, по этому коду не узнать.
У квитанций та же развилка. Effect.all из урока 36 собирает массив квитанций в одну, как Promise.all, и так же падает на первой ошибке. Если каждую строку сначала завернуть в Effect.result, падать будет нечему:
import { Effect } from 'effect';
import { decodeFrame } from './frame';
const night = ['T-3->T-1|1|Ping|', 'мусор', 'T-1->T-2|5|Smoke|', 'T-3->T-1|2|Lost|1'];
const strict = Effect.all(night.map((line) => decodeFrame(line)));
// Effect<Frame[], DecodeError>: первая битая строка роняет всю ночь
const settled = Effect.all(night.map((line) => Effect.result(decodeFrame(line))));
// Effect<Result<Frame, DecodeError>[], never>
Effect.result берёт квитанцию Effect<A, E> и отдаёт Effect<Result<A, E>, never>: ожидаемая ошибка переезжает из канала провала в значение, в коробку Failure. В типе это видно по never на месте ошибок: такая квитанция ожидаемой ошибкой не падает. Все четыре строки разобраны, и про каждую известно, что с ней:
[ 'Ping', 'GarbledFrame', 'UnknownTag', 'Lost' ]
Осталось разложить коробки на две стопки. Result.isSuccess это предикат с is из урока 06, и filter с ним сужает массив до успехов:
export const decodeAll = (
lines: readonly string[],
): Effect.Effect<{ readonly frames: readonly Frame[]; readonly errors: readonly DecodeError[] }> =>
Effect.map(Effect.all(lines.map((line) => Effect.result(decodeFrame(line)))), (results) => ({
frames: results.filter(Result.isSuccess).map((result) => result.success),
errors: results.filter(Result.isFailure).map((result) => result.failure),
}));
На ночи из примера это два кадра и две ошибки, GarbledFrame и UnknownTag, обе по порядку провода. Сравни с allSettled: ни одна строка не потеряна, и у каждой ошибки тег, который знает компилятор. Это и есть разбор, который не теряет ни строки.
Effect.option делает то же, но выбрасывает причину: провал становится пустой коробкой. Это подходит, когда причина неинтересна, например «есть такой кадр или нет». Отчёту Академии так нельзя: по пустой коробке не скажешь, пришёл мусор или неизвестный тег.
И правило из урока 40: Effect.result и Effect.option превращают в значение только ожидаемые ошибки. Дефект идёт мимо них, и квитанция кончится Die, как и без них:
Effect.runSyncExit(Effect.result(Effect.die(new Error('boom'))));
// Exit.Failure, в причине Die(boom)
Где тут дыра
const towerName = (code: string): Effect.Effect<string> =>
Effect.sync(() => Option.getOrThrow(Option.fromNullishOr(book.get(code))));
const safe = Effect.catch(towerName('T-9'), () => Effect.succeed('неизвестная башня'));
const exit = Effect.runSyncExit(safe);
if (Exit.isFailure(exit)) console.log(Cause.pretty(exit.cause));
На T-3 всё работает, и тип красивый: Effect<string>, без ошибок. На T-9 выходит вот что:
Error: getOrThrow called on a None
at <anonymous> (Option.ts:1467:75)
catch не помог. getOrThrow бросает внутри Effect.sync, а исключение внутри sync это дефект, как в уроке 40. Тип соврал: Effect<string> обещает, что ожидаемых ошибок нет, и catch честно ничего не ловит, потому что ловить ему нечего. Дефект прошёл мимо и уронил всю квитанцию.
Отсутствие башни не из разряда «быть не может». Это обычная ночь на Магистрали, где башни строят и переименовывают. Значит, это ошибка дела, и её место в типе, с тегом. getOrThrow оставь для пустоты, которая значит, что сломан твой собственный код.
Башня, этап 7
Книга башен приходит аргументом, словарём ReadonlyMap «код → имя». Башня учится искать в ней с ошибкой UnknownTower, а не со словом undefined, и разбирать ночь целиком: кадры по порядку, ошибки по порядку, ни одна строка не пропадает. Сигнатуры и пример ночи в карточке заказа.
Практика
Дальше
Ночь разобрана, ни одна строка не потерялась, у каждой ошибки тег. Рыдкулл листает код обработчика и считает вслух.
Как сопоставлять по тегу так, чтобы забытый вариант не собирался, а сопоставление было значением, которое можно передать и переиспользовать, разберём в следующем уроке.