The grammar, types, every built-in function, the four validation stages, exact-decimal evaluation and the API that compiles, tries and saves a formula.
The BimaStack team · · 8 min read
The exact rules of BimaStack’s formula language: grammar, types, every built-in, how a formula is checked and evaluated, and the API that compiles, tries and saves one. For a gentler start, read the guide first.
Reserved words: let, and, or, not, true, false. if is not reserved: if( is parsed as a conditional with exactly three arguments.
Numbers have no exponent and no sign; -5 is unary minus applied to 5. A number must start with a digit (0.5, not .5).
Strings are double-quoted and have no escapes, so a string can’t contain a double quote.
Whitespace, including new lines, is free between tokens. There are no comments in formula text (a workflow file has its own).
! on its own is an error with the hint “Use ’not’ for negation”; != is not-equal.
Precedence
From highest
Operators
Notes
1
- (unary), not
Right to left: not not x
2
*, /
Left to right
3
+, -
Left to right
4
==, !=, <, <=, >, >=
Non-chaining: a < b < c is a syntax error
5
and
Short-circuits
6
or
Short-circuits
Types
Three types: NUMBER, BOOLEAN and STRING. Nothing is coerced: there is no truthiness, no number-to-text, no text-to-number. Types are checked before a formula runs.
Construct
Operands
Result
+ - * /
NUMBER, NUMBER
NUMBER
< <= > >=
NUMBER, NUMBER
BOOLEAN
== !=
two values of the same type
BOOLEAN
and, or
BOOLEAN, BOOLEAN
BOOLEAN
not
BOOLEAN
BOOLEAN
unary -
NUMBER
NUMBER
if(c, a, b)
c BOOLEAN; a and b the same type
the type of a
A date is a STRING in ISO form (yyyy-MM-dd); the date functions parse it. A choice from a closed list (an ENUM input in a workflow) also reaches a formula as a STRING. A formula’s result type is whatever its expression yields; a workflow node that declares a BOOLEAN or STRING output requires exactly that type, and a mismatch stops the run rather than being converted.
Names
The variable surface is the set of names a formula may use, with a type each. Inside a workflow it is the node’s input ports; through the API you send it as variableSurface.
Dotted names (rate.value) are one name. If the surface holds a record instead (a row of a rate table, say), row.rate reads that record’s field. A flat name always wins over a record field of the same spelling.
let names are visible to every later let and to the final expression. A let may not reuse a surface name or an earlier let (a REFERENCE error), and a let’s own expression can’t see itself.
Unknown names get a “Did you mean” suggestion when one is within a small edit distance.
Built-in functions
Signature
Returns
Behaviour
min(NUMBER, NUMBER)
NUMBER
The smaller
max(NUMBER, NUMBER)
NUMBER
The larger
round(x NUMBER, places NUMBER, mode STRING)
NUMBER
places a whole number 0–10; mode one of UP, DOWN, CEILING, FLOOR, HALF_UP, HALF_DOWN, HALF_EVEN. UNNECESSARY, a fraction or an out-of-range places is an evaluation error, never a default
ageInYears(dateOfBirth STRING, asOf STRING)
NUMBER
Completed years between two ISO dates
daysBetween(from STRING, to STRING)
NUMBER
Days from one ISO date to the other, excluding the first (1 to 5 November is 4; add 1 to count both ends)
convert(amount NUMBER, from STRING, to STRING)
NUMBER
At the published exchange rate for "FROM:TO", resolved once per pair per run, so every conversion in a calculation uses the same rate
prorate(amount NUMBER, coverFrom STRING, coverTo STRING, type STRING)
NUMBER
FULL_TERM returns amount; PRORATED returns amount × daysBetween ÷ 365. SHORT_PERIOD needs a day-band table and is done with a workflow instead (see Workflows in depth)
A date argument that isn’t yyyy-MM-dd is an evaluation error naming the function and the argument. The set of functions is closed: there are no user-defined functions, and the arity and argument types of every call are checked before the formula runs.
Evaluation
Exact decimals. Every number is a decimal carried at 10 places. Division rounds half to even at 10 places; nothing else rounds unless round is called. Results leave the formula unrounded; rounding to money happens at a workflow’s output.
Short-circuit. if evaluates only the branch taken; and stops at the first false, and or at the first true. if(n == 0, 0, total / n) is safe.
Division by zero at run time is an error naming the line and column.
Deterministic. No clock, no randomness, no I/O. The same formula, surface and values always give the same result.
One engine. An error points at the exact sub-expression, and “try it” runs the same evaluation as production.
Validation
Compiling a formula runs four stages. The first three block saving; RISK is a warning.
Stage
Checks
SYNTAX
Tokens and grammar: unbalanced brackets, a dangling operator, an unterminated string, trailing tokens
REFERENCE
Every variable is in the surface or a let; every function exists; no let shadows a name
SEMANTIC
Operand and argument types, function arity, both if branches the same type
RISK
Division by a literal zero
A diagnostic
{
"stage": "REFERENCE",
"message": "Unknown variable 'driverAg'.",
"line": 1,
"column": 4,
"offendingToken": "driverAg",
"suggestion": "Did you mean 'driverAge'?"
}
The API
Under /api/formulas. Every call needs a signed-in session or a bearer token. Send a Bimastack-Tenant header to work in your organisation (without it, platform content’s permissions apply). compile, evaluate, list and get need formula:read; save needs formula:draft.
Call
Does
POST /api/formulas/compile
Validates source against a surface. Always 200: {isValid, diagnostics}
POST /api/formulas/evaluate
Compiles, then evaluates against sampleValues. result is absent if it didn’t compile
POST /api/formulas
Saves a new immutable version under formulaKey; 422 with diagnostics if it doesn’t compile
GET /api/formulas?q=&limit=&cursor=
The latest version of every key, a page at a time; your organisation’s key hides a platform key of the same name
Values travel as {"type": "NUMBER" | "BOOLEAN" | "STRING", "value": "<text>"}. A NUMBER result is the full 10-place value.
convert in the sandbox uses sampleExchangeRates, keyed "USD:KES", never live rates, so a trial is self-contained. A missing pair is a 422 naming it.
prorate FULL_TERM and PRORATED work in the sandbox; SHORT_PERIOD is a 422.
A saved formula returns {id, formulaKey, version, sourceText, createdAt}. Versions are numbered per owner, so your organisation’s v1 and the platform’s v1 are different rows. The variable types aren’t stored: a formula is checked against each place it is used.
Errors are RFC 7807 problems: 422 for a failed validation (with diagnostics) or evaluation, 404 for an unknown key or version.
From a saved formula to a live price
Saving a formula doesn’t make it live. A workflow refers to formulas by a binding key, and a binding (a calculation link) pins one key to one version, for a scope and from a date, approved by a second person. That is how a corrected formula reaches quotes on a chosen date, and how an old quote still names the version that priced it. Test, approve and go live covers bindings in full.
Build against it
Sandbox is a full BimaStack for integrators, wiped every night.