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

Квитанция Effect<A, E, R>

middle~35 мин

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

* Первое настоящее заклинание студент Академии записывает три недели, а произносит за полминуты. Старшекурсники над этим смеются, пока не вспомнят своё первое заклинание, произнесённое сразу. От него в восточном крыле до сих пор пахнет палёным.

Распечатку Рыдкулл принёс в конце прошлого урока: отправка кадра у Магистрали, пустой catch и «успешно» после него. Весь раздел EF4 разбирает её по строчке, и первая строчка, за которую стоит взяться, даже не catch. Это тип.

Ты собрал свой Гекс и знаешь, чем свиток лучше промиса: его записывают, а исполняют потом, и в его типе видно, чем он может провалиться. В этом уроке то же самое на настоящем Effect: как выписать такую запись из обычного значения, из функции, которая бросает, и из промиса, и как её запустить. А в конце урока у башни T-1 появится первый модуль на Effect.

Что обещает промис

Вот распечатка ещё раз:

declare const relay: (frame: string) => Promise<void>;

export const send = async (frame: string): Promise<string> => {
  try {
    await relay(frame);
  } catch {}
  return 'успешно';
};

Сигнатура relay говорит одно: когда-нибудь будет void. Что провод может оборваться, что башня-получатель может молчать, что кадр могут не принять, в ней нет. Отказ у промиса есть, но у отказа нет типа: в catch (error) приходит unknown, в .catch приходит any. Компилятор не знает, чем промис провалится, и ничего не требует. Программист Магистрали получил отказ без имени и сделал с ним самое короткое, что можно сделать с безымянным: проглотил.

Хотели ли там что-то спрятать, разберёмся потом. Но сигнатура, в которой записан только успех, прятать помогает: она приучает думать только об успехе. Помнишь, как в уроке 02 Ринцвальд видел одну дорожку из всех? Магистраль видела её же, только целым ведомством.

Квитанция

На почте Академии за каждый отправленный пакет дают квитанцию. На ней три графы: что получишь, когда пакет дойдёт; по каким причинам его могут вернуть; что предъявить в окошке. Кто держит квитанцию, знает про пакет всё, хотя пакет ещё никуда не уехал.

Effect<A, E, R> это такая квитанция:

  • A это то, что запись даст при успехе;
  • E это то, чем она может провалиться, каждым способом поимённо;
  • R это то, что ей нужно для работы. В этом разделе там всегда пусто, never, а что туда кладут, расскажет EF5.

Буквы те же, что у твоего IO.IO<A, E, R>, и значения по умолчанию те же: Effect<A, E = never, R = never>. Поэтому Effect.Effect<string> читается «даст строку, провалиться не может, ничего не просит», а Effect.Effect<void, SendFailed> читается «даст void или провалится с SendFailed».

Как и у свитка Гекса, у квитанции есть второе свойство: она ничего не делает. Выписать квитанцию на отправку кадра не значит отправить кадр. Отправит его запуск, и запускать её можно сколько угодно раз, каждый раз заново. Промис так не умеет: в уроке 03 Пондер называл его обедом, который уже готовится, а квитанцию рецептом.

Пять способов выписать квитанцию

У Гекса было три конструктора: succeed, sync, fail. У Effect те же три и ещё два, для кода, который уже написан без Effect и бросает исключения или отдаёт промисы. На каждом строится кусок башни.

Effect.succeed(value) выписывает квитанцию на значение, которое уже есть. Позывной башни не меняется никогда:

import { Effect } from 'effect';

export const callSign: Effect.Effect<string> = Effect.succeed('T-1');

Effect.sync(thunk) выписывает квитанцию на вычисление, которое сделают при запуске. Функцию позовут при каждом запуске, а не тогда, когда квитанцию выписали. Разница видна на счётчике:

let calls = 0;
const frozen = Effect.succeed(++calls);
const fresh = Effect.sync(() => ++calls);

console.log(
  calls,
  Effect.runSync(frozen),
  Effect.runSync(frozen),
  Effect.runSync(fresh),
  Effect.runSync(fresh),
);
1 1 1 2 3

++calls в succeed посчитался один раз, в строке, где квитанцию выписали, и дальше она отдаёт ту же единицу. Стрелку в sync позвали при каждом запуске. Для башни это вопрос часов: отметка времени, выписанная через Effect.succeed(Date.now()), навсегда покажет минуту, в которую её выписали. Поэтому часы приходят функцией и зовутся в sync.

Effect.fail(error) выписывает квитанцию, которая проваливается с известной ошибкой. Её тип Effect<never, E>: успеха у неё нет, поэтому на месте A пусто.

Ошибки башни это классы, и пишутся они так:

import { Data } from 'effect';

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

Data.TaggedError('BadPayload') делает класс ошибки с полем _tag: 'BadPayload', а в угловых скобках перечислены остальные поля. Это размеченное объединение из урока 07, только каждый вариант теперь отдельный класс. Почему классы и что ещё они умеют, разберём в уроке 39, пока хватит того, что у каждой ошибки есть имя.

Effect.try({ try, catch }) выписывает квитанцию на синхронный код, который бросает исключения. Текст кадра на проводе закодирован, как в адресной строке, и decodeURIComponent бросает URIError на битой последовательности:

import { Effect } from 'effect';

import { BadPayload } from './errors';

export const decodePayload = (payload: string): Effect.Effect<string, BadPayload> =>
  Effect.try({
    try: () => decodeURIComponent(payload),
    catch: () => new BadPayload({ payload }),
  });

try это код, который может бросить, catch переводит брошенное в ошибку с именем. В типе E стоит то, что вернул catch. Без catch, в форме Effect.try(() => ...), ошибка тоже будет, но безымянная: Cause.UnknownError. Башня так не делает, у каждого её провала имя.

А если бросит код внутри обычного Effect.sync? Тогда это не ошибка дела, а поломка, как Die у Гекса из урока 31: в E её нет, и ловить её привычными средствами нельзя. Об этой границе урок 40.

Effect.tryPromise({ try, catch }) то же для промисов. Отказ промиса становится ошибкой из catch:

class RelayFailed extends Data.TaggedError('RelayFailed')<{ readonly frame: string }> {}

const relayReceipt = (frame: string): Effect.Effect<void, RelayFailed> =>
  Effect.tryPromise({
    try: () => relay(frame),
    catch: () => new RelayFailed({ frame }),
  });

Посмотри на try: там стрелка () => relay(frame), а не промис relay(frame). tryPromise получает рецепт и зовёт его при каждом запуске. Отдай ему готовый промис, и квитанция будет на пакет, который уже уехал.

Это и есть ответ на вопрос Рыдкулла. relay у Магистрали отдаёт Promise<void>, отказ без имени. Тот же провод в tryPromise превращается в Effect<void, RelayFailed>, и отказ записан в квитанции. Проглотить его теперь можно только нарочно, и в типе это будет видно.

Квитанция из квитанций

Квитанции складываются так же, как свитки Гекса. Effect.flatMap выписывает квитанцию «сделай это, потом то, что зависит от результата», и провалы обоих шагов собираются объединением:

const relayText = (payload: string) =>
  Effect.flatMap(decodePayload(payload), (text) => relayReceipt(text));
// Effect.Effect<void, BadPayload | RelayFailed>

Длинные цепочки так писать тяжело, как и в Гексе; следующий урок это починит. А пока посмотри на чужую квитанцию из нескольких шагов: проба адреса через fetch. Шагай по ней и смотри, как с каждым шагом растёт E и как первая же ошибка обрывает всё остальное. Подсказки виджета ссылаются на уроки старого курса Effect: проверку данных схемой мы встретим позже, а сервисы в разделе EF5.

Запуск

Квитанцию исполняет рантайм Effect, и способов попросить его четыре:

ФункцияЧто отдаётЕсли провал
Effect.runSync(effect)Aбросает ошибку
Effect.runSyncExit(effect)Exit<A, E>провал внутри Exit
Effect.runPromise(effect)Promise<A>промис отвергнут ошибкой
Effect.runPromiseExit(effect)Promise<Exit<A, E>>провал внутри Exit

Exit ты знаешь по Гексу: итог исполнения, Success со значением или Failure с причиной. Вот он на живой квитанции:

console.log(Effect.runSyncExit(decodePayload('%D0%B3%D1%80%D0%BE%D0%B7%D0%B0')));
console.log(Effect.runSyncExit(decodePayload('%E0%A4%A')));
{ _id: 'Exit', _tag: 'Success', value: 'гроза' }
{
  _id: 'Exit',
  _tag: 'Failure',
  cause: { _id: 'Cause', reasons: [ [Object] ] }
}

В причине лежит массив reasons, и внутри него наш BadPayload. Почему причин бывает несколько и как их читать, разберём в уроке 41. runSync на той же квитанции бросил бы сам BadPayload, и его можно поймать try/catch. Но тогда ты снова в мире без типов: в catch придёт unknown.

Синхронные запуски не умеют ждать. Effect.runSync на квитанции из tryPromise отвечает поломкой:

AsyncFiberError: An asynchronous Effect was executed with Effect.runSync

У Гекса на этом месте был дефект «свиток ждёт ответа: запускай его через runPromise». Мысль та же, слова другие.

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

Перевод с Ук

Хорошо, квитанция честная. А если очень хочется, чтобы она не падала? Объявим тип без ошибки:

import { Effect } from 'effect';

import { sendLine } from './tower';

declare const send: (line: string) => Promise<void>;

const quiet: Effect.Effect<void> = sendLine(send, 'T-1->T-3|1|Ping|');
receipt.ts(7,7): error TS2375: Type 'Effect<void, SendFailed, never>' is not assignable to type 'Effect<void, never, never>' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the types of the target's properties.
  Type 'SendFailed' is not assignable to type 'never'.

Строка 7, колонка 7: имя quiet. Первая строка ошибки длинная, и половина её мимо: совет добавить undefined компилятор даёт, потому что у шага Т включён флаг exactOptionalPropertyTypes, а необязательные поля тут ни при чём. Библиотекарь прав, читаем снизу. 'SendFailed' is not assignable to type 'never': на месте E у quiet стоит never, «провалиться не может», а sendLine проваливается с SendFailed. Компилятор отказывается забыть провал. Ровно то, что Магистраль сделала пустым catch, здесь сделать молча нельзя.

Если провал правда нужно разобрать, его разбирают явно: Effect.catch, Effect.catchTag и их родня из урока 39. Тогда E станет never честно, потому что каждый провал обработан, и это будет видно в коде.

Под капотом

Effect.try это то, что ты писал бы в Гексе как sync плюс перевод исключения. В Гексе исключение внутри sync становилось Die, и catch его не трогал. Effect.try ловит исключение сам, внутри своей инструкции, и вместо Die кладёт в причину Fail с ошибкой из своего catch. Поэтому ошибка попадает в E, а у Effect.sync не попадает.

Effect.tryPromise это колокольчик из урока 33: муравей зовёт рецепт, получает промис и уходит от трубки, а then и catch промиса звонят в колокольчик успехом или ошибкой. Отличие одно: настоящий Effect даёт рецепту ещё и AbortSignal, чтобы ожидание можно было отменить. Нашему проводу он пока не нужен.

runPromise это наш runPromise: запустить цикл и отдать промис, который разрешится, когда стопка опустеет. А runSync у Effect отличается от нашего мелочью: наш бросал Error('свиток упал') с причиной в cause, а Effect бросает саму ошибку.

Где тут дыра

const decodeNow = (payload: string): Promise<string> => Effect.runPromise(decodePayload(payload));

const night = async (payloads: readonly string[]): Promise<number> => {
  const texts = await Promise.all(payloads.map(decodeNow));
  return texts.length;
};

const main = async () => {
  console.log(await night(['гроза', 'мост']));
  try {
    console.log(await night(['гроза', '%E0%A4%A', 'мост']));
  } catch (error) {
    console.log('ночь упала:', error);
  }
};
void main();
2
ночь упала: { payload: '%E0%A4%A', _tag: 'BadPayload' }

На двух чистых кадрах ответ 2. На соседнем примере, где один кадр битый, падает вся ночь, и тип Promise<number> об этом не предупреждал. Квитанция decodePayload знала про BadPayload, а runPromise посреди кода превратил её обратно в промис, и знание пропало. Следующий, кто напишет обработку, получит в catch то же, что получил программист Магистрали:

const report = async (): Promise<string> => {
  try {
    return `кадров: ${await night(['гроза', '%E0%A4%A', 'мост'])}`;
  } catch (error) {
    return `упал кадр: ${error._tag}`;
  }
};
hole.ts(5,26): error TS18046: 'error' is of type 'unknown'.

А от unknown до пустого catch {} один шаг. Чинится тем, что квитанция остаётся квитанцией до самого края программы: Effect.all собирает квитанции массива в одну, и ошибка остаётся в типе.

const nightReceipt = (payloads: readonly string[]) =>
  Effect.map(Effect.all(payloads.map(decodePayload)), (texts) => texts.length);
// Effect.Effect<number, BadPayload>

Запускают её один раз, там, где программа встречается с миром: Effect.runPromise(nightReceipt(payloads)). Это край мира из урока 30, и правило то же, что в подвале: квитанции пишут где угодно, а запускают у края. Как подружить край с колбэками и старым проводом, покажет урок 45.

Башня, этап 1

С этого урока у башни T-1 своё изделие на настоящем Effect, bashnya. Оно будет расти до раздела EF8, а пока в нём три модуля: ошибки, кадры и сама башня. На первом этапе башня получает позывной, отметку времени по часам при каждом запуске, разбор текста кадра и отправку строки в провод, у которой отказ записан в квитанции. Точные сигнатуры и тексты, на которых ловят тесты, в заказе Рыдкулла.

Практика

Дальше

Рыдкулл доволен: квитанции башни честные, и ни один провал не спрятан. Ринцвальд заглядывает через плечо, видит Effect.flatMap с вложенной стрелкой и морщится.

Как писать квитанцию сверху вниз, как обычный async-код, и почему gen и pipe это один и тот же flatMap, разберём в следующем уроке.