ObjectStackObjectStack

Expressions (CEL)

Canonical CEL-based expression language for formulas, predicates, conditions, and dynamic seed values

Expressions

ObjectStack uses a single canonical expression language for every place a piece of metadata needs to compute a value or evaluate a condition:

  • Formula fields (type: 'formula')
  • Predicates — validation condition, sharing condition, conditional visibility (visibleOn), conditional required (conditionalRequired), action disabled, view filter criteria, hook condition, flow decisions
  • Dynamic seed values — fixtures whose value depends on the install-time clock or identity context

The language is CEL (Google's Common Expression Language, Apache-2.0), evaluated through @objectstack/formula (thin wrapper over @marcbachmann/cel-js) plus the ObjectStack standard library.

Why CEL? Formal grammar, abundant public training corpus (so AI authors emit it natively), AST-first persistence, and sandboxed execution with bounded cost. ObjectStack does not ship a Salesforce-flavor DSL — the previous custom 22-function engine was deleted in M9. See north-star §8 "No private expression DSL".


The Expression envelope

Every expression in metadata is persisted as the same envelope:

type Expression = {
  dialect: 'cel' | 'js' | 'cron' | 'template';
  source?: string;          // surface syntax
  ast?: unknown;            // parsed AST (filled by `objectstack compile`)
  meta?: { rationale?: string; generatedBy?: string };
};

ObjectStack ships four registered dialects (M9.9):

DialectUse forHelper
celcomputed values, predicates, defaults, formulascel`...` / F`...` / P`...`
cronrecurring schedules (Job, ETL, sync, exports, jobs)cron`...`
templatetext interpolation ({{path}}) — notif/prompt/titletmpl`...`
jssandboxed L2 hook bodies (TypeScript source)n/a

All dialects share the same variable scope (record, previous, os.user, os.org, os.env, vars for flow steps), so you author once and never have to relearn the syntax across surfaces.

Predicates are bare CEL — never wrap field references in {…} braces. The most common mistake (and the root cause of issue #1491) is a condition like {record.rating} >= 4: in CEL, {…} is a map literal, so it is a parse error. Write bare CEL: record.rating >= 4. Braces belong only in {{ … }} text templates. As of 7.6 a malformed expression no longer silently does nothing — it fails objectstack build and throws at runtime.

Template formatting (7.6). A template hole is a field path with an optional whitelisted formatter — {{ path | formatter[:arg] }} — with defined value→string semantics (no arbitrary logic; put logic in a CEL field):

tmpl`Deal {{record.name}} — {{record.amount | currency}} closes {{record.close_date | date:long}}`

Formatters: currency[:CODE], number[:decimals], percent[:decimals], date[:short|long|iso], datetime[:…], upper, lower, trim, truncate:N, default:'…', json. Single-brace {x} is not a valid template hole.

At input time you may write a bare string for shorthand — the spec transforms it into the right envelope based on the field type. The compiled artifact always contains the full envelope (and, after M9.2, the AST).

import { F, P, cel, cron, tmpl } from '@objectstack/spec';
import { ObjectSchema, Field } from '@objectstack/spec/data';

export const Invoice = ObjectSchema.create({
  name: 'invoice',
  titleFormat: tmpl`Invoice {{record.invoice_no}} – {{record.customer.name}}`,
  fields: {
    total: Field.formula({
      result_type: 'currency',
      formula: F`record.subtotal + record.subtotal * record.tax_rate`,
    }),
    po_number: Field.text({
      conditionalRequired: P`record.amount > 10000`,
    }),
    // M9.9b — declarative default, evaluated at insert time
    issued_date: Field.date({
      defaultValue: cel`today()`,
    }),
  },
});

The three CEL tagged-template helpers — cel, F (formula alias), P (predicate alias) — are interchangeable; pick whichever reads best at the call site. cron and tmpl produce their respective envelopes.


CEL primer

CEL is documented at cel-spec. Cheat-sheet of what you'll write daily:

ConceptSyntax
Field on current recordrecord.first_name
Previous (in update hooks)previous.status
Input to a hookinput.amount
Equality / inequality== / !=
Logical&& / || / !
Ternarycond ? then : else
String literal'single quotes'
Membershiprecord.region in ['us', 'eu']
Field presenthas(record.foo) (returns true even when value is null)

has() gotcha: has(record.x) is true whenever the key exists, even when its value is null. To check for "not blank" use isBlank(...) or record.x != null — see stdlib below.


ObjectStack CEL standard library

Registered automatically into every Environment by @objectstack/formula. All functions are pure given a pinned now, which is what makes objectstack build artifacts byte-stable across runs.

FunctionReturnsDescription
now()timestampPinned wall-clock at evaluation start
today()timestampnow() truncated to UTC start-of-day
daysFromNow(n)timestamptoday() + n days
daysAgo(n)timestamptoday() - n days
isBlank(v)boolTrue for null, undefined, '', []
coalesce(v, fallback)dynv when non-null, else fallback

Add new helpers in packages/formula/src/stdlib.ts. Keep them pure, dependency-free, and AI-readable.

Variable scope

@objectstack/formula builds the scope per evaluation:

BindingSourceAvailable in
recordthe row being evaluatedformulas, validation, sharing, visibility
previousrow before updatehooks, validation on update
inputhook payloadhooks
os.userinstall / request userseed, predicates with identity
os.orgactive organizationseed, predicates
os.envinstall env (production, staging, …)seed, predicates

Cookbook

Computed full name (handle nullable parts)

{
  name: 'full_name',
  type: 'formula',
  formula: F`coalesce(record.salutation, '') + ' '
            + coalesce(record.first_name, '') + ' '
            + coalesce(record.last_name, '')`,
  result_type: 'text',
}

CEL throws on null + string, so wrap every nullable operand in coalesce(..., '').

Conditional formula

{
  name: 'roi',
  type: 'formula',
  formula: F`coalesce(record.actual_cost, 0) > 0
             ? ((coalesce(record.actual_revenue, 0) - record.actual_cost) * 100.0) / record.actual_cost
             : 0.0`,
  result_type: 'percent',
}

Predicate (visibility, conditional required)

{
  name: 'rating',
  type: 'picklist',
  visibleOn: P`record.status == 'qualified'`,
}

Dynamic seed dates

Use CEL inside seed records to defer evaluation to install time. The SeedLoader pins a single now per load run, so the same dataset produces identical SHA-1 across builds while still being "fresh" relative to whoever installs the package.

import { defineDataset, cel } from '@objectstack/spec';

export const opportunityDataset = defineDataset({
  object: 'opportunity',
  records: [
    {
      id: 'opp_acme',
      name: 'Acme Q3 Renewal',
      close_date: cel`daysFromNow(45)`,
      created_at: cel`now()`,
    },
  ],
});

Without CEL, new Date() would be evaluated at compile time — your package would ship with the timestamp from your laptop and every customer would see "stale" demo data. With CEL, the date resolves on the customer's clock at install time.

Validation rule

{
  name: 'amount_positive',
  type: 'expression',
  condition: P`record.amount > 0`,
  errorMessage: 'Amount must be positive.',
}

Hook condition

{
  name: 'notify_on_escalation',
  on: 'after_update',
  condition: P`previous.status != 'escalated' && record.status == 'escalated'`,
  handler: 'notifyOpsTeam',
}

Determinism contract

Two consecutive objectstack build runs with no source changes must produce byte-identical dist/objectstack.json. CEL plus pinned now is what makes this possible — there are zero compile-time-evaluated Date.now() calls in seed metadata. CI enforces this via SHA-1 comparison; if you add a new dialect or stdlib helper, ensure it preserves determinism.


Migration from the legacy formula DSL

The previous custom recursive-descent engine (packages/objectql/src/formula.ts, deleted in M9.5) had a Salesforce-style function set. Mechanical translation:

LegacyCEL equivalent
bare_fieldrecord.bare_field
= / <>== / !=
AND / OR / NOT&& / || / !
"text"'text' (single quotes preferred)
IF(c, a, b)c ? a : b
ISBLANK(x)isBlank(record.x)
CONCAT(a, b, …)a + b + … (with coalesce(...,''))
TODAY() / NOW()today() / now()
IN (a, b)record.x in [a, b]
ISCHANGED(x) (in update hook)previous.x != record.x
MONTH_DIFF(a, b)not yet — add to stdlib

Salesforce compatibility is not a project goal. Authors targeting Salesforce semantics rewrite their formulas in CEL.


Programmatic API

import { ExpressionEngine } from '@objectstack/formula';

// Compile + cache
const compiled = ExpressionEngine.compile({ dialect: 'cel', source: 'record.x * 2' });

// Evaluate
const result = ExpressionEngine.evaluate(
  { dialect: 'cel', source: 'record.x * 2' },
  { record: { x: 21 } },
);
// => { ok: true, value: 42 }

The low-level engine never throws — evaluate() returns { ok: false, error }. But call sites must not silently swallow that (ADR-0032): objectstack build fails on an invalid expression (with a located, schema-aware message), and at runtime the flow/rule engines throw a loud, attributed error instead of treating a bad expression as false/null. The validate_expression agent tool runs this same validator so you can check an expression before saving.


On this page