Skip to content

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')"
}

Open in playground

registerExpressionFunction(name: string, fn: (...args: unknown[]) => unknown): void
unregisterExpressionFunction(name: string): void
getExpressionFunction(name: string): ExpressionFunction | undefined

All 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, $rate are 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.

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.

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.

Registration is module-level and global, not per-form. Call it at module scope in a file that’s imported before your form renders:

src/formFunctions.ts
import { registerExpressionFunction } from 'driven-form'
registerExpressionFunction('lookup', (table, key) =>
(Array.isArray(table) ? table : []).find(row => row?.id === key)?.value ?? null,
)
src/main.tsx
import './formFunctions' // side-effect import, once

An 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.