| title | Expression System |
|---|
Object UI includes a powerful expression system that enables dynamic, data-driven UIs. Expressions allow you to reference data, compute values, and create conditional logic directly in your JSON schemas.
Expressions are JavaScript-like code snippets embedded in schemas using the ${} syntax:
{
"type": "text",
"content": "Hello, ${user.name}!"
}With this published as the expression scope:
const scope = { user: { name: "Alice" } }This renders: "Hello, Alice!" — user is a root because the host published a key by that
name. Data Context below shows the provider that publishes one.
Access data properties using dot notation:
{
"type": "text",
"content": "${user.firstName}"
}Access nested objects:
{
"type": "text",
"content": "${user.address.city}"
}Access array elements:
{
"type": "text",
"content": "${users[0].name}"
}Mix expressions with static text:
{
"type": "text",
"content": "Welcome, ${user.firstName} ${user.lastName}!"
}{
"type": "text",
"content": "Total: ${price * quantity}"
}Supported: +, -, *, /, %
{
"type": "text",
"content": "${score >= 90 ? 'Top grade' : 'Keep going'}"
}Supported: >, <, >=, <=, ==, ===, !=, !==
{
"type": "button",
"visibleOn": "${user.isAdmin && user.isActive}"
}Supported: &&, ||, !
{
"type": "text",
"content": "${count > 0 ? count + ' items' : 'No items'}"
}Show component when expression is true:
{
"type": "button",
"label": "Admin Panel",
"visibleOn": "${user.role === 'admin'}"
}hiddenOn
Hide component when expression is true:
{
"type": "section",
"hiddenOn": "${user.settings.hideSection}"
}Disable component when expression is true:
{
"type": "button",
"label": "Submit",
"disabledOn": "${record.status === 'submitted'}"
}Expression scope does not arrive as a prop. SchemaRenderer declares exactly one prop,
schema, and forwards every other prop it is handed straight through to the component the
schema names — so a data, dataSource or debug written on the element is neither read nor
refused. Nothing throws, and there is one line on the console; the expression simply never
resolves, and an unresolvable template is returned as its own source text, so the characters
you typed are what the reader sees.
The host publishes its values with PredicateScopeProvider, and every key it publishes becomes
a root:
import { PredicateScopeProvider, SchemaRenderer } from '@object-ui/react'
import type { DeclaredNode } from '@object-ui/types'
// The page schema — your own document.
declare const schema: DeclaredNode
// Every name here becomes a root the schema's expressions can read.
const scope = {
user: { name: 'Alice' },
settings: { theme: 'dark' },
}
function App() {
return (
<PredicateScopeProvider scope={scope}>
<SchemaRenderer schema={schema} />
</PredicateScopeProvider>
)
}{
"type": "text",
"content": "Theme: ${settings.theme}"
}The scope the evaluator builds is what you published, plus two names the renderer supplies:
| name | what it holds |
|---|---|
every key of scope |
exactly what you put there — user, settings, whatever the page needs |
record |
the row a record surface is bound to, when there is one |
page |
page-local variables, for predicates that gate on another component's state |
current_user is an alias of whatever you published as user; an app built on
@object-ui/app-shell does not mount the provider itself, because the shell's
ExpressionProvider already feeds the same channel with the signed-in user and features.
dataSourceis not an expression root.SchemaRendererProvider'sdataSourcecarries the host'sDataSourceadapter — the object renderers callfind()on. The renderer used to publish that adapter under the namedata; an adapter answers nodata.*path, so the root was constant for every conformant host, and objectui#9308 removed it. A${data.…}expression now reads whatever you published underdata, and nothing if you published none. This changes the verdict of a gate already in the field:visible: "${data.x}"used to resolve toundefinedand HIDE its node on every row; with nodatakey at all it is unevaluable, this surface fails soft, and the node is SHOWN. At the runtime layer the row isrecord(ADR-0089 D3) — rewrite such a gate torecord.*rather than re-publishing adatakey.
Some components provide scoped data:
Access the current index in loops:
{
"type": "list",
"items": "${users}",
"itemTemplate": {
"type": "text",
"content": "#${index + 1}: ${item.name}"
}
}{
"type": "text",
"content": "${user.name.toUpperCase()}"
}Available:
toUpperCase(),toLowerCase()trim(),trimStart(),trimEnd()substring(start, end)replace(search, replace)split(separator)includes(substring)startsWith(prefix),endsWith(suffix)
{
"type": "text",
"content": "Total users: ${users.length}"
}{
"type": "text",
"content": "${users.map(u => u.name).join(', ')}"
}Available:
lengthmap(fn),filter(fn),reduce(fn, initial)join(separator)slice(start, end)includes(item)find(fn),findIndex(fn)some(fn),every(fn)
{
"type": "text",
"content": "Price: ${price.toFixed(2)}"
}Available:
toFixed(decimals)toPrecision(digits)toString()
{
"type": "text",
"content": "${Math.round(average)}"
}Available: All standard Math functions
Math.round(),Math.floor(),Math.ceil()Math.min(),Math.max()Math.abs()Math.random()
{
"type": "text",
"content": "${new Date().toLocaleDateString()}"
}{
"type": "text",
"content": "${status === 'active' ? 'Active' : status === 'pending' ? 'Pending review' : status === 'error' ? 'Failed' : 'Unknown'}"
}{
"type": "alert",
"visibleOn": "${(user.role === 'admin' || user.role === 'moderator') && user.isActive && !user.isSuspended}"
}{
"type": "text",
"content": "${users.filter(u => u.isActive).map(u => u.name).join(', ')}"
}{
"type": "text",
"content": "${new Date().getHours() < 12 ? 'Good morning' : new Date().getHours() < 18 ? 'Good afternoon' : 'Good evening'}, ${user.firstName}!"
}badge has no row in the expression carriage map, so its label and variant
are read off the node exactly as written — a ${…} in either reaches the screen
as those characters. Resolve both in the data you hand the renderer and author
the node with the resolved values; the condition keys are evaluated on every
type and stay expressions:
{
"type": "badge",
"label": "Completed",
"variant": "secondary",
"visibleOn": "${status !== 'draft'}"
}Two neighbouring traps: text is not a badge key at all — the badge's text is
label — and variant is a closed set: default, secondary, destructive,
outline.
{
"type": "text",
"content": "$${(price * quantity).toFixed(2)}"
}{
"type": "empty",
"visibleOn": "${items.length === 0}",
"message": "No items to display",
"description": "Start by adding your first item"
}progress has no row in the expression carriage map, so its value and label
are read off the node exactly as written — a ${…} in either reaches the screen
as those characters. Compute the percentage in the data you hand the renderer;
the condition keys are evaluated on every type and stay expressions:
{
"type": "progress",
"value": 75,
"label": "75% complete",
"visibleOn": "${total > 0}"
}className has no row in the carriage map either — on card or on any other
type — so an expression written there lands in the rendered class attribute as
its own source text. Resolve the class list in the data you hand the renderer,
or author each variant and gate it with a condition key:
{
"type": "card",
"title": "${task.name}",
"className": "border-red-500 border-2",
"visibleOn": "${task.isPriority}"
}A form's own values are the row under edit, and the row is bound as record and nothing
else — the bare shorthand (country) and the wrong-layer data.country were both retired on
runtime record surfaces (objectui#5330 phase 2), and record is the canonical runtime-layer
root (ADR-0089 D3). There is no form root: a predicate written against one is unevaluable,
and a fail-soft surface answers it by showing the field on every row.
{
"type": "form",
"children": [
{
"type": "select",
"name": "country",
"label": "Country",
"options": ["USA", "Canada", "Mexico"]
},
{
"type": "select",
"name": "state",
"label": "State/Province",
"visibleOn": "${record.country === 'USA'}",
"options": ["CA", "NY", "TX"]
}
]
}{
"type": "input",
"name": "email",
"label": "Email",
"required": true,
"validations": {
"isEmail": true,
"errorMessage": "Please enter a valid email"
}
}input has no row in the expression carriage map either, so a computed total
cannot be carried by its value. Show it with a text node, whose content is
evaluated on every component type:
{
"type": "text",
"content": "Total: ${record.price * record.quantity}"
}Expressions are re-evaluated when data changes. Avoid expensive operations:
❌ Bad: Complex computation in expression
{
"type": "text",
"content": "${users.map(u => expensiveOperation(u)).join(', ')}"
}✅ Good: Pre-compute, and publish the result
const scope = {
processedUsers: users.map(u => expensiveOperation(u))
}The expression engine automatically caches results when data doesn't change.
Expressions run in a sandboxed environment and can only access:
- The names the host published as scope, plus
recordandpage - The globals listed in
SAFE_GLOBALS(packages/core/src/evaluator/SafeExpressionParser.ts):Math,JSON,parseInt,parseFloat,isNaN,isFinite - Methods on the values those names hold — string, number and array methods
new Date(...)andnew RegExp(...), the only two constructorsnewaccepts
A global that is not on that list fails the way every unresolved name fails.
Date,StringandArrayare reachable only in the two forms above —Dateas a constructor,StringandArrayas methods on a value you already hold. Written as globals they resolve to nothing:${Date.now()},${String(x)}and${Array.isArray(x)}each raise inside the parser, and this surface fails soft, so the template is returned as its own source text with one line on the console — the characters you typed are what the reader sees.
They cannot access:
- Browser APIs (window, document, localStorage)
- Node.js APIs (fs, path, etc.)
- Global variables
- Function constructors
All expression outputs are automatically sanitized to prevent XSS attacks.
An expression that cannot be resolved is not an error the reader sees, and it is not the same failure in both directions. Measured on the built evaluator:
{
"type": "text",
"content": "${user.invalidProperty}"
}| the scope | what the evaluator returns |
|---|---|
user is published, invalidProperty is not a member of it |
undefined — nothing is thrown |
no user root at all |
the template's own source text, and one line on the console |
So a missing member renders as nothing, and a missing root renders as the characters you typed. Neither raises, and neither stops the render — which is why the scope a page publishes has to be stated rather than assumed.
debug is read off the same provider context as dataSource
(context?.debug || context?.debugFlags?.enabled), never off the element — a debug written
on SchemaRenderer is forwarded to the component the schema names, exactly like a data prop,
and turns nothing on. Mount the provider instead:
<SchemaRendererProvider dataSource={null} debug>
<SchemaRenderer schema={schema} />
</SchemaRendererProvider>This logs all expression evaluations to the console. It is orthogonal to the expression scope:
wrap this pair in a PredicateScopeProvider as well when you want both.
There is no global evaluator to extend: SchemaRenderer builds a fresh
ExpressionEvaluator for each evaluation, so a function has to reach it through
the evaluation context. Anything callable you put in the context is callable in
an expression, under exactly the name you gave it:
import { evaluateExpression } from '@object-ui/core'
const formatCurrency = (value: number) =>
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(value)
// => '$1,234.50'
evaluateExpression('${formatCurrency(price)}', { formatCurrency, price: 1234.5 })That is the direct-evaluation path, and formatCurrency is a root there because this call
hands the evaluator its own context. A component expression rendered by SchemaRenderer
resolves against a different scope — the names the host published through
PredicateScopeProvider, plus record and page — so a function you registered elsewhere is
not reachable from a schema expression. Compute the value before it reaches the schema, and
bind the result.
Hold an evaluator when you want one context reused — construct it, then call
evaluate:
import { ExpressionEvaluator } from '@object-ui/core'
const evaluator = new ExpressionEvaluator({ user: { role: 'admin' } })
evaluator.evaluate('${user.role === "admin"}')Expressions are JavaScript, evaluated against the context — operators are the language's own. A membership test is written with the array method:
{
"type": "button",
"visibleOn": "${user.permissions.includes('admin')}"
}❌ Bad: Too complex
{
"content": "${users.filter(u => u.age > 18).map(u => ({...u, isAdult: true})).reduce((acc, u) => acc + u.score, 0)}"
}✅ Good: Pre-compute complex logic
{
"content": "${adultUsersScore}"
}❌ Bad
{
"visibleOn": "${x && y || z}"
}✅ Good
{
"visibleOn": "${isAdmin && isActive || isSuperUser}"
}❌ Bad: Might throw error
{
"content": "${user.address.city}"
}✅ Good: Safe access
{
"content": "${user.address?.city || 'N/A'}"
}Define the type of the scope you publish:
interface AppScope {
user: {
name: string
role: 'admin' | 'user'
isActive: boolean
}
}
const scope: AppScope = { /* ... */ }
<PredicateScopeProvider scope={scope}>
<SchemaRenderer schema={schema} />
</PredicateScopeProvider>- Schema Rendering - Learn the rendering engine
- Component Registry - Understand components
- Schema Overview - Explore schema specifications
@object-ui/coreREADME - Expression evaluator API- Form Plugin - Form-specific expressions
- View Plugin - Data view expressions
{ "type": "list", "items": "${users}", "itemTemplate": { "type": "card", "title": "${item.name}", // 'item' is scoped data "description": "${item.email}" } }