You don’t need lodash — but not where you think
“Porting lodash to FTS” is a proposal that comes regularly and cannot be accepted. FTS has no collections: an object has five built-in field types — string, number, date, money, flag — and no list-based ones. There are no higher-order functions: a utility takes one object and returns one scalar value, and a rule operand can be only a constant, a field, a percentage of a field, or the current result. There are no string operations, no utility calls from within a utility, and therefore no recursion. map, filter, groupBy, debounce, cloneDeep, merge are unexpressible in this language — and should remain unexpressible.
What’s interesting is something else. Open the list of real lodash calls in your project and divide it into two groups. The first group will include clamp, inRange, round for calculating price, defaultTo, sumBy with a condition, maxBy based on a threshold, chains get with a fallback value. These are not utilities — these are business rules written as function arguments. The second group will include everything else, and it will remain in JavaScript forever. The chapter’s thesis: lodash leaves the project not because FTS appeared, but because half of its calls were logic without a model, and the other half has been in the language’s standard library for twenty years. FTS takes only the first half — and it’s worth taking it, because it must be checkable.
Minimal example
Bonus write-off during order payment. In application code this is usually one line:
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
A line from lodash expanded into four rules, one property, and seven examples.
This is not verbosity: clamp did not answer what to do with a negative balance, and defaultTo did not answer where the solution to substitute zero came from.
Now both answers are recorded and being checked.
Both boundaries clamp have moved to rule conditions, and this is not a matter of style. The lower boundary 0 could not become a property: результат не меньше 0 at начинает с 0 coincides with the initial utility value, that is, it repeats the declaration rather than checking the calculation. The upper boundary could not remain a percentage of the field: результат не больше 30 процентов от поля «сумма заказа» changes sign together with the sum and breaks on a negative order, where no rule has triggered. Therefore, the ceiling is set as a number, and the rule “Large order hits the ceiling” keeps it there where a third of the order would be greater. The analysis of both errors — in the chapter on antipatterns, the check — by the command ftsmap from the language repository.
What the compiler does
начинает сaccepts only a constant, so the original value is introduced by the first rule:то результат равен поле «баланс бонусов». Its conditionне меньше 0— is not decoration, but the lower bound ofclamp.- Rules are executed from top to bottom, and all whose conditions are true
are executed. The second rule rewrites the result via
то результат равен, the third rewrites it once more. The upper bound ofclamp— is the rule that stands after the one it limits. - The rule condition compares a field with an operand. To the left of the comparison,
cannot be
результат:validaterequires that the name on the left exists in the objectпринимает. Therefore, it is impossible to “trim the accumulated result” with a rule — you can only limit what came in. - Properties are checked after all rules. Violation stops execution with
FTS_UTILITY_PROPERTY; the value does not go out and is not adjusted. - Examples are executed by the same machine as the working call — there is no discrepancy between the article, sandbox, and generated TypeScript.
What goes into the model and what stays in JavaScript
| Lodash call | What it actually is | Where it goes |
|---|---|---|
clamp(x, lo, hi) over the input field |
lower and upper bounds of the rule | two rules то результат равен + property with an absolute ceiling |
inRange(x, a, b) |
range condition | two conditions of one rule via и |
round/ceil/floor in price calculation |
rule threshold + monetary representation | threshold — into the model, rounding — into the application |
defaultTo(v, d) |
decision “what to replace absence with” | mandatory object field + normalization at the boundary |
sumBy(items, fn) with a condition inside fn |
condition — rule, summation — iteration | condition into the model, reduce into JS |
maxBy(items, fn) by threshold |
threshold — rule, maximum search — iteration | threshold into the model, iteration into JS |
get(o, 'a.b', d) |
absence of data contract | FTS object + one normalization function |
map, filter, groupBy, chunk, zip |
working with data | remain in JS (and this is Array.prototype) |
debounce, throttle |
time management | remain in JS |
cloneDeep, merge, isEqual |
working with structures | remain in JS (structuredClone, Object.assign) |
The top part of the table is what becomes readable and checkable in the model. The bottom part is what would become worse in the model, even if the language could do it.
get(order, 'customer.tier', 'basic') — not a utility, but a symptom
This chain occurs in any mature frontend, and it contains three different assertions, none of which is written down.
First: «customer field may be absent». Second: «if it is absent, the client is considered
basic». Third, the most expensive: «the difference between an absent profile and
a basic-level profile is insignificant for calculation». The first is about data format,
the second is a business decision, the third is a hypothesis that no one has checked. get
records all three as one default argument, in fourteen places in the code,
each of which can be adjusted independently.
The model does not resolve this question — it makes it mandatory. «уровень клиента» является строкой means the field cannot be absent: executeUtility without it
will stop with FTS_UTILITY_INPUT, and not substitute an empty string. The decision “what to replace absence with” has to be taken at the boundary and named:
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 };
}
The function returns not only the level, but also the assumed flag. This is a cheap
line, but it turns a silent assumption into measurable: now it is possible to
calculate how many baskets were counted according to the guessed level. While the solution lived
as the third argument get, there was no one to ask such a question.
Rounding: the rule is there, round is not
In FTS there is no rounding. Neither round, nor floor, nor ceil, nor accuracy settings:
the rule operand is a constant, a field, a percentage of a field, or a result, and nothing else. This
is a verifiable claim, not a default — there is not a single rounding expression in the compiler source code.
From here, an honest conclusion, not a detour. The model calculates the exact value:
7 percent of 157.50 is 11.025, and no other number. The application brings the value down to kopecks — exactly once, at the boundary, using the same round it used before. The “how many percent” rule moves to the model, the “when does money become kopecks” rule stays in the code, because this is a property of the representation, not the tariff.
The temptation to record rounding in the model through percentage thresholds exists, and one should not give in to it: rounding depends on the currency, the direction (in whose favor), and the position in the calculation chain — that is, on things the model does not and should not know.
Walkthrough: before.mjs → pricing.fts → after.mjs
The set is in examples/fts/lodash/. The discount on the cart item is calculated:
a percentage for the client level, a surcharge for bulk purchases of ten units or more, a promo code, and a maximum
limit of 13 percent.
before.mjs — lodash-style (the library itself is not included, five of its functions
are locally reproduced, round — verbatim, including the exponent shift):
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 — the same four solutions by the rules, the limit by the property:
правило «Оптовая позиция»
если количество не меньше 10
то добавить 5 процентов от поля стоимость
свойство «Скидка не превышает 13 процентов»
результат не больше 13 процентов от поля стоимость
Here the ceiling is deliberately left as a percentage of the field, although we have already abandoned this form above: the whole point of the walkthrough is that the old clamp clipped the result using the same formula, and the comparison of implementations should follow it as well. The cost of the solution is known and named: ftsmap examples/fts/lodash/pricing.fts --text shows that on the negative стоимости the property is violated where none of the rules applied. This is a transfer debt that is closed after the list of discrepancies is reviewed by the rule owner — not a pattern for a new model.
after.mjs — model call to a position, position enumeration by regular 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 runs both implementations on a deterministic grid of
350 inputs — seven prices with kopecks, five quantities around the threshold, five level values
(including the missing profile and the unfamiliar “platinum”) and two promo codes:
входов: 350; совпало: 317; расхождений: 33 (Потолок вместо отказа — 14; Двойное округление процентов — 19)
позиций в корзине: 64; итог: 815430.16; скидка: 76706.88
✔ модель компилируется, и её собственные примеры сходятся
✔ lodash-версия и модель совпадают на всей сетке, кроме объявленных расхождений
✔ каждое объявленное расхождение подтверждается сеткой и объяснено
✔ коллекции остались в JavaScript: многопозиционная корзина считается одинаково
✔ модель отказывается считать без поля, а не подставляет умолчание
ℹ tests 5
ℹ pass 5
ℹ fail 0
Thirty three discrepancies — not a porting defect, but two findings, and both are described
by the predicate in KNOWN_DIVERGENCES, and not fitted.
Ceiling instead of refusal (14 inputs). Gold plus opt plus promo code give
14 percent at the declared limit of 13. clamp silently truncated the result: at
a price of 1234.55 for ten units, the old code gives a discount of 1604.92 — a number
that no rule describes. The model on the same input stops with FTS_UTILITY_PROPERTY. This is the
difference between “limit” as a function argument and “limit” as an assertion: in the
second case, someone must decide whether such a combination can be issued at all,
rather than silently selling at 13.
Double rounding of percentages (19 inputs). In the lodash version round it was
inside each term. Base 157.50: 7 percent is 11.025, 5 percent is
7.875, both half-kopecks are rounded up, and the total comes to 18.91. The model
calculates the exact amount of 18.9, the application rounds it once — 18.90. The kopeck
difference did not come from a business rule: the number of round calls was a consequence
of how if was written. The correct answer is in the new version, but this could be found out
only by comparing implementations on the grid.
What FTS Will Not Replace
- Collections.
map,filter,reduce,groupBy,chunk,zip— in the language there is neither a list type nor higher-order functions. The utility works with one item; the loop is handled by the calling code. Inafter.mjsthe iteration remains exactly the same as it was. - Deferred calls.
debounceandthrottle— about time and the scheduler. The utility does not read the clock and does not store state between calls; otherwise, its examples would no longer be tests. - Deep comparison.
isEqualworks with an arbitrary structure of unknown depth. The FTS condition compares scalar to scalar — six comparison operators are sufficient for this, but not for recursive traversal. - Immutable updates.
cloneDeep,merge,setcreate new objects. The utility does not return an object — only a scalar of the declared type.
None of the four items is in the language development plan. Each of them is data processing, and it already has a suitable tool.
Practice in the sandbox
- Open the “Execute” tab and calculate the charge for order 2000 with a balance of 900. Then reduce the order amount to 499 and make sure the result becomes zero due to the third rule, not due to the limit.
- Swap the rules “No more than a third of the order” and “The entire
balance is charged” and run the “Examples” tab. The “Balance above the limit”
example will stop converging:
то результат равенoverwrites everything accumulated by the previous rules, so the limit placed before the restricted rule does not restrict anything — the calculation hits the property. - Strengthen the property to
результат не больше 500and run the “Balance above the limit” example. The tab will showFTS_UTILITY_PROPERTY— whereclampwould show a plausible number. Recording the same limit as a percentage (результат не больше 20 процентов от поля «сумма заказа») is not allowed: check with a negative order amount — the property will fail where no rule triggered.
Common Fallacies
FTS_UTILITY_FIELD: attempt to limit the accumulated result with a rule. Lineесли результат больше 13 процентов от поля стоимостьis parsed, but does not passvalidate: to the left of the comparison must be the name of a field from the objectпринимает, andрезультатis not such a field — the diagnostic saysunknown utility input field 'результат'. The upper limit of the accumulated sum is expressed as a property, that is, a refusal, not a truncation.- Transfer
roundto the model through percentages. Rounding is not present in the language, and faking it with percentage thresholds gives a rule that breaks when the currency changes. The model calculates the exact value; kopecks — the application’s job. - Leave
defaultToin the adapter silently. Substituting a value at the boundary is acceptable, but it must be visible: a separate function, a name, a sign “value substituted”. Otherwise, the default simply moved from lodash to the adapter. - Create a “field-list” in the model.
являетсяknows five types; the name of an unfamiliar type will be accepted by the compiler as the name of a state, and you will get an object that looks meaningful but does not work as a collection. - Consider
FTS_UTILITY_PROPERTYa regression. The property worked exactly where the old code quietly truncated the result. This is not a calculation break, but the first time it was asked.
Checklist
- The list of lodash calls is split into two: business rules and data processing — before the first line of the model is written.
- Into the model went thresholds, percentages, boundaries, and conditions; in the code remained
map,reduce, cloning, comparison, and timers. - No
get(o, 'a.b', d)chain moved into the model as is: a field became mandatory, and the decision on defaulting — a named function on the boundary. - Rounding is called once on the boundary, not inside each term.
- Upper limits are written as properties, and it is known that a property violation stops the calculation, not corrects it.
- There is an equivalence test on a deterministic grid, and the found discrepancies are declared as a list with explanations, not adjusted to match.
- The old code is disabled only after the list of discrepancies is reviewed by the rule owner.