FTS — исполняемые спецификации Вам не нужен lodash — но не там, где вы думаете
0%

Вам не нужен lodash — но не там, где вы думаете

Вам не нужен lodash — но не там, где вы думаете

«Портируем lodash на FTS» — предложение, которое приходит регулярно и которое нельзя принять. В FTS нет коллекций: у объекта пять встроенных типов полей — строка, число, дата, деньги, признак — и ни одного списочного. Нет функций высшего порядка: утилита принимает один объект и возвращает одно скалярное значение, а операнд правила может быть только константой, полем, процентом от поля или текущим результатом. Нет строковых операций, нет вызова утилиты из утилиты и, следовательно, нет рекурсии. map, filter, groupBy, debounce, cloneDeep, merge в этом языке невыразимы — и не должны быть выразимы.

Интересно другое. Откройте список реальных вызовов lodash в своём проекте и разделите его на две группы. В первой окажутся clamp, inRange, round в расчёте цены, defaultTo, sumBy с условием, maxBy по порогу, цепочки get с запасным значением. Это не утилиты — это бизнес-правила, записанные аргументами функций. Во второй группе окажется всё остальное, и оно останется в JavaScript навсегда. Тезис главы: lodash уходит из проекта не потому, что появился FTS, а потому, что половина его вызовов была логикой без модели, а вторая половина уже двадцать лет есть в стандартной библиотеке языка. FTS забирает только первую половину — и забирать её стоит, потому что она обязана быть проверяемой.

Минимальный пример

Списание бонусов при оплате заказа. В прикладном коде это обычно одна строка: clamp(defaultTo(customer.bonus, 0), 0, round(order.amount * 0.3, 2)).

категория «Ценообразование»

  объект «Списание бонусов»
    «сумма заказа» является деньгами
    «баланс бонусов» является числом

  утилита «Списать бонусы»
    принимает «Списание бонусов»
    возвращает деньги
    начинает с 0

    правило «Списывается весь баланс»
      если «баланс бонусов» не меньше 0
      то результат равен поле «баланс бонусов»

    правило «Не больше трети заказа»
      если «баланс бонусов» больше 30 процентов от поля «сумма заказа»
      то результат равен 30 процентов от поля «сумма заказа»

    правило «Мелкий заказ бонусами не оплачивается»
      если «сумма заказа» меньше 500
      то результат равен 0

    правило «Крупный заказ упирается в потолок»
      если «сумма заказа» больше 100000
      то результат равен 30000

    свойство «Списание не превышает потолка»
      результат не больше 30000

    пример «Баланс ниже предела»
      дано «сумма заказа» равна 2000
      дано «баланс бонусов» равен 300
      ожидается результат равен 300

    пример «Баланс ровно на пределе»
      дано «сумма заказа» равна 2000
      дано «баланс бонусов» равен 600
      ожидается результат равен 600

    пример «Баланс выше предела»
      дано «сумма заказа» равна 2000
      дано «баланс бонусов» равен 1000
      ожидается результат равен 600

    пример «Мелкий заказ»
      дано «сумма заказа» равна 400
      дано «баланс бонусов» равен 1000
      ожидается результат равен 0

    пример «Отрицательный баланс не списывается»
      дано «сумма заказа» равна 2000
      дано «баланс бонусов» равен -50
      ожидается результат равен 0

    пример «Заказ ровно на потолке списания»
      дано «сумма заказа» равна 100000
      дано «баланс бонусов» равен 50000
      ожидается результат равен 30000

    пример «Крупный заказ упирается в потолок»
      дано «сумма заказа» равна 200000
      дано «баланс бонусов» равен 100000
      ожидается результат равен 30000

Строка из lodash развернулась в четыре правила, одно свойство и семь примеров. Это не многословность: clamp не отвечал на вопрос, что делать с отрицательным балансом, а defaultTo не отвечал, откуда взялось решение подставить ноль. Теперь оба ответа записаны и проверяются.

Обе границы clamp уехали в условия правил, и это не стилистика. Нижняя граница 0 не могла бы стать свойством: результат не меньше 0 при начинает с 0 совпадает с начальным значением утилиты, то есть повторяет объявление, а не проверяет расчёт. Верхняя граница не могла бы остаться процентом от поля: результат не больше 30 процентов от поля «сумма заказа» меняет знак вместе с суммой и нарушается на отрицательном заказе, где не сработало ни одно правило. Поэтому потолок задан числом, а правило «Крупный заказ упирается в потолок» удерживает его там, где треть заказа была бы больше. Разбор обеих ошибок — в главе об антипаттернах, проверка — командой ftsmap из репозитория языка.

Что делает компилятор

  1. начинает с принимает только константу, поэтому исходное значение вносит первое правило: то результат равен поле «баланс бонусов». Его условие не меньше 0 — не украшение, а нижняя граница clamp.
  2. Правила выполняются сверху вниз, и выполняются все, чьи условия истинны. Второе правило перезаписывает результат через то результат равен, третье перезаписывает его ещё раз. Верхняя граница clamp — это правило, стоящее после того, которое она ограничивает.
  3. Условие правила сравнивает поле с операндом. Слева от сравнения не может стоять результат: validate требует, чтобы имя слева существовало в объекте принимает. Поэтому «обрезать накопленный результат» правилом нельзя — можно только ограничить то, что пришло на вход.
  4. Свойства проверяются после всех правил. Нарушение останавливает выполнение с FTS_UTILITY_PROPERTY; значение наружу не уходит и не подгоняется.
  5. Примеры выполняются той же машиной, что и рабочий вызов, — расхождение между статьёй, песочницей и сгенерированным TypeScript невозможно.

Что уезжает в модель, а что остаётся в JavaScript

Вызов lodash Что это на самом деле Куда девается
clamp(x, lo, hi) над входным полем нижняя и верхняя границы правила два правила то результат равен + свойство с абсолютным потолком
inRange(x, a, b) диапазон-условие два условия одного правила через и
round/ceil/floor в расчёте цены порог правила + денежное представление порог — в модель, округление — в приложение
defaultTo(v, d) решение «чем заменить отсутствие» обязательное поле объекта + нормализация на границе
sumBy(items, fn) с условием внутри fn условие — правило, суммирование — перебор условие в модель, reduce в JS
maxBy(items, fn) по порогу порог — правило, поиск максимума — перебор порог в модель, перебор в JS
get(o, 'a.b', d) отсутствие контракта данных объект FTS + одна функция нормализации
map, filter, groupBy, chunk, zip работа с данными остаются в JS (и это Array.prototype)
debounce, throttle управление временем остаются в JS
cloneDeep, merge, isEqual работа со структурами остаются в JS (structuredClone, Object.assign)

Верхняя часть таблицы — то, что в модели становится читаемым и проверяемым. Нижняя — то, что в модели стало бы хуже, даже если бы язык это умел.

get(order, 'customer.tier', 'basic') — не утилита, а симптом

Эта цепочка встречается в любом зрелом фронтенде, и в ней спрятаны три разных утверждения, ни одно из которых не записано.

Первое: «поля customer может не быть». Второе: «если его нет, клиент считается базовым». Третье, самое дорогое: «разница между отсутствующим профилем и профилем базового уровня для расчёта несущественна». Первое — про формат данных, второе — решение бизнеса, третье — гипотеза, которую никто не проверял. get записывает все три одним аргументом по умолчанию, в четырнадцати местах кода, каждое из которых можно поправить независимо.

Модель этот вопрос не решает — она делает его обязательным. «уровень клиента» является строкой означает, что поля не может не быть: executeUtility без него остановится с FTS_UTILITY_INPUT, а не подставит пустую строку. Решение «чем заменить отсутствие» приходится вынести на границу и назвать:

export function normalizeTier(cart) {
  const raw = cart?.customer?.tier;
  if (typeof raw === 'string' && KNOWN_TIERS.has(raw)) return { tier: raw, assumed: false };
  return { tier: 'базовый', assumed: true };
}

Функция возвращает не только уровень, но и признак assumed. Это дешёвая строчка, но она превращает молчаливое допущение в измеримое: теперь можно посчитать, сколько корзин посчиталось по угаданному уровню. Пока решение жило третьим аргументом get, такой вопрос было некому задать.

Округление: правило есть, round нет

В FTS нет округления. Ни round, ни floor, ни ceil, ни настройки точности: операнд правила — константа, поле, процент от поля или результат, и всё. Это проверяемое утверждение, а не умолчание — в исходниках компилятора нет ни одного округляющего выражения.

Отсюда честный вывод, а не обходной путь. Модель считает точное значение: 7 процентов от 157.50 — это 11.025, и никакое другое число. До копеек значение доводит приложение — ровно один раз, на границе, тем же round, каким пользовалось раньше. Правило «сколько процентов» уезжает в модель, правило «в какой момент деньги становятся копейками» остаётся в коде, потому что это свойство представления, а не тарифа.

Соблазн записать округление в модель через процентные пороги существует, и ему не надо поддаваться: округление зависит от валюты, от направления (в чью пользу) и от места в цепочке расчёта — то есть от вещей, которых модель не знает и знать не должна.

Разбор: before.mjspricing.ftsafter.mjs

Комплект лежит в examples/fts/lodash/. Считается скидка на позицию корзины: процент за уровень клиента, надбавка за опт от десяти штук, промокод и верхний предел в 13 процентов.

before.mjs — lodash-стиль (сама библиотека не подключена, пять её функций воспроизведены локально, round — дословно, вместе со сдвигом экспоненты):

let discount = 0;
if (percent > 0) discount += round((base * percent) / 100, 2);
if (quantity >= 10) discount += round((base * 5) / 100, 2);
if (line.promo === 'ЛЕТО') discount += round((base * 2) / 100, 2);
discount = round(clamp(discount, 0, round((base * MAX_DISCOUNT_PERCENT) / 100, 2)), 2);

pricing.fts — те же четыре решения правилами, предел свойством:

    правило «Оптовая позиция»
      если количество не меньше 10
      то добавить 5 процентов от поля стоимость

    свойство «Скидка не превышает 13 процентов»
      результат не больше 13 процентов от поля стоимость

Здесь потолок намеренно оставлен процентом от поля, хотя выше мы от этой формы отказались: весь смысл разбора в том, что старый clamp обрезал результат по той же формуле, и сравнение реализаций должно идти по ней же. Цена решения известна и названа: ftsmap examples/fts/lodash/pricing.fts --text показывает, что на отрицательной стоимости свойство нарушается там, где не сработало ни одно правило. Это долг переноса, который закрывается после того, как список расхождений разобран владельцем правила, — не образец для новой модели.

after.mjs — вызов модели на позицию, перебор позиций обычным map:

const lines = (cart?.lines ?? []).map((line) => {
  const quantity = line.qty ?? 1;
  const base = round(line.price * quantity, 2);
  const discount = lineDiscount({ base, quantity, tier, promo: line.promo ?? '' });
  return { sku: line.sku, base, discount, total: round(base - discount, 2) };
});

equivalence.test.mjs прогоняет обе реализации на детерминированной сетке из 350 входов — семь цен с копейками, пять количеств вокруг порога, пять значений уровня (включая отсутствующий профиль и незнакомую «платину») и два промокода:

входов: 350; совпало: 317; расхождений: 33 (Потолок вместо отказа — 14; Двойное округление процентов — 19)
позиций в корзине: 64; итог: 815430.16; скидка: 76706.88
✔ модель компилируется, и её собственные примеры сходятся
✔ lodash-версия и модель совпадают на всей сетке, кроме объявленных расхождений
✔ каждое объявленное расхождение подтверждается сеткой и объяснено
✔ коллекции остались в JavaScript: многопозиционная корзина считается одинаково
✔ модель отказывается считать без поля, а не подставляет умолчание
ℹ tests 5
ℹ pass 5
ℹ fail 0

Тридцать три расхождения — не дефект переноса, а две находки, и обе описаны предикатом в KNOWN_DIVERGENCES, а не подогнаны.

Потолок вместо отказа (14 входов). Золото плюс опт плюс промокод дают 14 процентов при объявленном пределе 13. clamp молча обрезал результат: на цене 1234.55 за десять штук старый код отдаёт скидку 1604.92 — число, которого не описывает ни одно правило. Модель на том же входе останавливается с FTS_UTILITY_PROPERTY. Это и есть разница между «предел» как аргументом функции и «предел» как утверждением: во втором случае кто-то обязан решить, можно ли вообще выдавать такое сочетание, а не молча продавать по 13.

Двойное округление процентов (19 входов). В lodash-версии round стоял внутри каждого слагаемого. База 157.50: 7 процентов — это 11.025, 5 процентов — 7.875, обе половинки копейки округляются вверх, и сумма выходит 18.91. Модель считает точную сумму 18.9, приложение округляет её один раз — 18.90. Копейка разницы взялась не из бизнес-правила: количество вызовов round было следствием того, как написан if. Правильный ответ здесь у новой версии, но узнать об этом можно было только сравнив реализации на сетке.

Чего FTS не заменит

  • Коллекции. map, filter, reduce, groupBy, chunk, zip — в языке нет ни списочного типа, ни функции высшего порядка. Утилита работает с одной позицией; корзину перебирает вызывающий код. В after.mjs перебор остался ровно таким же, каким был.
  • Отложенные вызовы. debounce и throttle — про время и планировщик. Утилита не читает часы и не хранит состояние между вызовами; будь иначе, её примеры перестали бы быть тестами.
  • Глубокое сравнение. isEqual работает по произвольной структуре неизвестной глубины. Условие FTS сравнивает скаляр со скаляром — шести операторов сравнения для этого достаточно, для рекурсивного обхода нет.
  • Иммутабельные обновления. cloneDeep, merge, set создают новые объекты. Утилита не возвращает объект — только скаляр объявленного типа.

Ни один из четырёх пунктов не в плане развития языка. Каждый из них — работа с данными, и у неё уже есть подходящий инструмент.

Практика в песочнице

  1. Откройте вкладку «Выполнить» и посчитайте списание для заказа 2000 с балансом 900. Затем уменьшите сумму заказа до 499 и убедитесь, что результат стал нулём из-за третьего правила, а не из-за предела.
  2. Поменяйте местами правила «Не больше трети заказа» и «Списывается весь баланс» и запустите вкладку «Примеры». Пример «Баланс выше предела» перестанет сходиться: то результат равен перезаписывает всё, что накопили предыдущие правила, поэтому предел, стоящий раньше ограничиваемого правила, не ограничивает ничего — расчёт упирается в свойство.
  3. Ужесточите свойство до результат не больше 500 и запустите пример «Баланс выше предела». Вкладка покажет FTS_UTILITY_PROPERTY — там, где clamp показал бы правдоподобное число. Записывать тот же предел процентом (результат не больше 20 процентов от поля «сумма заказа») нельзя: проверьте на отрицательной сумме заказа — свойство упадёт там, где ни одно правило не сработало.

Типичные ошибки

  • FTS_UTILITY_FIELD: попытка ограничить накопленный результат правилом. Строка если результат больше 13 процентов от поля стоимость разбирается, но не проходит validate: слева от сравнения обязано стоять имя поля из объекта принимает, а результат таким полем не является — диагностика говорит unknown utility input field 'результат'. Верхний предел накопленной суммы выражается свойством, то есть отказом, а не обрезанием.
  • Переносить round в модель через проценты. Округления в языке нет, и подделка его процентными порогами даёт правило, которое ломается при смене валюты. Модель считает точное значение; копейки — работа приложения.
  • Оставлять defaultTo в адаптере молча. Подстановка значения на границе допустима, но она обязана быть видимой: отдельная функция, имя, признак «значение подставлено». Иначе умолчание просто переехало из lodash в адаптер.
  • Заводить в модели «поле-список». является знает пять типов; имя незнакомого типа компилятор примет как имя состояния, и вы получите объект, который выглядит осмысленно, но не работает как коллекция.
  • Считать FTS_UTILITY_PROPERTY регрессом. Свойство сработало ровно там, где старый код тихо обрезал результат. Это не поломка расчёта, а первый раз, когда его спросили.

Чек-лист

  • Список вызовов lodash разделён на два: бизнес-правила и работа с данными — до того, как написана первая строка модели.
  • В модель уехали пороги, проценты, границы и условия; в коде остались map, reduce, клонирование, сравнение и таймеры.
  • Ни одна цепочка get(o, 'a.b', d) не переехала в модель как есть: поле стало обязательным, а решение об умолчании — именованной функцией на границе.
  • Округление вызывается один раз на границе, а не внутри каждого слагаемого.
  • Верхние пределы записаны свойствами, и известно, что нарушение свойства останавливает расчёт, а не исправляет его.
  • Есть тест эквивалентности на детерминированной сетке, а найденные расхождения объявлены списком с объяснениями, а не подогнаны под совпадение.
  • Старый код выключается только после того, как список расхождений разобран владельцем правила.

Кейсы каталога по этой теме

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов
Дальше