ObjectUIObjectUI

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:

  • length
  • map(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 data
const 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

On this page