У Гекса из прошлого урока провалу некуда деться, кроме исключения. Вот что получается, когда провод обрывается на втором кадре:
import * as IO from './IO';
const send = (frame: string): IO.IO<void> =>
IO.sync(() => {
if (frame.includes('T-2')) throw new Error('провод до T-2 оборван');
console.log(`ушёл ${frame}`);
});
const relay = IO.flatMap(send('T-1->T-7|1|Ping|'), () => send('T-1->T-2|2|Ping|'));
IO.runSync(relay);
console.log('отчёт: всё отправлено');
ушёл T-1->T-7|1|Ping|
Error: провод до T-2 оборван
Отчёта нет: исключение пролетело сквозь flatMap и runSync и уронило программу. Хуже другое: тип IO<void> ни словом не предупредил, что так бывает. Это та же слепота к несчастливому пути из урока 02, только теперь в своём Гексе.
В уроке 25 котёл уже решил эту задачу для вычислений: ошибка это не исключение, а значение, и её видно в типе. Гексу нужно то же самое для свитков.
Чем кончился свиток
Пусть run возвращает не голое значение, а отчёт о том, чем всё кончилось. Назовём его Exit:
export type Fail<E> = { readonly _tag: 'Fail'; readonly error: E };
export type Die = { readonly _tag: 'Die'; readonly defect: unknown };
export type Cause<E> = Fail<E> | Die;
export type Success<A> = { readonly _tag: 'Success'; readonly value: A };
export type Failure<E> = { readonly _tag: 'Failure'; readonly cause: Cause<E> };
export type Exit<A, E = never> = Success<A> | Failure<E>;
Exit похож на Result из котла, но провал в нём хранит не ошибку, а причину, и причины бывают двух видов.
Ожидаемая ошибка Fail это то, что может случиться по делу: провод оборвался, кадр битый. Такую ошибку ждут, о ней пишут в типе, на неё есть план.
Дефект Die это то, чего не должно было случиться никогда: опечатка, undefined там, где его не ждали, JSON.parse на мусоре. Обрабатывать дефект нечем, его нужно увидеть и починить.
Свиток получает вторую букву: IO<A, E> даст A или упадёт с ожидаемой ошибкой E. Дефекта в типе нет, он может случиться у любого свитка.
export type IO<A, E = never> = { readonly run: () => Exit.Exit<A, E> };
E = never по умолчанию: свиток без второй буквы не падает по делу. Поэтому тесты прошлого этапа с IO<number> остаются верными.
Провал по делу
Отправка, которая честно говорит, что провод может оборваться:
import * as IO from './IO';
type WireDown = { readonly _tag: 'WireDown'; readonly tower: string };
const send = (frame: string): IO.IO<void, WireDown> =>
frame.includes('T-2')
? IO.fail({ _tag: 'WireDown', tower: 'T-2' })
: IO.sync(() => {
console.log(`ушёл ${frame}`);
});
const relay = IO.flatMap(send('T-1->T-7|1|Ping|'), () => send('T-1->T-2|2|Ping|'));
console.log(IO.runSyncExit(relay));
const safe = IO.catch(relay, (error) => IO.succeed(`обрыв у ${error.tower}, повторю утром`));
console.log(IO.runSync(safe));
ушёл T-1->T-7|1|Ping|
{
_tag: 'Failure',
cause: { _tag: 'Fail', error: { _tag: 'WireDown', tower: 'T-2' } }
}
ушёл T-1->T-7|1|Ping|
обрыв у T-2, повторю утром
IO.fail(error) записывает провал так же, как succeed записывает успех. flatMap на провале останавливается: следующий шаг не исполняется, а Exit с причиной уходит наверх. runSyncExit отдаёт этот Exit значением, ничего не бросая. Ошибки двух шагов складываются в объединение, как в котле: flatMap свитка с ошибкой E и шага с ошибкой E2 даёт свиток с ошибкой E | E2.
IO.catch(self, f) ловит ожидаемую ошибку и продолжает другим свитком. Обработчик получает саму ошибку, со всеми полями: error.tower читается без проверок. IO.mapError переводит ошибку в другую, не трогая успех.
Слово catch занято языком, и функцию с таким именем объявить нельзя. Поэтому в модуле она называется catch_, а наружу выходит под своим именем: export { catch_ as catch }. Снаружи пишут IO.catch(...), как Effect.catch в Effect.
runSync остаётся для тех, кому нужно голое значение: на успехе он отдаёт value, на провале бросает new Error('свиток упал', { cause }). В поле cause, которое есть у ошибок JavaScript с ES2022, лежит причина из Exit, так что, поймав такое исключение, по-прежнему можно узнать, что случилось.
Провал не по делу
Исключение внутри sync теперь не роняет Гекс: sync заворачивает вызов в try и превращает брошенное в Die. То же делают map и flatMap с функциями, которые им дали. Но catch дефект не ловит:
import * as IO from './IO';
const parse = (raw: string): IO.IO<unknown> => IO.sync(() => JSON.parse(raw) as unknown);
const exit = IO.runSyncExit(IO.catch(parse('{кадр'), () => IO.succeed('поймал')));
console.log(exit._tag, exit._tag === 'Failure' ? exit.cause._tag : '');
Failure Die
Тип IO<unknown> обещал, что parse не падает по делу, и catch поверил: ему нечего ловить. Битый JSON здесь это баг в том, кто передал строку, и его не спрячешь обработчиком.
Перевод с Ук
Свиток разбирает кадр, потом отправляет его, а обработчик написан для одной ошибки:
import * as IO from './IO';
type WireDown = { readonly _tag: 'WireDown'; readonly tower: string };
type BadFrame = { readonly _tag: 'BadFrame'; readonly raw: string };
declare const send: (frame: string) => IO.IO<void, WireDown>;
declare const parse: (raw: string) => IO.IO<number, BadFrame>;
const relay = IO.flatMap(parse('T-1->T-2|2|Ping|'), () => send('T-1->T-2|2|Ping|'));
const handled = IO.catch(relay, (error: WireDown) => IO.succeed(`обрыв у ${error.tower}`));
ook.ts(10,26): error TS2345: Argument of type 'IO<void, WireDown | BadFrame>' is not assignable to parameter of type 'IO<void, WireDown>'.
Type 'WireDown | BadFrame' is not assignable to type 'WireDown'.
Property 'tower' is missing in type 'BadFrame' but required in type 'WireDown'.
Строка 10, колонка 26: первый аргумент catch, сам relay. Читаем снизу. У BadFrame нет поля tower, поэтому WireDown | BadFrame не помещается в WireDown, поэтому свиток с такими ошибками не подходит туда, где ждут свиток с одной WireDown. Откуда компилятор взял, что ждут одну? Из аннотации обработчика: ты написал (error: WireDown) =>, и по ней catch вывел, какой свиток готов принять. Обработчик обязан разобрать каждую ошибку, которую может дать свиток, и тип не даст забыть битый кадр. Чинится тем, что аннотацию убирают: тогда error получит тип WireDown | BadFrame, и разбирать его придётся по тегу, как в уроке 07.
Под капотом
IO.catch с обработчиком, который сам не падает, даёт свиток с ошибкой never: всё, что обещал тип, разобрано. Если бы catch ловил и Die, обработчик получал бы ещё и unknown из чужого бага, а тип притворялся бы, что разобрал его. Обработчик, написанный для оборванного провода, молча принял бы опечатку за обрыв. Поэтому в Effect два вида ошибок разведены так же: Effect.catch ловит ожидаемые, а до дефектов добираются отдельными, заметными функциями, Effect.catchDefect и Effect.catchCause, и обычно на краю программы, чтобы записать их в журнал.
У нашего catch_ это одна проверка: exit.cause._tag === 'Die', и такой Exit уходит дальше нетронутым. Одна тонкость в типах: проверка сужает exit.cause, но не сам exit, поэтому возвращают новый Exit.failCause(exit.cause), а не старый объект. Иначе компилятор посчитает, что наружу ушла и старая ошибка E.
Где тут дыра
import * as IO from './IO';
const catchAll = <A, E, B>(self: IO.IO<A, E>, fallback: () => IO.IO<B>): IO.IO<A | B> => ({
run: () => {
const exit = self.run();
return exit._tag === 'Success' ? exit : fallback().run();
},
});
const parse = (raw: string): IO.IO<number> =>
IO.sync(() => (JSON.parse(raw) as { readonly frames: number }).frames);
const report = catchAll(
IO.map(parse('{"frames": 3'), (frames) => `ушло кадров: ${frames}`),
() => IO.succeed('провод шалит, повторю утром'),
);
console.log(IO.runSync(report));
провод шалит, повторю утром
Провод тут ни при чём: в отчёте Ринцвальда потеряна закрывающая скобка, и JSON.parse бросает. catchAll принял дефект за обрыв и написал в журнал то, что пишет при обрыве. Завтра он повторит отправку, послезавтра снова, и баг в отчёте проживёт столько, сколько проживёт этот catchAll. Отчёт, который никогда не падает, ничего не сообщает, как зелёные отчёты Магистрали.
Вот куда придёшь
В Effect свиток с ошибкой выглядит так же, только ошибка стоит второй буквой в Effect<A, E, R>:
import { Effect } from 'effect';
type WireDown = { readonly _tag: 'WireDown'; readonly tower: string };
const send = (frame: string): Effect.Effect<void, WireDown> =>
frame.includes('T-2')
? Effect.fail({ _tag: 'WireDown', tower: 'T-2' })
: Effect.sync(() => console.log(`ушёл ${frame}`));
const relay = Effect.flatMap(send('T-1->T-7|1|Ping|'), () => send('T-1->T-2|2|Ping|'));
const safe = Effect.catch(relay, (error) => Effect.succeed(`обрыв у ${error.tower}`));
console.log(Effect.runSync(safe));
Гекс, этап 2
Гекс учится падать по правилам. Ты заведёшь модуль src/Exit.ts с итогом свитка и причинами, переведёшь src/IO.ts на Exit и добавишь fail, catch, mapError и runSyncExit. Начинка IO.ts меняется, а тесты прошлого этапа остаются и должны проходить.
Практика
Глубже в архиве
Как исключения и стек вызовов устроены в самом JavaScript, почему catch (e) получает unknown и как теряется причина ошибки, разбирает урок «Ошибки и трассировка стека».
Дальше
Гекс больше не падает от обрыва: провал приходит по своей трубке. Пондер смотрит на send и стучит карандашом по слову wire.
Откуда свиток берёт то, что ему нужно, и что всё это время носил в себе Сундук, разберём в следующем уроке.