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

Ошибка в типе

middle~25 мин

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

У Гекса из прошлого урока провалу некуда деться, кроме исключения. Вот что получается, когда провод обрывается на втором кадре:

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.

Откуда свиток берёт то, что ему нужно, и что всё это время носил в себе Сундук, разберём в следующем уроке.