Такой строки нет. В уроке 36 мы видели, что у промиса в типе нет места для ошибки: Promise<void> обещает только успех. Значит, и catch не знает, что ловит. Этот урок о том, как дать каждой беде башни имя, записать имена в тип и снимать их по одной, чтобы ни одна не потерялась по дороге.
Улика по строчке
declare const relay: (frame: string) => Promise<void>;
export const send = async (frame: string): Promise<string> => {
try {
await relay(frame);
} catch {}
return 'успешно';
};
Разберём, что здесь видно, а что нет.
relay: (frame: string) => Promise<void>. Сигнатура говорит: дай строку, когда-нибудь получишь ничего. Какrelayможет упасть, в ней не сказано. Провод может оборваться, соседняя башня может не ответить, а в самойrelayможет сидеть опечатка, из-за которой она бросаетTypeError. Для типа это всё одно и то же: ничего.catch {}ловит всё, что вылетело изtry, и даже не даёт на это посмотреть: у пустогоcatchнет переменной.return 'успешно'исполняется после любого исхода. Отчёт зелёный, потому что код не умеет быть другим.
Ринцвальд посмотрел распечатку и сказал, что у него так не бывает: он хотя бы смотрит, что поймал.
class WireDown extends Error {}
declare const relay: (frame: string) => Promise<void>;
export const send = async (frame: string): Promise<string> => {
try {
await relay(frame);
return 'успешно';
} catch (error) {
if (error instanceof WireDown) return 'повторить';
return 'отвергнут';
}
};
Лучше, но ненамного. error здесь unknown: TypeScript честно не знает, что прилетит, потому что бросить можно что угодно. Список instanceof Ринцвальд пишет по памяти. Если завтра relay научится падать ещё одной ошибкой, ни одна строка в городе не покраснеет: новая беда тихо уйдёт в «отвергнут». А опечатка внутри relay тоже станет «отвергнутым кадром», хотя кадр ни при чём, сломан код.
Нужны две вещи. У каждой беды должно быть имя. И имена должны стоять в типе, чтобы список помнил компилятор, а не Ринцвальд.
Ошибка с именем
С урока 36 у башни уже есть ошибки-классы: BadPayload, SendFailed, потом GarbledFrame и BadRoute. Тогда мы сказали про них одну фразу: класс с тегом. Теперь разберём, что это за класс.
import { Data } from 'effect';
export class NotForUs extends Data.TaggedError('NotForUs')<{ readonly to: string }> {}
const error = new NotForUs({ to: 'T-2' });
console.log(error._tag, error.to, error instanceof Error);
NotForUs T-2 true
Data.TaggedError('NotForUs') это функция, которая строит базовый класс. Строка в скобках становится полем _tag с литеральным типом 'NotForUs', а не string. Это размеченное объединение из урока 07: по _tag компилятор отличает одну ошибку от другой. В угловых скобках поля ошибки: конструктор принимает их одним объектом и кладёт в экземпляр только для чтения. А сам экземпляр это настоящий Error, со стеком вызовов, так что в логе он читается как любая ошибка JavaScript.
Такую ошибку Effect называет ожидаемой. Кадр не нам: это не поломка башни, а обычная часть ночи на Магистрали. Тот, кто позвал обработчик, решает, что с ней делать: пропустить кадр, переслать дальше, записать в отчёт.
Экземпляр такого класса сам отдаётся в yield*, как эффект, который сразу падает этой ошибкой:
export const checkRoute = (frame: Frame) =>
Effect.gen(function* () {
if (frame.to !== 'T-1') return yield* new NotForUs({ to: frame.to });
return frame;
});
return перед yield* здесь ради читателя и ради компилятора: после этой строки рецепт дальше не идёт, и return говорит об этом прямо. Тип checkRoute компилятор выводит сам: Effect<Frame, NotForUs>. Имя ошибки попало в квитанцию.
Ошибка для отчёта
У призрака судьба другая. Подтверждение кадра, которого башня не посылала, это след Магистрали, и Рыдкулл хочет видеть каждый такой след в утреннем отчёте Академии. Отчёт уходит в JSON, значит, ошибку надо уметь превратить в простой объект и прочитать обратно. Для этого есть второй способ описать класс, через схему:
import { Schema } from 'effect';
export class PhantomAck extends Schema.TaggedError<PhantomAck>()('PhantomAck', {
of: Schema.Number,
}) {}
console.log(Schema.encodeSync(PhantomAck)(new PhantomAck({ of: 40 })));
{ _tag: 'PhantomAck', of: 40 }
Вызова два, и это не опечатка. Первый, Schema.TaggedError<PhantomAck>(), получает сам класс в параметре типа, чтобы схема знала, экземпляры чего она строит. Второй получает тег и схемы полей. Снаружи PhantomAck такой же класс с _tag, полем of и стеком, только знает больше: Schema.encodeSync превращает его в простой объект для отчёта, Schema.decodeUnknownSync собирает экземпляр обратно, а конструктор проверяет поле по схеме и бросит, если вместо числа подсунуть строку.
Какой способ выбрать? Data.TaggedError для ошибок, которые живут внутри программы. Schema.TaggedError для тех, что пересекают её границу: уходят в отчёт, в ответ по сети, в очередь. Схемы целиком будут в EF9, сейчас хватит того, что призрак умеет кодироваться.
Снимать по одной
Ошибки в типе складываются объединением, как нужды в Сундуке из урока 32. Обработчик, который разбирает кадр, проверяет и отвечает, падает по-разному, и тип это знает: Effect<Outcome, NotForUs | PhantomAck | SendFailed>. Назовём такой эффект handled и начнём снимать ошибки.
const skipped = Effect.catchTag(handled, 'NotForUs', (error) =>
Effect.succeed(`не нам, а ${error.to}`),
);
// Effect<Outcome | string, PhantomAck | SendFailed>
Effect.catchTag ловит ошибку с одним тегом. Обработчик получает NotForUs, а не всё объединение, поэтому error.to доступно без проверок. А главное в типе результата: NotForUs из него ушла. Остались ошибки, которые никто не обработал. Это и есть ответ Рыдкуллу: что поймано, а что нет, стоит в типе, и это можно прочитать.
Если несколько тегов обрабатываются одинаково, их передают массивом:
const quiet = Effect.catchTag(handled, ['NotForUs', 'PhantomAck'], (error) =>
Effect.succeed(`пропущен: ${error._tag}`),
);
// Effect<Outcome | string, SendFailed>
Обработчик получает объединение NotForUs | PhantomAck, и из типа уходят оба тега. Если у каждого тега свой ответ, есть Effect.catchTags с объектом, где ключ это тег, а значение обработчик:
const each = Effect.catchTags(handled, {
NotForUs: (error) => Effect.succeed(`не нам, а ${error.to}`),
PhantomAck: (error) => Effect.succeed(`призрак на кадр ${error.of}`),
});
// Effect<Outcome | string, SendFailed>
Иногда ошибку надо не обработать, а переназвать: снаружи модуля подробности разбора не нужны, хватит одной ошибки «кадр отвергнут». Это Effect.mapError, он меняет ошибку и ничего не ловит:
class Rejected extends Data.TaggedError('Rejected')<{ readonly why: string }> {}
const rejected = Effect.mapError(handled, (error) => new Rejected({ why: error._tag }));
// Effect<Outcome, Rejected>
И самый широкий, Effect.catch: он ловит любую ожидаемую ошибку. Обработчик получает всё объединение, а в типе результата ошибок не остаётся, если обработчик сам не падает. К нему мы ещё вернёмся.
Перевод с Ук
Ринцвальд торопится пропустить чужие кадры:
import { Data, Effect } from 'effect';
class NotForUs extends Data.TaggedError('NotForUs')<{ readonly to: string }> {}
class SendFailed extends Data.TaggedError('SendFailed')<{ readonly reason: string }> {}
declare const handled: Effect.Effect<string, NotForUs | SendFailed>;
export const skipped = Effect.catchTag(handled, 'NotForUz', () => Effect.succeed('skipped'));
tower.ts(8,31): error TS2769: No overload matches this call.
Overload 1 of 2, '(k: readonly [never, ...never[]], f: (e: never) => Effect<unknown, unknown, unknown>, orElse?: ((e: unknown) => Effect<string, never, never>) | undefined): <A, R>(self: Effect<A, unknown, R>) => Effect<unknown, unknown, unknown>', gave the following error.
Argument of type 'Effect<string, NotForUs | SendFailed, never>' is not assignable to parameter of type 'readonly [never, ...never[]]'.
Overload 2 of 2, '(self: Effect<string, NotForUs | SendFailed, never>, k: "NotForUs" | "SendFailed" | readonly ["NotForUs" | "SendFailed", ...("NotForUs" | "SendFailed")[]], f: (e: NotForUs | SendFailed) => Effect<...>, orElse?: ((e: never) => Effect<...>) | undefined): Effect<...>', gave the following error.
Argument of type '"NotForUz"' is not assignable to parameter of type '"NotForUs" | "SendFailed" | readonly ["NotForUs" | "SendFailed", ...("NotForUs" | "SendFailed")[]]'.
Ошибка длинная, потому что у catchTag два вида вызова, и компилятор отчитывается за оба. Читаем снизу. Последняя строка: аргумент "NotForUz" не подходит к параметру "NotForUs" | "SendFailed" | readonly [...]. Вот он, список тегов, которые вообще есть в ошибке этого эффекта: компилятор собрал его из E. Тега NotForUz в списке нет, ловить нечего, и вызов не собирается. Строкой выше перегрузка 2, та, где эффект идёт первым аргументом: это наш вызов. Перегрузка 1 с readonly [never, ...never[]] это вид для pipe, где эффект придёт потом; к нам она не относится.
Последний параметр, orElse, это необязательный обработчик для всех остальных ошибок. Он бывает нужен редко, но объясняет, почему в подсказке у catchTag три аргумента после эффекта.
В скорописи Ринцвальда такая опечатка прошла бы молча: instanceof NotForUz ни разу бы не сработал. Здесь опечатка в теге не собирается, а забытый тег не пропадает: он остаётся в типе и доходит до места, где ошибки обязаны кончиться.
Под капотом
type Tagged = { readonly _tag: string };
export const catchTagByHand = <A, E extends Tagged, K extends E['_tag'], B, E2>(
self: Effect.Effect<A, E>,
tag: K,
handler: (error: Extract<E, { readonly _tag: K }>) => Effect.Effect<B, E2>,
): Effect.Effect<A | B, Exclude<E, { readonly _tag: K }> | E2> =>
Effect.catch(self, (error): Effect.Effect<B, Exclude<E, { readonly _tag: K }> | E2> =>
error._tag === tag
? handler(error as Extract<E, { readonly _tag: K }>)
: Effect.fail(error as Exclude<E, { readonly _tag: K }>),
);
В рантайме это catch и одно сравнение: тег совпал, зовём обработчик; не совпал, та же ошибка падает дальше. Вся остальная работа в типе. K extends E['_tag'] не даёт передать тег, которого нет в ошибке, отсюда Ук на NotForUz. Extract выбирает из объединения ошибку для обработчика, Exclude вычитает её из результата; это условные типы из урока 11. Два приведения as стоят там, где компилятор не может сам сузить E по сравнению с параметром K. Настоящий catchTag устроен так же, только у него ещё массив тегов, вид для pipe и orElse.
Обрати внимание на сравнение: error._tag === tag. В рантайме ошибку узнают по строке, и только по ней. Это сейчас пригодится.
Два TowerTimeout
Ринцвальд пишет свою ретрансляцию: башня спрашивает соседа, и если сосед не ответил вовремя, спросить ещё раз. В модуле relay.ts у него ошибка:
import { Data } from 'effect';
/** Соседняя башня не ответила вовремя. */
export class TowerTimeout extends Data.TaggedError('TowerTimeout')<{ readonly tower: string }> {}
Через неделю он пишет чтение провода, и в модуле wire.ts заводится точно такая же строка, только с комментарием «Провод не ответил вовремя». Имя хорошее, зачем придумывать другое.
Первая беда в типах. Две ошибки с одинаковым _tag и одинаковыми полями для TypeScript совпадают: типы в нём структурные, имя класса роли не играет. Компилятор спокойно пропускает такое:
import { Effect } from 'effect';
import * as Relay from './relay';
import * as Wire from './wire';
const askNeighbour: Effect.Effect<string, Relay.TowerTimeout> = Effect.fail(
new Wire.TowerTimeout({ tower: 'провод' }),
);
Лечится тем же, чем MessageId в уроке 14: объявленным protected-полем. Класс с private или protected членом совместим только сам с собой и со своими наследниками.
export class TowerTimeout extends Data.TaggedError('TowerTimeout')<{ readonly tower: string }> {
declare protected readonly brand: never;
}
Теперь строка askNeighbour не собирается (пути к модулям сокращены):
main.ts(6,7): error TS2375: Type 'Effect<never, TowerTimeout, never>' is not assignable to type 'Effect<string, TowerTimeout, never>' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the types of the target's properties.
Type 'import("./wire").TowerTimeout' is not assignable to type 'import("./relay").TowerTimeout' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the types of the target's properties.
Property 'brand' is protected but type 'TowerTimeout' is not a class derived from 'TowerTimeout'.
Вторая беда в рантайме, и от неё бренд не спасает. Ночью свиток читает провод и спрашивает соседа, в его ошибке обе: Relay.TowerTimeout | Wire.TowerTimeout. Ринцвальд вешает повтор:
const retried = Effect.catchTag(night, 'TowerTimeout', (error) =>
Effect.succeed(`переспросить ${error.tower}`),
);
console.log(Effect.runSync(retried));
переспросить провод
Повтор задумывался для соседа, а поймал провод. catchTag сравнивает строку, а строка у обоих классов одна. Тип тоже не возразил: из ошибки ушли оба TowerTimeout, потому что Exclude вычитает по тегу. Всю ночь башня переспрашивает мёртвый провод, а в отчёте пусто.
Отсюда правило: теги ошибок уникальны в пределах программы. Бренд защищает присваивания, но не перехват. У библиотек тег начинается с имени пакета, как имена сервисов: 'bashnya/TowerTimeout'. Башня это приложение, а не библиотека, поэтому её теги без префикса, но каждый встречается один раз: у Ринцвальда должны быть RelayTimeout и WireTimeout.
Где тут дыра
import { Effect } from 'effect';
import { handleChecked, type Tower } from './tower';
const tower: Tower = { send: async () => {}, sent: new Set([1, 2]) };
const send = (line: string) =>
Effect.catch(handleChecked(tower, line), () => Effect.succeed('успешно' as const));
const main = async () => {
console.log(await Effect.runPromise(send('T-3->T-1|5|Text|гроза')));
console.log(await Effect.runPromise(send('T-7->T-1|41|Ack|40')));
console.log(await Effect.runPromise(send('мусор')));
};
void main();
acked
успешно
успешно
На хорошем кадре работает: acked. А второй кадр это призрак, подтверждение кадра 40, который башня не посылала, и send отвечает «успешно». Третий вообще не кадр, и снова «успешно». Это тот же catch {} Магистрали, слово в слово.
Effect не запрещает глотать ошибки. Он делает так, что глотание видно. В коде это одно слово catch без тега, а в типе у обработчика башни Effect<Outcome | 'успешно', never>: башня на Магистрали, которая не может упасть ни одной ошибкой, это повод остановиться и спросить, куда они делись. Честный перехват называет теги и оставляет в типе всё, что не обработал.
Башня, этап 4
Башня учится отличать ошибки дела от мусора. Кадр, адресованный не T-1, и подтверждение того, чего башня не посылала, становятся ошибками с именами; призрак описан схемой, потому что идёт в отчёт. Чужие кадры башня пропускает по одному тегу, мусор с провода отвергает массивом из четырёх, а всё остальное остаётся в типе.
Практика
Дальше
Ночью башня работает на новом обработчике. Чужие кадры пропущены, мусор отвергнут, призраки записаны по номерам. Под утро в журнале одна строка без тега.
Тега нет, и в типе его нет. Почему ошибка, которой не может быть, всё-таки случилась, и что башне с ней делать, разберём в следующем уроке.