A high-performance formula evaluator written in Rust, compiled to WebAssembly via wasm-pack. It powers the formula and rollup property types in notion-database-sys, but is designed as a standalone library you can embed in any TypeScript/JavaScript project.
Formula string
│
▼
┌────────┐ tokens ┌──────────┐ AST ┌────────────┐
│ Lexer │ ────────────▶ │ Parser │ ──────────▶ │ Compiler │
└────────┘ └──────────┘ └────────────┘
│ Chunk (bytecode)
▼
┌────────────┐
Context ──▶ │ VM │ ──▶ Value
└────────────┘
| Module | File | Responsibility |
|---|---|---|
| Lexer | src/lexer.rs |
Tokenise formula strings; track line/column positions |
| Parser | src/parser.rs |
Pratt (precedence climbing) parser → AST |
| Compiler | src/compiler.rs |
Walk AST → bytecode Chunk with constant pool |
| VM | src/vm.rs |
Register-based stack executor |
| Types | src/types.rs |
Value enum, Expr AST nodes |
| Error | src/error.rs |
FormulaError with source spans |
| Functions | src/functions/ |
Built-in function registry (math, text, date, array, logic) |
| WASM entry | src/lib.rs |
wasm-bindgen exports: compile / evaluate / validate |
| TS bridge | bridge.ts |
TypeScript wrapper with init, cache and typed helpers |
# Install wasm-pack (one-time)
curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
# Build the WASM package (outputs to pkg/)
cd src/lib/engine
wasm-pack build --target web --out-dir pkg
# Development build (faster, no optimisations)
wasm-pack build --target web --out-dir pkg --devThe pkg/ directory contains:
formula_engine_bg.wasm— the compiled binaryformula_engine.js— JS glue generated bywasm-bindgenformula_engine.d.ts— TypeScript declarations
import { initFormulaEngine } from './bridge';
await initFormulaEngine();The module also auto-initialises on import (non-blocking), so await is optional for most use-cases.
import { evalFormula } from './bridge';
const result = evalFormula('price * quantity', {
price: 49.99,
quantity: 3,
});
// → 149.97import { compileFormula, evaluateHandle } from './bridge';
const compiled = compileFormula('upper(name) + " — " + category');
if (compiled?.ok && compiled.handle !== undefined) {
for (const row of rows) {
const label = evaluateHandle(compiled.handle, row);
}
}import { batchEvaluate } from './bridge';
const discountedPrices = batchEvaluate('round(price * (1 - discount), 2)', rows);import { validateFormula } from './bridge';
const { ok, errors, dependencies } = validateFormula('if(score > 100, "A", "B")');
// ok → true
// errors → []
// dependencies → ['score']| Function | Signature | Description |
|---|---|---|
abs |
abs(n) |
Absolute value |
ceil |
ceil(n) |
Round up to integer |
floor |
floor(n) |
Round down to integer |
round |
round(n, decimals?) |
Round to N decimal places |
sqrt |
sqrt(n) |
Square root |
pow |
pow(base, exp) |
Exponentiation |
log |
log(n, base?) |
Logarithm (base 10 default) |
min |
min(a, b, …) |
Minimum of values or array |
max |
max(a, b, …) |
Maximum of values or array |
mod |
mod(n, divisor) |
Modulo |
sign |
sign(n) |
-1, 0 or 1 |
clamp |
clamp(n, min, max) |
Clamp value to range |
pi |
pi() |
π constant |
e |
e() |
Euler's number |
| Function | Signature | Description |
|---|---|---|
upper |
upper(s) |
Uppercase |
lower |
lower(s) |
Lowercase |
len |
len(s) |
Character count (UTF-8 aware) |
concat |
concat(a, b, …) |
Join strings |
join |
join(sep, arr) |
Array → delimited string |
trim |
trim(s) |
Strip leading/trailing whitespace |
substr |
substr(s, start, len?) |
Substring |
contains |
contains(s, sub) |
Case-sensitive substring check |
startsWith |
startsWith(s, prefix) |
Prefix check |
endsWith |
endsWith(s, suffix) |
Suffix check |
replace |
replace(s, find, rep) |
First occurrence substitution |
split |
split(s, sep) |
String → array |
| Function | Signature | Description |
|---|---|---|
now |
now() |
Current timestamp (ms) |
today |
today() |
Midnight of current day |
dateAdd |
dateAdd(d, n, unit) |
Add years/months/weeks/days/hours |
dateDiff |
dateDiff(a, b, unit) |
Difference in given unit |
dateFormat |
dateFormat(d, fmt) |
Format with YYYY, MM, DD, HH, mm tokens |
year |
year(d) |
Extract year |
month |
month(d) |
Extract month (1–12) |
day |
day(d) |
Extract day of month |
weekday |
weekday(d) |
Day of week (0=Sun … 6=Sat) |
timestamp |
timestamp(d) |
Date → Unix ms |
| Function | Signature | Description |
|---|---|---|
count |
count(arr) |
Number of non-empty elements |
sum |
sum(arr) |
Sum of numeric elements |
avg |
avg(arr) |
Arithmetic mean |
min |
min(arr) |
Minimum element |
max |
max(arr) |
Maximum element |
unique |
unique(arr) |
Deduplicated array |
flatten |
flatten(arr) |
Recursively flatten nested arrays |
first |
first(arr) |
First element or empty |
last |
last(arr) |
Last element or empty |
| Function | Signature | Description |
|---|---|---|
if |
if(cond, then, else) |
Ternary |
ifs |
ifs(c1, v1, c2, v2, …) |
Multi-branch ternary |
switch |
switch(val, case1, res1, …, default) |
Pattern switch |
and |
and(a, b, …) |
Logical AND (callable form) |
or |
or(a, b, …) |
Logical OR (callable form) |
not |
not(a) |
Logical NOT (callable form) |
| Category | Operators |
|---|---|
| Arithmetic | + - * / % ** |
| Comparison | == != < > <= >= |
| Logical | and or not |
| String | + (concatenation) |
| Property access | prop["field name"] |
The 10 examples below are ordered from beginner (1) to advanced (10).
price * quantity
Context: { price: 29.99, quantity: 4 }
Result: 119.96
Simple multiplication. The engine coerces property values to numbers automatically.
round(price * 1.2, 2)
Context: { price: 49.99 }
Result: 59.99
Wraps an arithmetic expression in round() to control decimal places.
upper(first_name) + " " + upper(last_name)
Context: { first_name: "Jane", last_name: "doe" }
Result: "JANE DOE"
Demonstrates string concatenation with + and the upper() text function.
if(stock > 0, "In Stock", "Out of Stock")
Context: { stock: 12 }
Result: "In Stock"
The most common use of if(). Condition is a comparison expression, branches are string literals.
dateFormat(due_date, "YYYY-MM-DD")
Context: { due_date: 1743292800000 }
Result: "2025-03-30"
Converts a Unix-ms timestamp stored in a date property into a readable string.
if(
dateDiff(today(), due_date, "day") < 0,
"Overdue by " + abs(dateDiff(today(), due_date, "day")) + " days",
if(
dateDiff(today(), due_date, "day") == 0,
"Due today",
dateDiff(today(), due_date, "day") + " days left"
)
)
Context: { due_date: <tomorrow's timestamp> }
Result: "1 days left"
Nested if() with dateDiff and abs to render an urgency label from a raw date.
ifs(
discount >= 0.3, "🔥 Big Deal — " + round(discount * 100, 0) + "% off",
discount >= 0.15, "Sale — " + round(discount * 100, 0) + "% off",
discount > 0, round(discount * 100, 0) + "% off",
"Full price"
)
Context: { discount: 0.35 }
Result: "🔥 Big Deal — 35% off"
Uses ifs() as a multi-branch classifier. The last argument without a paired condition is the default fallback.
clamp(
round((completed / if(total == 0, 1, total)) * 100, 1),
0,
100
)
Context: { completed: 7, total: 8 }
Result: 87.5
Guards against division-by-zero with if(total == 0, 1, total), computes a percentage, rounds, then clamps to a 0–100 band.
lower(
replace(
replace(
trim(first_name) + "-" + trim(last_name),
" ", "-"
),
"--", "-"
)
)
Context: { first_name: " Alice ", last_name: "Van Der Berg" }
Result: "alice-van-der-berg"
Chains trim, +, two replace calls, and lower to produce a URL-safe identifier.
round(
clamp(
(
if(priority == "critical", 40, if(priority == "high", 20, 5))
+ if(dateDiff(today(), due_date, "day") < 2, 35, if(dateDiff(today(), due_date, "day") < 7, 15, 0))
+ if(len(assignee) == 0, 20, 0)
+ if(prop["story_points"] > 8, 10, 0)
),
0, 100
),
0
)
Context: { priority: "critical", due_date: <2 days away>, assignee: "", story_points: 13 }
Result: 95
Combines multi-field boolean conditions, date arithmetic, string checks, property access via prop["key"], weighted addition, and a final clamp-and-round. Models a real-world risk-scoring formula where several independent factors each contribute a penalty score.
The raw WASM exports (consumed by bridge.ts) are:
| Export | Signature | Description |
|---|---|---|
compile |
(formula: string) → JSON |
Compile and cache; returns { ok, handle, error, error_pos } |
evaluate |
(handle: number, propsJson: string) → JSON |
Run cached handle; returns { ok, value, error } |
batch_evaluate |
(handle: number, rowsJson: string) → JSON |
Run over array of rows; returns { ok, values, error } |
eval_formula |
(formula: string, propsJson: string) → JSON |
Compile + evaluate in one call |
validate |
(formula: string) → JSON |
Returns { ok, errors[], dependencies[] } |
get_dependencies |
(handle: number) → JSON |
Property names the formula reads |
free_formula |
(handle: number) → void |
Release handle from WASM cache |
clear_formula_cache |
() → void |
Clear all cached handles |
formula_handle_count |
() → number |
Current number of cached handles |
All propsJson and rowsJson arguments expect the FormulaValue envelope format:
{ "field_name": { "type": "number", "value": 42 } }The TypeScript bridge handles this serialisation automatically.
Compile and validation errors include source positions for editor highlighting:
{
"ok": false,
"error": "unexpected token ')'",
"error_pos": [12, 13]
}error_pos is a [start_byte, end_byte] pair relative to the formula string.
MIT