В прошлом уроке мы развели ошибки и дефекты по разным корзинам: ошибка стоит в типе, дефект нет. Но провал не обязан быть одним. Этот урок о том, из чего состоит провал эффекта, как прочитать его целиком, ничего не потеряв, и почему catchTag иногда не ловит даже ту ошибку, которая записана в типе.
Скоропись теряет первую
Сначала посмотрим, как с этим справляется скоропись. Ринцвальд записывает кадр в журнал в finally, чтобы запись была при любом исходе:
class BadRoute extends Error {}
const journal = { open: false, lines: [] as string[] };
const handleFrame = async (line: string): Promise<string> => {
throw new BadRoute(`маршрут не разобрать: ${line}`);
};
const writeJournal = (line: string): void => {
if (!journal.open) throw new Error('журнал закрыт');
journal.lines.push(line);
};
export const handleLogged = async (line: string): Promise<string> => {
try {
return await handleFrame(line);
} finally {
writeJournal(line);
}
};
handleLogged('T-1->T-1->T-2|2|Ping|').catch((error: Error) => console.log(error.message));
журнал закрыт
BadRoute пропала. В JavaScript исключение из finally заменяет то, что летело из try, и первое исчезает без следа. Наружу вылетает поломка журнала, а то, что кадр шёл по кривому маршруту, не узнает никто. Это тот же catch {} Магистрали, только его никто не писал: язык проглотил ошибку сам. С Promise.all похожая история: из нескольких упавших промисов наружу выходит одна причина, первая, остальные теряются.
Exit: чем кончился запуск
Начнём с итога. В уроке 36 Exit появился как то, чем отвечают Effect.runSyncExit и Effect.runPromiseExit. Теперь разберём его по-честному.
Exit это размеченное объединение из двух веток, как Result из котла:
Successс полемvalue: A, эффект кончился успехом;Failureс полемcause: Cause<E>, эффект провалился, иcauseобъясняет, почему.
const bad = Effect.runSyncExit(Effect.fail(new BadRoute({ route: 'T-1->T-1->T-2' })));
const text = Exit.match(bad, {
onSuccess: (value) => `ok: ${value}`,
onFailure: (cause) => `причин: ${cause.reasons.length}`,
});
console.log(text);
причин: 1
Exit.isSuccess и Exit.isFailure сужают тип, как предикаты из урока 06. А Effect.exit превращает провал в значение внутри программы: из Effect<A, E> получается Effect<Exit<A, E>>. Такой эффект сам не падает: любой провал, даже дефект, становится значением, и итог можно разобрать дальше в том же рецепте. Это пригодится башне, когда она будет писать отчёт за ночь.
Cause: протокол вскрытия
Самое интересное лежит в cause. У Гекса причина провала была одна: Fail, Die или Interrupt. В Effect 4 Cause плоский: у него поле reasons, массив причин, и каждая из них одна из трёх.
Failс полемerror: E, ожидаемая ошибка;Dieс полемdefect: unknown, дефект;Interrupt, эффект остановили снаружи, как рычагом Гекса в боссе EF3.
Пройти причины можно обычным switch по _tag, тем же, что в уроке 07:
const explain = (exit: Exit.Exit<unknown, TowerError>): string[] =>
Exit.isSuccess(exit)
? []
: exit.cause.reasons.map((reason) => {
switch (reason._tag) {
case 'Fail': {
return `ошибка дела: ${reason.error._tag}`;
}
case 'Die': {
return `поломка: ${String(reason.defect)}`;
}
case 'Interrupt': {
return 'прервано';
}
}
});
В ветке Fail у reason.error тип TowerError, и _tag доступен без проверок. В ветке Die у reason.defect тип unknown, по той же причине, что в catchDefect: бросить можно что угодно. Для частых вопросов есть готовые проверки: Cause.hasFails, Cause.hasDies, Cause.hasInterrupts.
Зачем массив, если обычно причина одна? Ради кадра номер 2. Вот он на Effect, с журналом в финализаторе:
import { Cause, Effect, Exit } from 'effect';
import { handleChecked, type Tower } from './tower';
const tower: Tower = { send: async () => {}, sent: new Set([1]) };
const journal = { open: false, lines: [] as string[] };
const writeJournal = (line: string) =>
Effect.sync(() => {
if (!journal.open) throw new Error('журнал закрыт');
journal.lines.push(line);
});
const handleLogged = (line: string) =>
Effect.ensuring(handleChecked(tower, line), writeJournal(line));
const main = async () => {
const exit = await Effect.runPromiseExit(handleLogged('T-1->T-1->T-2|2|Ping|'));
if (Exit.isFailure(exit)) {
console.log(exit.cause.reasons.map((reason) => reason._tag));
console.log(Cause.pretty(exit.cause));
}
};
void main();
[ 'Die', 'Die' ]
BadRoute:
at routeOf (frame.ts:48:19)
at <anonymous> (frame.ts:83:33)
at Tower.handleChecked (night.ts:15:19)
at Tower.handleChecked (definition) (tower.ts:115:37)
Error: журнал закрыт
at <anonymous> (night.ts:10:30)
Effect.ensuring это финализатор из Гекса: исполняется при любом исходе. Cause.pretty печатает причину целиком, для человека, и в ней обе беды: и BadRoute со строкой, где маршрут не разобрался, и поломка журнала. Ничего не пропало. А Tower.handleChecked в трассе это имя из Effect.fn, которое мы дали обработчику в уроке 38: по нему видно, чей это кадр.
Но посмотри на первую строку вывода: причины две, и обе Die. BadRoute была ожидаемой ошибкой, а в причине она лежит дефектом.
Дефект главнее
Это правило Effect 4, и его надо знать в лицо: если в причине есть дефект, ожидаемые ошибки рядом с ним тоже становятся дефектами. В исходниках оно так и записано: когда к провалу работы добавляется провал финализатора, дефекты главнее ожидаемых ошибок, и каждый Fail превращается в Die. Сама ошибка не теряется, она лежит внутри Die, как в orDie из прошлого урока: reason.defect у первой причины это наш экземпляр BadRoute.
Смысл правила такой. Ожидаемую ошибку обрабатывают как обычную часть ночи: пропустили кадр, отвергли, пошли дальше. Но если в том же провале сломалась программа, отвечать «кадр отвергнут» и идти дальше нельзя: обработчик ошибки дела заслонил бы поломку. Поэтому весь провал считается поломкой.
То же правило работает в операторах, которые ловят ошибки. В документации catchTag сказано прямо: если в причине есть дефект, обработчик не зовётся, а все ожидаемые ошибки становятся дефектами. Так же ведут себя catch и orDie. А прерывание рядом с ошибкой ловле не мешает.
Собери причину сам и посмотри, кто её поймает. Флажки кладут причины в reasons по порядку, таблица показывает, что сделает с этой причиной каждый оператор. Начальная причина это ловушка урока: BadRoute и дефект рядом.
Поиграй с флажками. Убери дефект, и catchTag("BadRoute") снова ловит. Оставь PhantomAck без BadRoute, и он не ловит уже по другой причине: catchTag смотрит на первую ожидаемую ошибку, а она другая. Оставь один Interrupt, и не ловит никто, кроме catchCause. Причину целиком, с любыми дефектами и прерываниями, видят только два оператора: catchCause получает весь Cause, catchDefect срабатывает, если в нём есть дефект.
Под капотом
В Гексе провал кончался одной причиной: свиток упал, муравей снял рамки, финализаторы отработали. Если бы финализатор Гекса сам упал, нам пришлось бы выбирать, какую причину оставить, и одна потерялась бы, как в finally у Ринцвальда. Effect не выбирает: он складывает причины в один Cause, и в reasons оказывается и провал работы, и провал уборки. Позже причин станет больше: в EF7 много муравьёв работают сразу, и каждый может упасть по-своему, а Cause соберёт всё, что случилось.
Порядок причин при сложении сохраняется: у ensuring сначала провал работы, потом провал уборки. Поэтому в нашем выводе BadRoute идёт первой, а журнал второй. Правило «дефект главнее» срабатывает, когда Effect складывает провал работы с провалом уборки, вот почему ensuring с упавшим финализатором отдаёт уже две Die, а не Fail и Die. Простой Cause.combine правила не применяет: флажки виджета собирают причину так же, и там Fail рядом с Die ещё лежит; превращение делает оператор, который на неё смотрит. Итог для catchTag одинаковый.
Где тут дыра
import { Effect, Exit } from 'effect';
import { handleChecked, type Tower } from './tower';
const tower: Tower = { send: async () => {}, sent: new Set([1]) };
const journal = { open: true, lines: [] as string[] };
const writeJournal = (line: string) =>
Effect.sync(() => {
if (!journal.open) throw new Error('журнал закрыт');
journal.lines.push(line);
});
const handleLogged = (line: string) => {
const logged = Effect.ensuring(handleChecked(tower, line), writeJournal(line));
return Effect.catchTag(logged, 'BadRoute', () => Effect.succeed('rejected' as const));
};
const main = async () => {
console.log(await Effect.runPromise(handleLogged('T-1->T-1->T-2|1|Ping|')));
journal.open = false;
const exit = await Effect.runPromiseExit(handleLogged('T-1->T-1->T-2|2|Ping|'));
console.log(Exit.isFailure(exit) && exit.cause.reasons.map((reason) => reason._tag));
};
void main();
rejected
[ 'Die', 'Die' ]
Пока журнал открыт, всё как задумано: кривой маршрут отвергнут. Под утро журнал закрыли, и тот же кадр уже не rejected: провал из двух дефектов. Тип handleLogged при этом не врёт: BadRoute из ошибки ушла, Effect<Outcome | 'rejected', Exclude<TowerError, BadRoute>>, и ожидаемой BadRoute наружу правда не выйдет. Выходит дефект, а дефектов в типе не бывает. Обработчик Ринцвальда написан верно, но на него нельзя опереться, пока рядом может сломаться что-то ещё.
Отсюда два вывода. Чинить надо журнал: финализатор, который падает, это поломка, и дефект показывает на неё честно. А там, где кончается обработка кадра, нужен страж, который видит причину целиком: catchDefect из прошлого урока или catchCause.
const atEdge = (line: string) =>
Effect.catchCause(handleLogged(line), (cause) =>
Effect.sync(() => {
console.error(Cause.pretty(cause));
return 'crashed' as const;
}),
);
// Effect<Outcome | 'rejected' | 'crashed'>
У стража в типе нет ни одной ошибки, и это честно: он принял на себя любую причину, записал её целиком, со всеми трассами, и ответил 'crashed'. Такой catchCause стоит ровно один раз, на краю обработки кадра. Поставь его посреди логики, и он превратится в catch {} Магистрали: поймает всё, и следующие шаги пойдут дальше, будто ничего не случилось.
logged вынесен в отдельную константу не для красоты. Если вписать Effect.ensuring(...) прямо первым аргументом Effect.catchTag, TypeScript 6 на effect@4.1.0 не может вывести типы и не собирает вызов. Длинные вложенные вызовы и так лучше разбирать на именованные шаги.
Башня, этап 6
Башне нужен итог словами: в отчёт, в журнал, Рыдкуллу утром. describeExit проходит по причинам провала и называет каждую своим словом: ожидаемую ошибку по тегу, дефект по тексту, прерывание одним словом. Ни одна причина не теряется, сколько бы их ни было.
Практика
Дальше
Башня называет каждую причину провала по имени, и утренний отчёт Рыдкулла впервые не врёт. Ринцвальд тем временем пишет пересылку кадров соседям: для этого надо найти башню в книге башен Академии по позывному.
Как «ничего» из книги превратить в ошибку с именем, а разбор ночного провода сделать таким, чтобы он не терял ни одной строки, разберём в следующем уроке.