В прошлом уроке условия Рыдкулла сложились из маленьких функций. Маленьких функций в котле теперь много, и каждая делает одно дело: record дописывает запись, unconfirmed выбирает кадры без ответа, shiftReport пишет сводку. Чтобы получить ответ, их зовут одну за другой, и вложенные вызовы читаются изнутри наружу.
Этот урок о том, как выстроить вызовы в порядке, в каком они случаются, и не потерять типы. Ты напишешь для котла pipe и flow, те самые, с которых начинается почти любой код на Effect.
Рецепт с конца
Вот сводка, на которую показала Матушка:
const waiting = `ждут ответа: ${unconfirmed(record(record(journal, retry), ack)).length}`;
ждут ответа: 2
Чтобы понять, что происходит, глаз ищет самую глубокую скобку: record(journal, retry). Потом выходит на уровень вверх ко второму record, потом к unconfirmed, потом к .length и в конце к шаблонной строке. Порядок исполнения обратный порядку чтения. А аргумент ack стоит далеко от своего record, через целый вызов.
Можно разложить на константы:
const withRetry = record(journal, retry);
const withAck = record(withRetry, ack);
const frames = unconfirmed(withAck);
const waiting = `ждут ответа: ${frames.length}`;
Читается сверху вниз, и это честный способ. Но каждой промежуточной ступени пришлось придумать имя, хотя нужна она ровно в следующей строке. И ничто не мешает через месяц написать unconfirmed(withRetry) вместо unconfirmed(withAck): оба имени живут до конца функции, и компилятор обоим рад.
Конвейер
Котлу нужна запись, где значение идёт по шагам сверху вниз, а имена ступеням не нужны:
import { pipe } from './Function';
import { record, unconfirmed } from './journal';
const waiting = pipe(
journal,
(j) => record(j, retry),
(j) => record(j, ack),
unconfirmed,
(frames) => `ждут ответа: ${frames.length}`,
);
pipe берёт значение и цепочку функций с одним аргументом. Первая функция получает значение, вторая получает результат первой, и так до конца. Шаги стоят в том порядке, в каком исполняются. Промежуточных имён нет, перепутать ступени нечем.
Это композиция: из функций «сначала f» и «потом g» получается функция «сначала f, потом g», то есть (x) => g(f(x)). pipe сразу применяет такую сборку к значению. Реализация занимает одну строку, ты видел её в уроке 09:
fns.reduce((value, fn) => fn(value), a);
Вся работа в типах. Там же, в уроке 09, ты читал три перегрузки pipe и видел, как стрелки получают типы параметров слева направо: A выводится из значения, B из первой стрелки, C из второй. В котле лестница длиннее: значение и до шести функций, семь перегрузок.
Обрати внимание на шаги конвейера. unconfirmed стоит в нём по имени, без стрелки, потому что принимает ровно один аргумент. А record принимает два, журнал и запись, поэтому для него пришлось написать переходник (j) => record(j, retry). Запомни эти переходники, к ним вернёмся в следующем уроке.
Конвейер на других данных можно собрать и разобрать руками. Ниже погодные записи и три вида блоков: filter, map, reduce. Собери цепочку и посмотри, в каком виде записи доезжают до последнего шага.
?
Палитра кладёт в pipe filter / map / reduce. На каждом блоке выбери
лямбду из пресета и смотри intermediate-таблицу справа: видно, какие
строки прошли filter и в какой форме доехали до reduce. Переключатель
imperative рисует тот же расчёт через for:
видно, какая версия читается без комментариев. Pipe ограничен 4
стадиями, чтобы не утонуть в монстрах.
flow: конвейер без значения
Иногда нужен не ответ, а сама сборка: функция, которую позовут позже и не раз. Для этого есть flow. Он берёт только функции и возвращает новую функцию:
import { flow } from './Function';
import { type Entry, overdue, unconfirmed } from './journal';
const waitingReport = flow(
unconfirmed,
(frames) => frames.length,
(count) => `ждут ответа: ${count}`,
);
const lateReport = flow(
(journal: readonly Entry[], now: number) => overdue(journal, now, 5000),
(frames) => frames.join(', '),
);
console.log(waitingReport(journal));
console.log(lateReport(journal, 6000));
ждут ответа: 2
1, 3
flow(f, g, h) это (...args) => h(g(f(...args))), а pipe(x, f, g, h) это flow(f, g, h)(x). Разница одна: у flow первая функция может принимать несколько аргументов, и собранная функция принимает их все. lateReport ждёт журнал и момент, как стрелка в его начале. Остальные шаги, как и в pipe, получают одно значение, результат соседа слева.
Когда что брать? pipe, когда значение уже в руках и нужен ответ здесь. flow, когда собираешь функцию, у которой будет имя и несколько вызовов. Ринцвальд пишет flow везде, потому что так короче; Матушка пишет pipe, пока функции не понадобится имя.
Перевод с Ук
Ринцвальд хочет к числу кадров без ответа приписать сводку смены и добавляет shiftReport последним шагом:
import { pipe } from './Function';
import { type Entry, shiftReport, unconfirmed } from './journal';
declare const journal: readonly Entry[];
export const report = pipe(
journal,
unconfirmed,
(frames) => frames.length,
shiftReport,
);
report.ts(9,15): error TS2322: Type 'number' is not assignable to type 'readonly Entry[]'.
Ошибка стоит не там, где Ринцвальд её ждал. Сломан шаг shiftReport: он ждёт журнал, а получает число. Но компилятор показывает на строку 9, колонку 15, на frames.length, шагом раньше.
Так выходит из-за того, как выводятся параметры типа. Четыре аргумента после значения подходят к перегрузке с A, B, C и D. Тип C встречается дважды: как то, что вернёт третья стрелка, и как то, что примет shiftReport. Компилятор собирает подсказки про C с обеих сторон и закрепляет C = readonly Entry[] по готовой функции shiftReport, у которой тип записан явно. После этого стрелка (frames) => frames.length обязана вернуть журнал, а возвращает число. Об этом и строка: «number нельзя присвоить readonly Entry[]».
Отсюда правило для ошибок в конвейере. Если Ук показывает на шаг, проверь и его соседа справа: ошибиться мог любой из двоих, и компилятор назвал того, у кого тип был выведен, а не записан. Здесь виноват shiftReport, ему в этом конвейере не место: сводке нужен весь журнал, а не число.
Под капотом
Параметры типа у pipe сцеплены: A во вход первой стрелки, её выход B во вход второй, и так дальше. У цепочки из трёх функций одна такая сцепка, у цепочки из пяти другая, и одной строкой «любой длины» её не записать так, чтобы каждая стрелка получила тип своего параметра от соседа слева. Перегрузка на каждую длину решает это прямо, ценой длинного объявления. Реализацию снаружи не видно, поэтому вызов с семью функциями в котле не соберётся, хотя reduce переварил бы и сто. Если конвейеру не хватает ступеней, это знак собрать его часть отдельным flow с именем.
У flow первая функция описана как (...args: P) => B, где P extends readonly unknown[]. Компилятор выводит P как кортеж параметров первой функции вместе с их именами: для lateReport это [journal: readonly Entry[], now: number]. Поэтому подсказка в редакторе показывает собранную функцию как (journal: readonly Entry[], now: number) => string, с теми же именами, что у стрелки в начале. Тесты котла проверяют именно такой тип.
Где тут дыра
const pipe = (a: any, ...fns: Array<(value: any) => any>) =>
fns.reduce((value, fn) => fn(value), a);
console.log(pipe(journal, unconfirmed).length);
console.log(`ждут ответа: ${pipe(journal, unconfirmed).lenght}`);
2
ждут ответа: undefined
На первой строке всё сходится: кадров без ответа два. Во второй Ринцвальд опечатался в length, и компилятор промолчал: у pipe Ринцвальда результат any, а у any есть любое поле. В сводку смены ушло «ждут ответа: undefined», и дежурный решил, что башня шутит.
Перегрузки нужны не для красоты. Каждая из них несёт тип по конвейеру от значения до результата, и опечатка, неверный шаг или забытая стрелка ловятся там, где их написали. Свой pipe на any выключает компилятор на всём конвейере сразу.
Вот куда придёшь
В Effect pipe и flow те же самые, только перегрузок у них двадцать:
import { flow, pipe } from 'effect';
const shout = flow(
(tower: string) => `${tower} молчит`,
(text) => text.toUpperCase(),
);
console.log(pipe('T-3', shout, (text) => `${text}!`));
T-3 МОЛЧИТ!
Котёл, этап 5
Котёл получает свой модуль Function, как effect/Function. Ты напишешь identity, pipe для значения и до шести функций и flow до пяти функций, перегрузками, чтобы тип шёл по конвейеру от шага к шагу. Дальше на этих двух функциях будет держаться вся мини-библиотека.
Практика
Глубже в архиве
Тот же конвейер на чистом JavaScript, без типов, с filter, map и reduce и сравнением с циклом, есть в уроке «ФП-стиль в JavaScript».
Дальше
Сводка смены читается сверху вниз. Пондер смотрит на твой конвейер и стучит карандашом по переходникам (j) => record(j, retry).
import { Option, pipe } from 'effect';
const double = (n: number) => n * 2;
const first = Option.map(Option.some(2), double);
const second = pipe(Option.some(2), Option.map(double));
С двумя аргументами Option.map считает сразу, с одним отдаёт шаг для конвейера. Как одна функция понимает, как её позвали, и почему это не магия, а подсчёт аргументов, разберём в следующем уроке.