За три урока котёл обзавёлся двумя коробками. В уроке 24 Option научился быть пустым и не открываться зря, в уроке 25 Result пустил ошибку по второму пути, в уроке 26 ошибки научились копиться. У обеих коробок есть map и flatMap, и написаны они почти одинаково. У массива, с которым ты работаешь каждый день, тоже есть map и flatMap.
Матушка спрашивает не про имя, а про доверие. Тест с тремя примерами говорит, что map верен на трёх примерах. Этот урок о том, какие обещания должен держать честный map, как эти обещания записать и как заставить компьютер проверить их на сотнях примеров, которых ты не придумал. Имя узору дадим в конце, когда станет понятно, что за ним стоит.
Три коробки, один узор
Положи рядом три сигнатуры:
import type * as Option from './Option';
import type * as Result from './Result';
type OptionMap = <A, B>(self: Option.Option<A>, f: (a: A) => B) => Option.Option<B>;
type ResultMap = <A, E, B>(self: Result.Result<A, E>, f: (a: A) => B) => Result.Result<B, E>;
type ArrayMap = <A, B>(self: readonly A[], f: (a: A) => B) => readonly B[];
Читай их, как учил Библиотекарь в уроке 09: что приходит, что уходит, что остаётся. Приходит коробка с A внутри и функция из A в B. Уходит та же коробка, но уже с B. Коробка не меняется: Option остаётся Option, у Result даже ошибка E та же самая, массив остаётся массивом той же длины. Меняется только содержимое, и меняет его только f.
Если назвать коробку буквой F, все три сигнатуры станут одной: из F<A> и A => B получается F<B>. В TypeScript так прямо не написать, параметр типа не может сам быть шаблоном вроде Option. Обход есть, и мы его соберём, когда у узора появится имя. Пока хватит того, что узор виден глазами.
Честный map
Что может пойти не так у функции, которая всего лишь применяет f к содержимому? Многое. Вот map для массива, который заодно выкидывает пустые строки, «чтобы не мусорить»:
const map = <A, B>(self: readonly A[], f: (a: A) => B): readonly B[] =>
self.filter((item) => item !== '').map((item) => f(item));
console.log(map(['гроза', 'туман'], (s) => s.length));
console.log(map(['', 'гроза'], (s) => s));
[ 5, 5 ]
[ 'гроза' ]
На первом примере всё верно. На втором map с функцией, которая ничего не делает, вернул не то, что ему дали. Это и есть первое обещание честного map: если f ничего не меняет, то и map ничего не меняет.
map(box, x => x) равно box
Второе обещание про два шага подряд. Сначала прибавить единицу, потом удвоить, или сразу сделать «прибавить и удвоить» одной функцией: ответ должен совпасть.
pipe(box, map(f), map(g)) равно map(box, x => g(f(x)))
Такие обещания называют законами. Первый закон запрещает map что-то делать самому: выкидывать элементы, менять порядок, писать в журнал, превращать Some в None. Второй разрешает тебе склеивать и разрезать цепочки map как угодно, не боясь изменить ответ. Это ровно то, что ты делаешь при каждом рефакторинге.
Коробку, у которой есть map с этими двумя законами, называют функтором. Вот и первое имя: функтор это коробка с честным map. Option, Result и массив из сигнатур выше это функторы. А map с фильтром пустых строк делает массив чем-то другим, и верить ему нельзя.
Коробка в коробке
Теперь шаг, который сам может не удаться. Половина числа есть только у чётных:
import * as Option from './Option';
const half = (n: number): Option.Option<number> =>
n % 2 === 0 ? Option.some(n / 2) : Option.none();
const quarter: Option.Option<number> = Option.map(Option.some(8), half);
Перевод с Ук
boxbox.ts(6,7): error TS2322: Type 'Option<Option<number>>' is not assignable to type 'Option<number>'.
Type 'Some<Option<number>>' is not assignable to type 'Option<number>'.
Type 'Some<Option<number>>' is not assignable to type 'Some<number>'.
Type 'Option<number>' is not assignable to type 'number'.
Type 'None' is not assignable to type 'number'.
Читаем снизу вверх. Последняя строка: None не помещается в number. Строкой выше: целый Option<number> не помещается в number. Ещё выше компилятор сравнивает Some<Option<number>> с Some<number> и находит, что у первой внутри не число, а снова коробка. Верхняя строка собирает всё: справа от = стоит Option<Option<number>>, коробка в коробке. map честно положил результат half в коробку, а результат half сам был коробкой.
Для этого у тебя есть flatMap: он применяет шаг, который возвращает коробку, и не заворачивает его ответ ещё раз. Для flatMap тоже есть законы, их три. Нужен ещё конструктор, который кладёт значение в коробку без лишнего: у Option это some, у Result это succeed.
flatMap(some(a), f) равно f(a) конструктор слева ничего не добавляет
flatMap(box, some) равно box конструктор справа ничего не добавляет
pipe(box, flatMap(f), flatMap(g))
равно flatMap(box, x => flatMap(f(x), g)) скобки не важны
Первые два закона говорят, что some честный: он не прячет в коробку ничего, кроме значения. Третий говорит, что цепочку шагов можно группировать как удобно. Вынес два шага в отдельную функцию, и отвар не изменился.
Коробку с конструктором и flatMap, которые подчиняются этим трём законам, называют монадой. Вот и второе имя, то самое страшное слово. Монада это коробка с честным конструктором и flatMap без коробки в коробке. Больше в этом слове ничего нет.
Цена скобок
Третий закон обещает одинаковый ответ, но не одинаковую цену. Это видно на коробке, внутри которой лежит не значение, а рецепт действий: такую мы соберём сами в разделе про Гекс. В виджете ниже она называется IO, а flatMap по старой традиции зовётся bind. Сравни две группировки одной и той же цепочки: в левой каждый новый шаг идёт от корня по всем прошлым, в правой встаёт сверху.
PureОтвет у обеих форм один, закон это гарантирует. А шагов у левой формы растёт как квадрат длины цепочки. Раз ответ от группировки не зависит, исполнитель цепочки вправе обойти её так, как ему дешевле, а ты пишешь так, как удобнее читать. Без закона такая свобода была бы опасной. Как исполнитель обходит цепочку, увидишь, когда соберёшь свой Гекс.
Одна буква на все коробки
Начнём с того, что хочется написать:
interface Functor<F> {
readonly map: <A, B>(self: F<A>, f: (a: A) => B) => F<B>;
}
Перевод с Ук
functor.ts(2,30): error TS2315: Type 'F' is not generic.
functor.ts(2,55): error TS2315: Type 'F' is not generic.
Ошибка одна, но стоит на двух местах: колонка 30 это F<A>, колонка 55 это F<B>. «Тип F не обобщённый»: параметр типа в TypeScript пробегает только по готовым типам вроде number или Option<string>. А Option без угловых скобок это не тип, а конструктор типа, и передать его параметром нельзя. Приписывать скобки к F не к чему.
Обход такой: передавать не конструктор, а интерфейс, который его описывает. Слот Target держит то, что положат в коробку, а слот type говорит, какая коробка получится:
import * as Option from './Option';
interface TypeLambda {
readonly Target: unknown;
readonly type: unknown;
}
type Kind<F extends TypeLambda, A> = (F & { readonly Target: A })['type'];
interface OptionLambda extends TypeLambda {
readonly type: Option.Option<this['Target']>;
}
interface ArrayLambda extends TypeLambda {
readonly type: readonly this['Target'][];
}
Kind пересекает лямбду с объектом, где в Target уже лежит A, и читает type. Внутри пересечения this['Target'] смотрит на этот A, поэтому Kind<OptionLambda, number> разворачивается в Option<number>. Буква F стала обычным параметром типа: в неё передают не Option, а OptionLambda.
Теперь сигнатура из начала урока пишется один раз:
interface Functor<F extends TypeLambda> {
readonly map: <A, B>(self: Kind<F, A>, f: (a: A) => B) => Kind<F, B>;
}
const OptionFunctor: Functor<OptionLambda> = { map: Option.map };
const ArrayFunctor: Functor<ArrayLambda> = { map: (self, f) => self.map((a) => f(a)) };
const label =
<F extends TypeLambda>(F: Functor<F>) =>
(box: Kind<F, number>): Kind<F, string> =>
F.map(box, (frame) => `кадр ${frame}`);
console.log(label(OptionFunctor)(Option.some(7)));
console.log(label(ArrayFunctor)([1, 2]));
{ _tag: 'Some', value: 'кадр 7' }
[ 'кадр 1', 'кадр 2' ]
label ничего не знает про Option и массив, только то, что у коробки есть map. Оба инстанса компилятор проверил без единого as: Option.map котла совпал с сигнатурой, развёрнутой для Option. У Result параметров два, и ошибку лямбда берёт своим параметром: ResultLambda<E> с type: Result.Result<this['Target'], E>.
Возможность абстрагироваться над конструктором типа называют HKT. В Effect она устроена так же: effect/HKT экспортирует TypeLambda и Kind, только слотов у лямбды четыре, чтобы поместились ещё ошибка и требования. Ссылка на разбор в конце урока.
А на кухне?
Затем, что функция над типами нужна не только коробкам. Вот задача из журнала башни. Смену открывают и закрывают, и в сводке обе отметки обязаны говорить про одну и ту же смену:
type Shift = 'утро' | 'день' | 'ночь';
type Watch<S> = readonly [opened: S, closed: S];
declare const shift: Shift;
const watch = [shift, shift] as const;
// readonly [Shift, Shift]
Обе отметки взяты из одной переменной, но тип кортежа помнит каждый слот отдельно. readonly [Shift, Shift] пускает и ['утро', 'ночь']: смену открыло утро, а закрыла ночь. Нужен не кортеж из объединений, а объединение кортежей: Watch<'утро'> | Watch<'день'> | Watch<'ночь'>.
Раскладывать объединение по членам умеет условный тип из урока 11: S extends unknown ? Watch<S> : never. Его можно вписать в возвращаемый тип одной функции, но тогда в каждой такой функции появятся свой условный тип и свой каст. Отличается в них только конструктор Watch. Значит, его можно передать параметром, той же лямбдой, только над значением:
interface Lambda {
readonly In: unknown;
readonly Out: unknown;
}
type Apply<L extends Lambda, V> = (L & { readonly In: V })['Out'];
type Distribute<L extends Lambda, V> = V extends unknown ? Apply<L, V> : never;
const distribute =
<L extends Lambda>(make: (value: L['In']) => Apply<L, L['In']>) =>
<V extends L['In']>(value: V) =>
make(value) as Distribute<L, V>;
Apply это Kind с другими именами слотов, а Distribute разносит его по членам объединения. distribute пишется один раз на весь котёл, и каст живёт только в нём. Для смены нужна одна лямбда:
interface AsWatch extends Lambda {
readonly In: Shift;
readonly Out: Watch<this['In']>;
}
const watchOf = distribute<AsWatch>((s) => [s, s] as const);
const honest = watchOf(shift);
// Watch<'утро'> | Watch<'день'> | Watch<'ночь'>
Теперь ['утро', 'ночь'] в honest не положить, а watchOf('полдник') не скомпилируется: допустимый вход лямбда держит в In. Сам make компилятор сверяет только с Watch<Shift>, где член уже не виден, поэтому стрелка (s) => [s, 'ночь'] as const тоже пройдёт. Связь слотов ты записал один раз, в AsWatch, и держишь её сам, как держишь законы map.
Это не учебная выдумка. В Effect такая лямбда называется Struct.Lambda, а в демо next-midi-demo из репозитория effect-garden тем же приёмом оба элемента перехода между фрагментами воспроизведения строятся из одного объединения таймингов и не путаются друг с другом. Библиотек можно и не писать, а функция над типами всё равно пригодится.
Четвёртая коробка: журнал
Это и есть четвёртая коробка котла. В ней лежит значение и список заметок о том, как его посчитали:
import { pipe } from './Function';
import * as Logged from './Logged';
const verdict = pipe(
Logged.make(3, 'без ответа: 3 кадра'),
Logged.flatMap((waiting) => Logged.make(waiting > 2, 'порог: больше двух')),
Logged.map((alarm) => (alarm ? 'тревога' : 'тихо')),
);
console.log(verdict);
{
value: 'тревога',
log: [ 'без ответа: 3 кадра', 'порог: больше двух' ]
}
map меняет значение и не трогает заметки. flatMap берёт следующий шаг вместе с его заметками и дописывает их после заметок первого шага. Никакой консоли: вычисление остаётся вычислением из урока 17, а журнал стал частью ответа. Честный ли тут map и честный ли flatMap? По трём примерам не скажешь. По законам скажешь.
Законы как тесты
Закон говорит «для любых значений». Тест с примерами проверяет три значения. Между ними пропасть, и закрывает её тест на свойство: ты описываешь, какие бывают входы, а библиотека сама придумывает их сотнями. В JavaScript для этого есть fast-check. Вот все законы для Option из этапа 8:
import fc from 'fast-check';
import { expect, it } from 'vitest';
import { pipe } from './src/Function';
import * as Option from './src/Option';
const anyBox = fc.option(fc.integer(), { nil: undefined }).map(Option.fromNullable);
const inc = (n: number) => n + 1;
const double = (n: number) => n * 2;
const half = (n: number) => (n % 2 === 0 ? Option.some(n / 2) : Option.none());
const positive = (n: number) => (n > 0 ? Option.some(n) : Option.none());
it('map(x => x) ничего не меняет', () => {
fc.assert(
fc.property(anyBox, (box) => {
expect(Option.map(box, (x) => x)).toEqual(box);
}),
);
});
it('два map подряд равны одному map с композицией', () => {
fc.assert(
fc.property(anyBox, (box) => {
expect(pipe(box, Option.map(inc), Option.map(double))).toEqual(
Option.map(box, (x) => double(inc(x))),
);
}),
);
});
it('some слева ничего не добавляет', () => {
fc.assert(
fc.property(fc.integer(), (n) => {
expect(Option.flatMap(Option.some(n), half)).toEqual(half(n));
}),
);
});
it('some справа ничего не добавляет', () => {
fc.assert(
fc.property(anyBox, (box) => {
expect(Option.flatMap(box, Option.some)).toEqual(box);
}),
);
});
it('скобки в цепочке flatMap не важны', () => {
fc.assert(
fc.property(anyBox, (box) => {
expect(pipe(box, Option.flatMap(half), Option.flatMap(positive))).toEqual(
Option.flatMap(box, (x) => Option.flatMap(half(x), positive)),
);
}),
);
});
fc.integer() это источник случайных целых чисел, в fast-check такой источник зовётся arbitrary. fc.option(..., { nil: undefined }) иногда вместо числа даёт undefined, а .map(Option.fromNullable) превращает всё это в коробки: и полные, и пустые. Заметь, у источника тоже есть map, и он тоже честный. fc.property связывает источник с проверкой, fc.assert прогоняет проверку на сотне входов и падает на первом, где она не выполнилась.
Тело проверки стоит в фигурных скобках не случайно. fast-check считает свойство выполненным, если проверка вернула true или ничего. Стрелка без скобок вернула бы то, что вернул expect(...).toEqual(...), и в настоящем vitest это не undefined.
Скрытые тесты этапа 11 проверяют те же законы на Option и Result из этапов 8 и 9 и на новой коробке Logged. Если map котла нечестный, ты узнаешь об этом не от Матушки, а от контрпримера.
Под капотом
const f = (frame: number) => Promise.resolve(frame + 1);
const g = (value: unknown) => typeof value;
const main = async () => {
const twoSteps = await Promise.resolve(40).then(f).then(g);
const oneStep = await Promise.resolve(40).then((frame) => g(f(frame)));
console.log(twoSteps, oneStep);
};
void main();
number object
Два шага подряд дали 'number', один шаг с композицией дал 'object'. Закон нарушен, и вот почему. Когда then получает от функции промис, он не кладёт его в промис, а ждёт и разворачивает. Promise<Promise<number>> в JavaScript не бывает. Поэтому then работает то как map, то как flatMap, смотря что вернула функция, и решает это в рантайме по наличию метода then у результата. В первой строке f вернула промис, и следующий then получил уже число. Во второй промис из f попал в g как есть.
Ничего страшного, пока ты об этом помнишь. Но рефакторинг «склей два then в один» у промиса меняет ответ, а у честного функтора нет. В Effect, как и в котле, map и flatMap это разные функции, и решать за тебя, какая из них нужна, никто не станет.
Где тут дыра
const map = <A, B>(self: Logged.Logged<A>, f: (a: A) => B): Logged.Logged<B> => ({
value: f(self.value),
log: [...self.log, `посчитали ${String(self.value)}`],
});
console.log(map(Logged.make(2, 'кадр 2 отправлен'), (n) => n * 10));
{ value: 20, log: [ 'кадр 2 отправлен', 'посчитали 2' ] }
На его примере всё выглядит прилично. Прогони первый закон:
Error: Property failed after 1 tests
{ seed: 20261020, path: "0:0:0", endOnFailure: true }
Counterexample: [{"value":0,"log":[]}]
Shrunk 2 time(s)
Caused by: AssertionError: expected { value: +0, log: [ 'посчитали 0' ] } to deeply equal { value: +0, log: [] }
fast-check упал на первом же входе, а потом дважды упростил его, пока не нашёл самый маленький: значение 0 и пустой журнал. map с функцией, которая ничего не делает, дописал заметку. Это не мелочь. Теперь журнал зависит от того, как код разбит на шаги: склеишь два map в один, и заметок станет меньше, хотя посчитано то же самое. Смотритель, который сравнивает два журнала, увидит разницу там, где её нет. Журнал должен рассказывать о том, что посчитали, а не о том, как программист нарезал функции.
Больше значит громче. Заметки пишет тот шаг, которому есть что сказать, через make внутри flatMap. map молчит.
Котёл, этап 11
Котёл учится записывать заметки рядом с ответом. Ты напишешь модуль Logged с конструкторами of и make и честными map и flatMap, а в журнале башни функцию explain: сводку смены с заметкой на каждый кадр без ответа и на каждый призрак. Законы функтора и монады проверит fast-check, и не только для Logged: для Option и Result тоже.
Практика
Глубже в архиве
Те же законы в языке, где они живут в системе типов, разбирают уроки «Функтор» и «Монада» курса Haskell. Лямбду Effect на четыре слота и одну функцию сразу над Effect и Stream разбирает урок «HKT и TypeLambda». Тесты на свойства шире, чем законы, и о них урок «Тесты на свойства и снимки».
Дальше
Матушка кивает: имена так имена, раз за ними стоят законы. Пондер тем временем выписал на доску разбор строки журнала из урока 25 и отступил на шаг.
Как записать цепочку flatMap сверху вниз, как обычный код, и не потерять ни одной пустой коробки, разберём в следующем уроке.