* Первое настоящее заклинание студент Академии записывает три недели, а произносит за полминуты. Старшекурсники над этим смеются, пока не вспомнят своё первое заклинание, произнесённое сразу. От него в восточном крыле до сих пор пахнет палёным.
Распечатку Рыдкулл принёс в конце прошлого урока: отправка кадра у Магистрали, пустой 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, разберём в следующем уроке.