Expression functions
Expressions can call functions. driven-form ships the
aggregates (sum, count, min, max,
avg), and you can register your own:
import { registerExpressionFunction } from 'driven-form'
// Once, at module scope — before any schema that uses it is parsed.registerExpressionFunction('median', (rows, column) => { const nums = (Array.isArray(rows) ? rows : []) .map(row => Number(row?.[String(column)])) .filter(n => !Number.isNaN(n)) .sort((a, b) => a - b)
if (nums.length === 0) return null // match the built-ins' "no data" case const mid = Math.floor(nums.length / 2) return nums.length % 2 ? nums[mid] : (nums[mid - 1] + nums[mid]) / 2})Now any schema can use it:
{ "name": "medianSalary", "type": "number", "label": "Median salary", "calculateValue": "median(%employees%, 'salary')"}The API
Section titled “The API”registerExpressionFunction(name: string, fn: (...args: unknown[]) => unknown): voidunregisterExpressionFunction(name: string): voidgetExpressionFunction(name: string): ExpressionFunction | undefinedAll three are exported from driven-form (the core entry, not /react) —
expressions are pure JS and don’t need React.
- Names must be valid identifiers.
myFn,_lookup,$rateare fine; anything else throws. - Some names are reserved and throw:
Number,String,Boolean,Math,Date,new,typeof,null,true,false,undefined. These mean something to the evaluator itself. - The value must be a function, or it throws.
- Functions must be pure and synchronous. They run during render, on every dependency change. No fetches, no timers, no mutation of the arguments. Async data belongs in a field’s value, not an expression.
Shadowing and removal
Section titled “Shadowing and removal”Registering a built-in name shadows it — registerExpressionFunction('sum', …)
replaces the built-in sum everywhere. unregisterExpressionFunction('sum')
puts the original back. Re-registering an existing name simply replaces it.
Expression results are memoized, but the memo key includes a generation counter that’s bumped on every registration and removal, so a re-registered function can never serve results computed by the old one.
Where the trust boundary sits
Section titled “Where the trust boundary sits”This is the same trust model as the
validator registry: the
function is ordinary JavaScript that you wrote and imported. The expression
itself stays sandboxed — it’s parsed to an AST and evaluated by a custom
interpreter, never eval() or Function(). Only names in this registry (plus
the evaluator’s own Number/String/Boolean/Math) are callable, so a
schema from your backend can’t reach anything you haven’t handed it.
That’s the useful property: a backend, a CMS or an LLM can author expressions freely, and the worst it can do is call a function you chose to expose.
Registering at the right time
Section titled “Registering at the right time”Registration is module-level and global, not per-form. Call it at module scope in a file that’s imported before your form renders:
import { registerExpressionFunction } from 'driven-form'
registerExpressionFunction('lookup', (table, key) => (Array.isArray(table) ? table : []).find(row => row?.id === key)?.value ?? null,)import './formFunctions' // side-effect import, onceAn expression naming a function that was never registered fails the same way any
unbound identifier does — it resolves to undefined, which usually surfaces as
a field that won’t calculate or a condition that’s quietly false. If a
calculated field isn’t updating, check the registration ran first.