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

Option и Result внутри Effect

middle~35 мин

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

В прошлом уроке ты вскрывал причину провала и отличал в ней ошибку дела от дефекта. Там ошибки были настоящие, с тегами. Здесь ошибки ещё нет: есть значение, которого нет. Книга башен это словарь «код башни → имя», и поиск в нём отвечает либо именем, либо undefined.

С отсутствием ты уже встречался. В уроке 24 Матушка Воскова дала тебе коробку Option, а в уроке 25 коробку Result с причиной внутри. Тогда коробки были свои, из котла. Теперь они настоящие, из effect, и этот урок о том, как они живут рядом с квитанцией Effect. В Effect 4 коробка и квитанция это разные вещи, и переход между ними пишут явно.

Книга башен

Вот как Ринцвальд ищет имя башни:

const book: ReadonlyMap<string, string> = new Map([
  ['T-1', 'Академия'],
  ['T-3', 'Мост'],
]);

const towerName = async (code: string): Promise<string> => book.get(code) as string;

export const report = async (code: string): Promise<string> =>
  `кадр от башни ${await towerName(code)}`;

console.log(await report('T-3'));
console.log(await report('T-9'));
кадр от башни Мост
кадр от башни undefined

book.get честно отвечает string | undefined: компилятор знает, что башни может не оказаться. Ринцвальд не захотел возиться с undefined и написал as string, то самое «поверь мне», от которого предостерегал урок 03. Компилятор поверил. undefined прошло через промис, через шаблонную строку и встало в отчёт словом.

Исключения никто не бросил, и ловить нечего. Это та же болезнь, что у catch {} Магистрали, только без catch: беда превратилась в значение, которое выглядит нормальным. Без as пришлось бы проверять undefined в каждом месте, где нужно имя, и рано или поздно одно место забылось бы. Матушка лечила это коробкой.

Коробка из effect

В effect есть модуль Option той же формы, что ты собирал в котле: теги Some и None, поле value. Из мира undefined в мир коробок ведёт Option.fromNullishOr, так в Effect 4 зовут твой fromNullable:

import { Option } from 'effect';

const entry: Option.Option<string> = Option.fromNullishOr(book.get('T-9'));
// { _tag: 'None' }

Отсутствие больше не похоже на имя: это пустая коробка, и открыть её, не проверив, компилятор не даст. Но башня пишет квитанции, а не коробки. Обработчик кадра это Effect.gen из урока 37, и первое, что хочется сделать, это открыть коробку прямо в рецепте через yield*:

import { Effect, Option } from 'effect';

const book: ReadonlyMap<string, string> = new Map([['T-3', 'Мост']]);

export const name = Effect.gen(function* () {
  const found = yield* Option.fromNullishOr(book.get('T-3'));
  return found;
});

Перевод с Ук

name.ts(5,32): error TS2345: Argument of type '() => Generator<Option.Option<string>, string, any>' is not assignable to parameter of type '() => Generator<Effect<any, any, any>, string, never>'.
  Call signature return types 'Generator<Option<string>, string, any>' and 'Generator<Effect<any, any, any>, string, never>' are incompatible.
    The types returned by 'next(...)' are incompatible between these types.
      Type 'IteratorResult<Option<string>, string>' is not assignable to type 'IteratorResult<Effect<any, any, any>, string>' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the types of the target's properties.
        Type 'IteratorYieldResult<Option<string>>' is not assignable to type 'IteratorYieldResult<Effect<any, any, any>>' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the types of the target's properties.
          Type 'Option<string>' is not assignable to type 'Effect<any, any, any>' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the types of the target's properties.
            Property '[TypeId]' is missing in type 'None<string>' but required in type 'Effect<any, any, any>'.

Читаем снизу, как учил урок 04. Последняя строка: у None<string> нет поля [TypeId], а у Effect оно обязательно. [TypeId] это метка, по которой TypeScript отличает квитанцию от всего остального. Строкой выше то же для целых типов: Option<string> не Effect<any, any, any>. Ещё выше компилятор объясняет, откуда он это взял: генератор отдаёт наружу то, что стоит справа от yield*, твой отдаёт Option<string>, а Effect.gen принимает только генераторы, которые отдают Effect. Самая верхняя строка называет место: аргумент Effect.gen, строка 5.

Слова про exactOptionalPropertyTypes в середине не про тебя. Компилятор сравнивал результаты итератора, у которых есть необязательные поля, и на всякий случай упомянул флаг. Беда в последней строке.

Перевод в одну фразу: «в рецепт Effect.gen отдают только квитанции, а ты отдал коробку». Почему yield* на коробке вообще пишется? Коробка итерируема, но для своего рецепта, Option.gen, такого же, как твой из урока 28. В чужой рецепт она не годится.

Из коробки в квитанцию

Мост из коробки в квитанцию это Effect.fromOption:

const named = Effect.fromOption(entry);
// Effect<string, Cause.NoSuchElementError>

Полная коробка становится успехом, пустая провалом с ошибкой NoSuchElementError. Это уже лучше undefined: провал виден в типе, и yield* его не пропустит. Но имя у ошибки общее, она не скажет, какой башни не нашлось. Отчёту Академии нужна своя ошибка, с кодом башни. Её даёт mapError из урока 39:

import { Data, Effect, Option } from 'effect';

export class UnknownTower extends Data.TaggedError('UnknownTower')<{ readonly code: string }> {}

export const lookupTower = (code: string): Effect.Effect<string, UnknownTower> =>
  Effect.mapError(
    Effect.fromOption(Option.fromNullishOr(book.get(code))),
    () => new UnknownTower({ code }),
  );

Короче то же пишется вторым аргументом: Effect.fromOption(коробка, () => new UnknownTower({ code })) сразу даёт Effect<string, UnknownTower>. Есть и путь из undefined без коробки: Effect.fromNullishOr(book.get(code)), он падает с той же NoSuchElementError.

Отчёт Ринцвальда переписывается так:

const report = (code: string) =>
  Effect.gen(function* () {
    const name = yield* lookupTower(code);
    return `кадр от башни ${name}`;
  });

console.log(Effect.runSync(report('T-3')));
console.log(Effect.runSync(Effect.flip(report('T-9'))));
кадр от башни Мост
{ code: 'T-9', _tag: 'UnknownTower' }

Effect.flip меняет успех и ошибку местами: так ошибку видно без вскрытия Exit. Тип report теперь Effect<string, UnknownTower>. Слово undefined в отчёт не попадёт, а «нет в книге» стало ошибкой с тегом и кодом. Её можно поймать catchTag, описать в отчёте и сосчитать.

С Result всё так же: Effect.fromResult превращает Success в успех квитанции, а Failure в провал с той же ошибкой. Пригодится, когда разбор уже написан на коробках, как твой журнал в котле, а дальше нужна квитанция. Вот все мосты урока на одной карточке:

ОткудаКудаЧем
A | undefinedEffect<A, NoSuchElementError>Effect.fromNullishOr
Option<A>Effect<A, NoSuchElementError>Effect.fromOption
Option<A>Effect<A, E>Effect.fromOption(коробка, () => ошибка)
Result<A, E>Effect<A, E>Effect.fromResult
Effect<A, E>Effect<Result<A, E>, never>Effect.result
Effect<A, E>Effect<Option<A>, never>Effect.option

Две последние строки ведут обратно, из квитанции в коробку. О них дальше.

Под капотом

В Effect 3 Option и Either (так раньше звали Result) были подтипами Effect. yield* на коробке внутри Effect.gen проходил, а пустая коробка становилась провалом NoSuchElementException. Писать было коротко, читать труднее: по строке yield* entry не понять, ищешь ты в книге или идёшь по проводу, и откуда в ошибках квитанции взялось «элемента нет». В Effect 4 коробки снова стали данными, а переход из данных в действие пишется словом fromOption, с ошибкой, которую ты выбрал сам.

У нас в подвале так было с самого начала. Коробка котла и свиток Гекса были разными типами, и чтобы положить одно в другое, надо было что-то написать. Коробку проверяют сразу, она ничего не делает и ничего не ждёт. Свиток исполняет Гекс, его можно запустить, повторить и отменить. Смешать их значит спрятать, где кончается расчёт и начинается действие, а различать их Матушка Воскова учила весь EF2.

Из квитанции в коробку

Бывает нужно обратное. Ночью на провод приходит сотня строк, часть из них мусор. Promise.all упал бы на первой битой строке и потерял всю ночь, поэтому Ринцвальд взял Promise.allSettled:

export const decode = async (line: string): Promise<string> => {
  if (!line.includes('|')) throw new Error(`мусор: ${line}`);
  return line;
};

const night = ['T-3->T-1|1|Ping|', 'мусор', 'T-3->T-1|2|Lost|1'];
const results = await Promise.allSettled(night.map(decode));
for (const result of results) {
  if (result.status === 'rejected') console.log(result.reason.message.toUpperCase());
}
МУСОР: МУСОР

Ночь не теряется: каждая строка отчитывается, удалась она или нет. Зато теряются типы. result.reason у отклонённого промиса это any, и компилятор пропустит reason.message.toUpperCase(), хотя никто не обещал, что там Error. Какие ошибки бывают при разборе, по этому коду не узнать.

У квитанций та же развилка. Effect.all из урока 36 собирает массив квитанций в одну, как Promise.all, и так же падает на первой ошибке. Если каждую строку сначала завернуть в Effect.result, падать будет нечему:

import { Effect } from 'effect';
import { decodeFrame } from './frame';

const night = ['T-3->T-1|1|Ping|', 'мусор', 'T-1->T-2|5|Smoke|', 'T-3->T-1|2|Lost|1'];

const strict = Effect.all(night.map((line) => decodeFrame(line)));
// Effect<Frame[], DecodeError>: первая битая строка роняет всю ночь

const settled = Effect.all(night.map((line) => Effect.result(decodeFrame(line))));
// Effect<Result<Frame, DecodeError>[], never>

Effect.result берёт квитанцию Effect<A, E> и отдаёт Effect<Result<A, E>, never>: ожидаемая ошибка переезжает из канала провала в значение, в коробку Failure. В типе это видно по never на месте ошибок: такая квитанция ожидаемой ошибкой не падает. Все четыре строки разобраны, и про каждую известно, что с ней:

[ 'Ping', 'GarbledFrame', 'UnknownTag', 'Lost' ]

Осталось разложить коробки на две стопки. Result.isSuccess это предикат с is из урока 06, и filter с ним сужает массив до успехов:

export const decodeAll = (
  lines: readonly string[],
): Effect.Effect<{ readonly frames: readonly Frame[]; readonly errors: readonly DecodeError[] }> =>
  Effect.map(Effect.all(lines.map((line) => Effect.result(decodeFrame(line)))), (results) => ({
    frames: results.filter(Result.isSuccess).map((result) => result.success),
    errors: results.filter(Result.isFailure).map((result) => result.failure),
  }));

На ночи из примера это два кадра и две ошибки, GarbledFrame и UnknownTag, обе по порядку провода. Сравни с allSettled: ни одна строка не потеряна, и у каждой ошибки тег, который знает компилятор. Это и есть разбор, который не теряет ни строки.

Effect.option делает то же, но выбрасывает причину: провал становится пустой коробкой. Это подходит, когда причина неинтересна, например «есть такой кадр или нет». Отчёту Академии так нельзя: по пустой коробке не скажешь, пришёл мусор или неизвестный тег.

И правило из урока 40: Effect.result и Effect.option превращают в значение только ожидаемые ошибки. Дефект идёт мимо них, и квитанция кончится Die, как и без них:

Effect.runSyncExit(Effect.result(Effect.die(new Error('boom'))));
// Exit.Failure, в причине Die(boom)

Где тут дыра

const towerName = (code: string): Effect.Effect<string> =>
  Effect.sync(() => Option.getOrThrow(Option.fromNullishOr(book.get(code))));

const safe = Effect.catch(towerName('T-9'), () => Effect.succeed('неизвестная башня'));

const exit = Effect.runSyncExit(safe);
if (Exit.isFailure(exit)) console.log(Cause.pretty(exit.cause));

На T-3 всё работает, и тип красивый: Effect<string>, без ошибок. На T-9 выходит вот что:

Error: getOrThrow called on a None
    at <anonymous> (Option.ts:1467:75)

catch не помог. getOrThrow бросает внутри Effect.sync, а исключение внутри sync это дефект, как в уроке 40. Тип соврал: Effect<string> обещает, что ожидаемых ошибок нет, и catch честно ничего не ловит, потому что ловить ему нечего. Дефект прошёл мимо и уронил всю квитанцию.

Отсутствие башни не из разряда «быть не может». Это обычная ночь на Магистрали, где башни строят и переименовывают. Значит, это ошибка дела, и её место в типе, с тегом. getOrThrow оставь для пустоты, которая значит, что сломан твой собственный код.

Башня, этап 7

Книга башен приходит аргументом, словарём ReadonlyMap «код → имя». Башня учится искать в ней с ошибкой UnknownTower, а не со словом undefined, и разбирать ночь целиком: кадры по порядку, ошибки по порядку, ни одна строка не пропадает. Сигнатуры и пример ночи в карточке заказа.

Практика

Дальше

Ночь разобрана, ни одна строка не потерялась, у каждой ошибки тег. Рыдкулл листает код обработчика и считает вслух.

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