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.
Overview
Expressions are JavaScript-like code snippets embedded in schemas using the ${} syntax:
{
"type": "text",
"content": "Hello, ${user.name}!"
}With data:
const data = { user: { name: "Alice" } }This renders: "Hello, Alice!"
Basic Syntax
Simple Property Access
Access data properties using dot notation:
{
"type": "text",
"content": "${user.firstName}"
}Nested Properties
Access nested objects:
{
"type": "text",
"content": "${user.address.city}"
}Array Access
Access array elements:
{
"type": "text",
"content": "${users[0].name}"
}String Interpolation
Mix expressions with static text:
{
"type": "text",
"content": "Welcome, ${user.firstName} ${user.lastName}!"
}Operators
Arithmetic Operators
{
"type": "text",
"content": "Total: ${price * quantity}"
}Supported: +, -, *, /, %
Comparison Operators
{
"type": "text",
"content": "${score >= 90 ? 'Top grade' : 'Keep going'}"
}Supported: >, <, >=, <=, ==, ===, !=, !==
Logical Operators
{
"type": "button",
"visibleOn": "${user.isAdmin && user.isActive}"
}Supported: &&, ||, !
Ternary Operator
{
"type": "text",
"content": "${count > 0 ? count + ' items' : 'No items'}"
}Conditional Properties
visibleOn
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}"
}disabledOn
Disable component when expression is true:
{
"type": "button",
"label": "Submit",
"disabledOn": "${form.submitting || !form.isValid}"
}Data Context
Accessing Root Data
The root data object is available directly:
const data = {
user: { name: "Alice" },
settings: { theme: "dark" }
}
<SchemaRenderer schema={schema} data={data} />{
"type": "text",
"content": "Theme: ${settings.theme}"
}Scoped Data
Some components provide scoped data:
{
"type": "list",
"items": "${users}",
"itemTemplate": {
"type": "card",
"title": "${item.name}", // 'item' is scoped data
"description": "${item.email}"
}
}Index in Loops
Access the current index in loops:
{
"type": "list",
"items": "${users}",
"itemTemplate": {
"type": "text",
"content": "#${index + 1}: ${item.name}"
}
}Built-in Functions
String Functions
{
"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)
Array Functions
{
"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)
Number Functions
{
"type": "text",
"content": "Price: ${price.toFixed(2)}"
}Available:
toFixed(decimals)toPrecision(digits)toString()
Math Functions
{
"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()
Date Functions
{
"type": "text",
"content": "${new Date().toLocaleDateString()}"
}Complex Expressions
Nested Ternary
{
"type": "text",
"content": "${
status === 'active' ? 'Active' :
status === 'pending' ? 'Pending review' :
status === 'error' ? 'Failed' :
'Unknown'
}"
}Combining Operators
{
"type": "alert",
"visibleOn": "${
(user.role === 'admin' || user.role === 'moderator') &&
user.isActive &&
!user.isSuspended
}"
}Array Methods
{
"type": "text",
"content": "${
users
.filter(u => u.isActive)
.map(u => u.name)
.join(', ')
}"
}Practical Examples
User Greeting
{
"type": "text",
"content": "${
new Date().getHours() < 12 ? 'Good morning' :
new Date().getHours() < 18 ? 'Good afternoon' :
'Good evening'
}, ${user.firstName}!"
}Status Badge
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.
Price Formatting
{
"type": "text",
"content": "$${(price * quantity).toFixed(2)}"
}Empty State
{
"type": "empty",
"visibleOn": "${items.length === 0}",
"message": "No items to display",
"description": "Start by adding your first item"
}Percentage Bar
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}"
}Conditional Styling
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}"
}Form Expressions
Dependent Fields
{
"type": "form",
"body": [
{
"type": "select",
"name": "country",
"label": "Country",
"options": ["USA", "Canada", "Mexico"]
},
{
"type": "select",
"name": "state",
"label": "State/Province",
"visibleOn": "${form.country === 'USA'}",
"options": ["CA", "NY", "TX"]
}
]
}Dynamic Validation
{
"type": "input",
"name": "email",
"label": "Email",
"required": true,
"validations": {
"isEmail": true,
"errorMessage": "Please enter a valid email"
}
}Computed Fields
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: ${form.price * form.quantity}"
}Performance Considerations
Expensive Computations
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 in dataconst data = {
processedUsers: users.map(u => expensiveOperation(u))
}Caching
The expression engine automatically caches results when data doesn't change.
Security
Sandboxed Execution
Expressions run in a sandboxed environment and can only access:
- The data context you provide
- Built-in JavaScript functions (Math, Date, String, Array methods)
They cannot access:
- Browser APIs (window, document, localStorage)
- Node.js APIs (fs, path, etc.)
- Global variables
- Function constructors
Sanitization
All expression outputs are automatically sanitized to prevent XSS attacks.
Debugging Expressions
Expression Errors
Invalid expressions show helpful error messages:
{
"type": "text",
"content": "${user.invalidProperty}"
}Error: "Cannot read property 'invalidProperty' of undefined"
Debug Mode
Enable debug mode to see expression evaluation:
<SchemaRenderer
schema={schema}
data={data}
debug={true}
/>This logs all expression evaluations to the console.
Advanced Usage
Custom Functions
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. A component expression rendered by
SchemaRenderer resolves against the scope the renderer itself builds — the
provider's data source (as data), the host scope (user / current_user) and
page variables — 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"}')Custom Operators
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')}"
}Best Practices
1. Keep Expressions Simple
// ❌ 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}"
}2. Use Meaningful Variable Names
// ❌ Bad
{
"visibleOn": "${x && y || z}"
}
// ✅ Good
{
"visibleOn": "${isAdmin && isActive || isSuperUser}"
}3. Handle Null/Undefined
// ❌ Bad: Might throw error
{
"content": "${user.address.city}"
}
// ✅ Good: Safe access
{
"content": "${user.address?.city || 'N/A'}"
}4. Use TypeScript
Define your data types:
interface UserData {
user: {
name: string
role: 'admin' | 'user'
isActive: boolean
}
}
const data: UserData = { /* ... */ }
<SchemaRenderer schema={schema} data={data} />Next Steps
- Schema Rendering - Learn the rendering engine
- Component Registry - Understand components
- Schema Overview - Explore schema specifications
Related Documentation
@object-ui/coreREADME - Expression evaluator API- Form Plugin - Form-specific expressions
- View Plugin - Data view expressions