fp-ts на практике: оценщик автомобилей
Идея урока: пройти путь от ручного === до полноценной CLI-игры на fp-ts,
держа всё время одно и то же доменное ядро (автомобиль с маркой, годом,
двигателем и пробегом). На каждом шаге проявляется новый typeclass: Eq,
Ord, Semigroup, Monoid, Reader. Цель не “выучить fp-ts”, а почувствовать,
что typeclass это способ зафиксировать алгебраическое свойство домена в
переиспользуемом виде.
Источник: репозиторий примеров
fp-ts-examples(Eq, Ord, Semigroup, Monoid, Reader). В лекции мы разбираем код и обсуждаем, что именно даёт переход на typeclass.
Домен
type CarEngine = 'diesel' | 'petrol' | 'electric';
type CarBrand = 'Ford' | 'Audi' | 'BMW';
type Car = {
brand: CarBrand;
year: number;
engine: CarEngine;
owner?: string;
mileage: number;
};
Сервис продажи машин. Дальше нам надо будет: сравнивать машины на эквивалентность, сортировать по “дороговизне”, склеивать профили владельцев, и в финале собрать из всего этого мини-игру.
Часть 1. Eq: эквивалентность как алгебра
Стартуем с самого простого: понятие “эти две машины одинаковые”. Без fp-ts получится так:
const isEqual = (a: Car, b: Car) =>
a.brand === b.brand &&
a.year === b.year &&
a.engine === b.engine &&
Math.abs(a.mileage - b.mileage) <= 100;
Работает. Минус один: правило равенства зашито в одну функцию. Если завтра понадобится “равны по марке и пробегу, без учёта года”, придётся писать ещё одну такую же. Каждое новое правило, новая функция, склеенная вручную.
Typeclass Eq<A> фиксирует это понятие как структуру:
interface Eq<A> {
readonly equals: (x: A, y: A) => boolean;
}
Не “функция, которая сравнивает машины”, а “значение, которое знает, как
сравнивать A”. Можно положить в переменную, передать в комбинатор, скомбинировать.
import { Eq, struct } from 'fp-ts/Eq';
import { Eq as numberEq } from 'fp-ts/number';
import { Eq as stringEq } from 'fp-ts/string';
const eqMileage: Eq<number> = {
equals: (x, y) => Math.abs(x - y) <= 100,
};
const eqCar: Eq<Car> = struct({
brand: stringEq,
year: numberEq,
engine: stringEq,
mileage: eqMileage,
});
eqCar.equals(car1, car2); // false
Что произошло: struct это комбинатор. Он принимает запись { ключ: Eq<полеX> }
и возвращает Eq<{ key: полеX }>. Правила для каждого поля выбраны независимо
(eqMileage со своим допуском, остальные дефолтные), сборка декларативна.
Ключ “поменять правило для одного поля” не равен “переписать функцию”.
Eq в виде закона: рефлексивность (
equals(x, x)всегда true), симметрия (equals(x, y) === equals(y, x)), транзитивность. Если ты пишешь свою реализацию через комбинатор, законы выполняются автоматически. Если руками, проверяй сам.
Часть 2. Ord: порядок и contramap
Теперь “какая машина дороже”. Правила:
- новее, лучше,
- марка ценнее (BMW > Audi > Ford),
- двигатель ценнее (дизель > бензин > электро),
- меньше пробег, лучше (с допуском 100 км),
- приоритет правил, в порядке перечисления.
Без typeclass, придётся писать сложную сортировку с цепочкой if/else и
“если равны по году, сравним по марке, иначе…”. Структура решения теряется в коде сравнения.
С Ord<A> каждый из четырёх критериев это отдельное значение:
import { Ord, contramap, getMonoid, reverse } from 'fp-ts/Ord';
import { concatAll } from 'fp-ts/Monoid';
import * as N from 'fp-ts/number';
import { pipe } from 'fp-ts/function';
enum CarEngineRate { diesel = 3, petrol = 2, electric = 1 }
enum CarBrandRate { BMW = 3, Audi = 2, Ford = 1 }
type PricedCar = Omit<Car, 'engine' | 'brand'> & {
enginePrice: CarEngineRate;
brandPrice: CarBrandRate;
};
const ordByYear: Ord<PricedCar> = pipe(
N.Ord,
contramap((car) => car.year),
);
const ordMileage: Ord<number> = {
equals: (x, y) => Math.abs(x - y) <= 100,
compare: (x, y) => (Math.abs(x - y) <= 100 ? 0 : x < y ? -1 : 1),
};
const ordByMileage: Ord<PricedCar> = pipe(
ordMileage,
reverse,
contramap((car) => car.mileage),
);
contramap это ключевое движение. У нас есть Ord<number> (как сравнивать
числа), мы хотим Ord<PricedCar> “по году”. contramap берёт функцию PricedCar -> number
и переворачивает её: сравнение чисел поднимается до сравнения машин по году.
Ord контравариантен по A, отсюда префикс “contra”.
reverse инвертирует порядок: для пробега меньше значит лучше.
Дальше склеиваем четыре Ord<PricedCar> в один сложный:
const M = getMonoid<PricedCar>();
const complexOrd = concatAll(M)([
ordByYear,
ordByBrand,
ordByEngine,
ordByMileage,
]);
getMonoid<PricedCar>() даёт “моноид Ord-ов по PricedCar”: concat это
“если первый говорит EQ, спроси второго”; empty это “всегда EQ”. Это и есть
лексикографический порядок: пока кандидаты равны по верхнему критерию,
переходим к следующему.
Заметь: цепочка
if/elseисчезла. Структура решения “сначала по году, потом по марке” буквально записана в массиве. Изменить приоритет, переставить элементы. Добавить критерий, дописать одну строку. Это и есть алгебраический подход: данные о порядке стали значениями, которыми можно манипулировать.
Часть 3. Semigroup: склеить два профиля
Переходим к владельцам. Профиль:
type Owner = {
name: string;
isVerified: boolean;
registeredAt: number | null;
lastUpdatedAt: number;
cars: Car[];
};
Задача: слить два профиля в один. Правила специфические:
cars, конкатенация массивов,isVerified, OR (если хоть один верифицирован, итог true),name, выбираем самый длинный,lastUpdatedAt, максимум,registeredAt, “не null” побеждает “null”.
Semigroup<A> это просто бинарная ассоциативная операция:
interface Semigroup<A> {
readonly concat: (x: A, y: A) => A;
}
Никакой “пустоты” пока нет (она появится в моноиде). Ассоциативность важна:
concat(a, concat(b, c)) === concat(concat(a, b), c). Это значит, что когда
у нас будет три и больше профиля, порядок группировки не меняет результат.
Сборка через struct:
import { Semigroup, max, struct } from 'fp-ts/Semigroup';
import { SemigroupAny } from 'fp-ts/boolean';
import * as Array from 'fp-ts/Array';
import * as Ord from 'fp-ts/Ord';
import { Ord as OrdNumber } from 'fp-ts/number';
const EmptyDateSemigroup: Semigroup<number | null> = {
concat: (x, y) => x ?? y,
};
const MergeOwnersSemigroup: Semigroup<Owner> = struct({
cars: Array.getSemigroup<Car>(),
isVerified: SemigroupAny,
name: pipe(
OrdNumber,
Ord.contramap((s: string) => s.length),
max,
),
lastUpdatedAt: max(OrdNumber),
registeredAt: EmptyDateSemigroup,
});
Опять тот же паттерн struct({ field: SemigroupДляПоля }). Заметь:
Array.getSemigroup<Car>(), библиотечная Semigroup для массивов (concat),SemigroupAny,ORдля булей,max(Ord), моноид-конструктор: “взять больший по этому Ord”. Черезcontramapмы превратили “максимум числа” в “максимум по длине строки”,EmptyDateSemigroup, ручная Semigroup, потому что нужное правило специфическое и не покрывается готовыми.
Когда правил много и они разнородные, struct остаётся декларативным.
Каждое поле, отдельная маленькая алгебра, сборка автоматическая.
дальше после входа
Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.
Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.