Skip to Content
ExpressionsThe expression evaluator

The expression evaluator

A model writes every expr, and a saved document replays in other people’s browsers, so an expression is untrusted input.

@uicast/expr defines the language expressions are written in, a subset of JavaScript, and runs it. The subset is JavaScript expressions over JSON data: the standard methods of strings, numbers and arrays and a short list of globals, without statements, new, mutation, regular expressions or function values.

How an expression runs

Evaluator parses an expression once with acorn and runs it itself, checking every property read and every call under a CPU and allocation budget. No source reaches the JavaScript engine, so a strict Content-Security-Policy without unsafe-eval works.

import { Evaluator } from "@uicast/expr"; const evaluator = new Evaluator({ functions: tools }); <RendererProvider evaluator={evaluator} /> // the React binding

The checks happen at run time, because a name can be built while the expression runs:

"abc".substr(1) // ❌ refused — `substr` is not in the language "abc"["sub" + "str"](1) // ❌ the same call — the name exists only at run time

Under Evaluator the call itself is checked, so both lines are refused. Three rules do most of the work:

  • Own properties only. Nothing inherited is readable, so ({}).constructor is undefined. No deny-list is needed.
  • Only data is readable. Objects, arrays, primitives, a short list of built-ins. A class instance, a DOM node or a function fails on the first read, so a real MouseEvent in scope cannot leak evt.target.ownerDocument.
  • Methods are called, never passed around. rows.map as a value is an error.

Where expressions appear

Every site, in one product search (scopes.root.products is seeded higher up):

{ "key": "search", "component": "SearchInput", "seed": [{ "set": "scopes.root.query", "literal": "" }], "props": { "expr": "({ value: scopes.root.query })" }, "callbacks": { "onChange": [{ "set": "scopes.root.query", "expr": "evt.value" }] } } { "key": "empty", "component": "Typography", "hidden": "scopes.root.products.length > 0", "props": { "literal": { "text": "No products" } } } { "key": "rows", "component": "Typography", "as": "row", "keyBy": "id", "each": "scopes.root.products.filter(p => p.name.includes(scopes.root.query))", "props": { "expr": "({ text: scopes.row.name })" } }

props is a value source; hidden, loading and each are bare strings. Inside callbacks, an expression also sees evt. A set step also sees currentValue, the value at that path now.

Only seed and callbacks await a result, so only they may call host functions. A host call in props, hidden, loading or each is rejected into the error slot.

A scope path read in props, hidden, loading or each subscribes the element to it. The paths are found in the expression text; nothing is declared. See Reactivity & Dependencies.

The language

The grammar is a closed allow-list of AST node types; anything else fails validation.

KindAllowed
Valuesnumbers, strings, booleans, null, template literals
Accessa.b, a[b], a?.b, a?.[b] (optional receiver, not a?.())
Callsmethods, host functions
Operatorsarithmetic, comparison, ===/==, &&, ||, ??, !, -, +, typeof, ? :
Literalsarrays and objects, with spread and computed keys
Functionsarrow functions with an expression body, only as a method’s callback; parameters may destructure

Left out on purpose:

  • Statements and declarations. No const/let/var, loops, try, blocks or function. A runaway loop cannot be written, and with nothing named, nothing can recurse.
  • Assignment, ++, delete, mutating methods. Expressions are pure by construction: only a step’s set changes state. .toSorted() and .toReversed() replace .sort() and .reverse().
  • Regular expressions. Catastrophic backtracking runs outside the budget. Text search goes through .includes(), .startsWith(), .split() and .replaceAll().
  • Functions as values. An arrow is only a method’s callback, as in rows.map(r => r.name): never stored, returned or called. A global that takes one argument can replace it: rows.filter(Boolean).
  • await. The runtime awaits a host call, so the call must be the result: the whole expression, a branch of ?:, or the right side of ??, ||, &&. getUser({ id }).name and ids.map(id => getUser({ id })) are refused before any call runs.
  • Intl. The locale methods still take a locale and options: price.toLocaleString(undefined, { style: "currency", currency: "USD" }).
  • new, so Date values, Set and Map. Every value is JSON. A date is an ISO string or a timestamp from Date.parse(text) or Date.now(); format it in a component (see DateTime). An object does a Map’s job, and Object.keys(Object.groupBy(rows, r => r.tag)) lists distinct tags.
  • forEach, iterators. Callbacks have no effects, and functions are not values.
  • in, bitwise operators, x?.(), instanceof, tagged templates, this. None shapes a display value.

A callback body is one expression too, never a block:

// ✅ a ternary inside the callback scopes.root.rows.map(r => r.status === "paid" ? "green" : "red") // ❌ rejected — a block body is a statement list, and there are no statements scopes.root.rows.map(r => { switch (r.status) { case "paid": return "green" } })

Expressions are also stateless: no Math.random (a random value comes from a host function) and nowhere to keep a counter. Date.now() changes on every re-evaluation, so the prompt tells the model to take it once in a step and read it back:

// ❌ a new timestamp every render pass { "props": { "expr": "({ text: 'Draft ' + Date.now() })" } } // ✅ produced once, read back from the path { "seed": [{ "set": "scopes.root.draftId", "expr": "Date.now()" }], "props": { "expr": "({ text: 'Draft ' + scopes.root.draftId })" } }

getExpressionsPartialPrompt() gives the model these rules and the globals, filled from the evaluator’s own tables.

Budgets

Every expression stops: evaluation throws when the budget runs out. Only built-in methods iterate, and Evaluator implements each one, so every iteration is charged:

  • a step counter (1,000,000 by default): a callback invocation costs its body’s node count, a call costs its work (a sort about n·log n steps);
  • a wall-clock deadline (100 ms), on a clock the expression cannot read or move;
  • allocation caps, so "x".repeat(1e9) is refused, not attempted;
  • an AST depth limit, so nested source cannot overflow the compiler;
  • a source-length limit (maxSourceLength, 1000 characters by default), checked before parsing. The prompt states it to the model.

The step counter is the contract. Each operation is priced by its measured time, and a test keeps each within 4× a plain step, so a long evaluation stops at the same step on any device. The clock is a backstop: in Chrome at 6× CPU slowdown (a mid-range phone) the step limit still fires first; at 12×, locale formatting or comparing two long strings can reach the clock first.

When an expression is rejected

A rejection is a classified EntryError, a document fault shown in the element’s error slot:

expressionreason
if (scopes.root.n) 1expression-syntax — a statement, not an expression
scopes.root.count = 1guardrail-violation — assignment is not in the language
scopes.root.rows["so" + "rt"]()guardrail-violation — checked at the call, computed or not
fetchProducts()unknown-reference — a name nobody handed in

Performance

The numbers below come from a fast computer, and from Chrome with its CPU slowed 6× (a mid-range phone) and 12× (a slow phone).

A page with a 50-row table (7 cells a row, the reference catalog) rendered by React, with its expressions run by Evaluator or compiled with new Function. Time from the event to the updated DOM:

eventevaluatorfast computermid-range phoneslow phone
first renderEvaluator15 ms37 ms78 ms
new Function14 ms34 ms71 ms
next pageEvaluator15 ms36 ms76 ms
new Function15 ms34 ms70 ms
edit a rowEvaluator1.5 ms4.6 ms8.8 ms
new Function1.3 ms3.9 ms8.4 ms

Most of each event is React and the DOM, the same under either evaluator. Per expression, on the fast computer:

expressionin a documentEvaluatornew Function
scopes.root.count > 0a hidden condition~45 ns~4 ns
scopes.root.user.nameany scope read~75 ns~6 ns
template, two interpolationsa text prop~160 ns~28 ns
object literal from scopeevery props.expr~180 ns~9 ns
filter, map, reduce over 1000 rowsa derived list20–39 µs0.7–2 µs
re-evaluate a whole 1000-row table, 5 expressions per rowthe realistic worst case~0.65 ms~0.09 ms

Two prompt rules keep the load small. Neither is an Evaluator limit:

  • maxListItems on getCommonInstructionsPartialPrompt (default 100): the model caps each list at that many items and pages past it.
  • The model leaves real computation to host functions. It asks a function for the page, sort and filters it offers, and reads the totals it returns instead of summing rows. Give your functions those inputs and outputs.

Expression time of whole events (every expression one event re-evaluates), in Chrome:

eventfast computermid-range phoneslow phone
50-row table, 7 cells per row0.3 ms1.3 ms4.5 ms
100-row table, currency and date formatting per row0.4 ms2.5 ms5.1 ms
dashboard over 1,000 orders: 3 KPIs, a chart, a 30-day series1.6 ms11 ms21 ms
search keystroke over 1,000 orders1.1 ms7.1 ms16 ms
the same over 10,000 orders9.8 ms60 ms123 ms
kanban board, 300 cards0.2 ms3.5 ms3.5 ms
join 1,000 orders to 200 customers with find() per order5.2 ms33 ms68 ms

A frame is 16 ms. Two kinds of work belong in a host function: the whole table in the browser, and a search per row.

What it does not secure

Expressions cannot reach window, fetch or the rest of the page. They see only the data and functions you give them. Two things are still up to you:

RiskWhat you do
FunctionsA document can call every function you give it. If you give it deleteProduct, it can delete products.Give only the functions it needs. Check permissions on the server.
URLsA document can put your data into an image URL. The browser sends the data when it loads the image.Declare URL props with z.url(). Then urlPolicy blocks other sites.

The Security model page has more, including the rules for components.

Last updated on