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

Ожидаемые ошибки

middle~35 мин

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

Такой строки нет. В уроке 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, и подтверждение того, чего башня не посылала, становятся ошибками с именами; призрак описан схемой, потому что идёт в отчёт. Чужие кадры башня пропускает по одному тегу, мусор с провода отвергает массивом из четырёх, а всё остальное остаётся в типе.

Практика

Дальше

Ночью башня работает на новом обработчике. Чужие кадры пропущены, мусор отвергнут, призраки записаны по номерам. Под утро в журнале одна строка без тега.

Тега нет, и в типе его нет. Почему ошибка, которой не может быть, всё-таки случилась, и что башне с ней делать, разберём в следующем уроке.