Skip to content
All posts
ConfigurationDevelopersReference

Formula language reference

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.

Grammar

EBNF
formula      = { let_binding } , expression ;
let_binding  = "let" , identifier , "=" , expression , ";" ;

expression   = or_expr ;
or_expr      = and_expr , { "or" , and_expr } ;
and_expr     = comparison , { "and" , comparison } ;
comparison   = arithmetic , [ ( "==" | "!=" | "<" | "<=" | ">" | ">=" ) , arithmetic ] ;
arithmetic   = term , { ( "+" | "-" ) , term } ;
term         = factor , { ( "*" | "/" ) , factor } ;
factor       = number | string | "true" | "false"
             | variable
             | identifier , "(" , [ expression , { "," , expression } ] , ")"
             | "(" , expression , ")"
             | "-" , factor
             | "not" , factor ;

variable     = identifier , { "." , identifier } ;
identifier   = letter , { letter | digit | "_" } ;    (* a letter may be "_" *)
number       = digit , { digit } , [ "." , digit , { digit } ] ;
string       = '"' , { any character but '"' } , '"' ;
  • 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), notRight to left: not not x
2*, /Left to right
3+, -Left to right
4==, !=, <, <=, >, >=Non-chaining: a < b < c is a syntax error
5andShort-circuits
6orShort-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, NUMBERNUMBER
< <= > >=NUMBER, NUMBERBOOLEAN
== !=two values of the same typeBOOLEAN
and, orBOOLEAN, BOOLEANBOOLEAN
notBOOLEANBOOLEAN
unary -NUMBERNUMBER
if(c, a, b)c BOOLEAN; a and b the same typethe 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)NUMBERThe smaller
max(NUMBER, NUMBER)NUMBERThe larger
round(x NUMBER, places NUMBER, mode STRING)NUMBERplaces 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)NUMBERCompleted years between two ISO dates
daysBetween(from STRING, to STRING)NUMBERDays 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)NUMBERAt 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)NUMBERFULL_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
SYNTAXTokens and grammar: unbalanced brackets, a dangling operator, an unterminated string, trailing tokens
REFERENCEEvery variable is in the surface or a let; every function exists; no let shadows a name
SEMANTICOperand and argument types, function arity, both if branches the same type
RISKDivision 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/compileValidates source against a surface. Always 200: {isValid, diagnostics}
POST /api/formulas/evaluateCompiles, then evaluates against sampleValues. result is absent if it didn’t compile
POST /api/formulasSaves 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
GET /api/formulas/{formulaKey}?version=One version’s source (default the latest)
Try a formula
curl -s -X POST https://<your-address>/api/formulas/evaluate \
  -H "Authorization: Bearer $TOKEN" \
  -H "Bimastack-Tenant: <organisation>" \
  -H "Content-Type: application/json" \
  -d '{
    "sourceText": "let extra = max(childCount - 4, 0); extra * 1500",
    "variableSurface": {"variableTypes": {"childCount": "NUMBER"}},
    "sampleValues": {"childCount": {"type": "NUMBER", "value": "6"}}
  }'
  • 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.

For developers