| title | Expressions (CEL) |
|---|---|
| description | Canonical CEL-based expression language for formulas, predicates, conditions, and dynamic seed values |
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, sharingcondition, field conditional rules (visibleWhen,readonlyWhen,requiredWhen), view section/column visibility (visibleWhen), actiondisabled, hookcondition, 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 the north-star principles on typed metadata over custom DSLs.
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):
| Dialect | Use for | Helper |
|---|---|---|
cel |
computed values, predicates, defaults, formulas | cel`...` / F`...` / P`...` |
cron |
recurring schedules (Job, ETL, sync, exports, jobs) | cron`...` |
template |
text interpolation ({{path}}) — notif/prompt/title |
tmpl`...` |
js |
sandboxed 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.
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',
nameField: 'display_title', // ADR-0079 — the record title is a designated field; composite titles migrate off the deprecated `titleFormat` to a text formula
fields: {
// Composite record title as a text formula, surfaced via `nameField` above.
display_title: Field.formula({
returnType: 'text',
expression: F`"Invoice " + string(record.invoice_no) + " – " + record.customer.name`,
}),
total: Field.formula({
returnType: 'number',
expression: F`record.subtotal + record.subtotal * record.tax_rate`,
}),
po_number: Field.text({
requiredWhen: 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 is documented at cel-spec. Cheat-sheet of what you'll write daily:
| Concept | Syntax |
|---|---|
| Field on current record | record.first_name |
| Previous (in update hooks) | previous.status |
| Input to a hook | input.amount |
| Equality / inequality | == / != |
| Logical | && / || / ! |
| Ternary | cond ? then : else |
| String literal | 'single quotes' |
| Membership | record.region in ['us', 'eu'] |
| Field present | has(record.foo) (returns true even when value is null) |
has()gotcha:has(record.x)is true whenever the key exists, even when its value isnull. To check for "not blank" useisBlank(...)orrecord.x != null— see stdlib below.
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.
| Function | Returns | Description |
|---|---|---|
now() |
timestamp | Pinned wall-clock at evaluation start |
today() |
timestamp | Calendar day in the business timezone, as UTC midnight |
daysFromNow(n) / daysAgo(n) |
timestamp | today() ± n days (calendar-day, not wall-clock) |
addDays(d, n) / addMonths(d, n) |
timestamp | Shift a given date; addMonths clamps to month end (Jan 31 + 1mo → Feb 28) |
date(s) / datetime(s) |
timestamp | Parse an ISO date / date-time string (aliases) |
daysBetween(a, b) |
int | Whole days from a to b (negative when b is earlier) |
isBlank(v) |
bool | True for null, undefined, '', [] |
isEmpty(v) |
bool | True for null or zero-length string/list/map |
coalesce(v, fallback) |
dyn | v when non-null, else fallback |
trim(v) |
string | v with leading/trailing whitespace removed |
joinNonEmpty(list, sep) |
string | list joined by sep, skipping blank entries |
upper(s) / lower(s) |
string | Case conversion |
contains(s, sub) / startsWith(s, p) / endsWith(s, p) |
bool | Substring checks (free-function form) |
matches(s, regex) |
bool | Regex test |
len(v) |
int | Length of a string / list / map (mirrors CEL's built-in size()) |
abs(x) / round(x) |
number | Numeric helpers |
min(a, b) / max(a, b) |
dyn | Smaller / larger operand (numeric comparison) |
Add new helpers in
packages/formula/src/stdlib.ts.
Keep them pure, dependency-free, and AI-readable.
@objectstack/formula builds the scope per evaluation:
| Binding | Source | Available in |
|---|---|---|
record |
the row being evaluated | formulas, validation, sharing, visibility |
previous |
row before update | hooks, validation on update |
input |
hook payload | hooks |
os.user |
install / request user | seed, predicates with identity |
os.org |
active organization | seed, predicates |
os.env |
install env (prod, dev, test) |
seed, predicates |
objectstack build type-checks every expression against the object schema, so
a mistake that would silently evaluate to null at runtime is caught before it
ships. The same shared validator also runs when a flow is registered
(registerFlow), so a flow authored dynamically gets the same verdict. Findings
come at two severities: errors fail the build; warnings are advisory
(surfaced, not fatal — promote them with --strict).
| Check | Example | Severity |
|---|---|---|
Qualify field references — a formula / validation / predicate binds only the record namespace, so a bare amount resolves to nothing and evaluates to null. Write record.amount. |
amount > 100 → record.amount > 100 |
error |
Field must exist — a typo'd record.<field> is flagged with a did-you-mean. |
record.amont → record.amount |
error |
| Unknown function — a misspelled or nonexistent stdlib call. | isBlnk(record.x) |
error |
Type soundness — a text or boolean field used with an arithmetic (+ - * / %) or ordering (< > <= >=) operator against a number faults at runtime and evaluates to null. Store it as a number field, or drop the arithmetic. |
record.title * 2 |
warning |
Integer literals in arithmetic are fine.
record.amount / 100,record.price * 2,record.total - feeall work — the engine handles mixeddouble × intarithmetic, so you never need the100.0/2.0float-literal workaround. Equality against any type is also safe (record.stage == 5simply returnsfalse), so only arithmetic/ordering on a genuinely text/boolean field is flagged.
In flow and automation conditions the record's fields are bound bare
(stage == "won", not record.stage), so a bare reference is correct there.
Instead, a bare identifier that is a near-miss of a real field gets a
did-you-mean warning (a genuine flow variable is left alone), and the same
type-soundness check applies.
{
name: 'full_name',
type: 'formula',
expression: F`joinNonEmpty([record.salutation, record.first_name, record.last_name], ' ')`,
}joinNonEmpty skips blank parts, so you avoid the verbose triple-coalesce.
Building the string by hand with + would also work, but CEL throws on
null + string, so each nullable operand must be wrapped in coalesce(..., '').
{
name: 'roi',
type: 'formula',
expression: F`coalesce(record.actual_cost, 0) > 0
? ((coalesce(record.actual_revenue, 0) - record.actual_cost) * 100.0) / record.actual_cost
: 0.0`,
}{
name: 'rating',
type: 'select',
visibleWhen: P`record.status == 'qualified'`,
}
Field.text({
visibleWhen: P`record.type == 'business'`,
readonlyWhen: P`record.status == 'approved'`,
requiredWhen: P`record.amount > 10000`,
})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 { defineSeed } from '@objectstack/spec/data';
import { cel } from '@objectstack/spec';
import { Opportunity } from './objects/opportunity.object';
export const opportunitySeed = defineSeed(Opportunity, {
records: [
{
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.
{
name: 'amount_positive',
type: 'script',
condition: P`record.amount <= 0`,
message: 'Amount must be positive.',
}The generic CEL rule is type: 'script'. For script rules, the condition
is the failure predicate — when it evaluates TRUE the validation fails, so
invert the test (here amount <= 0 rejects non-positive amounts).
{
name: 'notify_on_escalation',
object: 'case',
events: ['afterUpdate'],
condition: P`previous.status != 'escalated' && record.status == 'escalated'`,
body: { language: 'js', source: 'await ctx.notifyOpsTeam(record)' },
}Complex calculations and logic in formula fields.
Formula fields are CEL. Use ternary cond ? a : b for conditionals and
&&/||/! for boolean logic. Reference fields via record.<field>:
// Tiered pricing
Field.formula({
label: 'Discount Tier',
returnType: 'text',
expression: `
record.amount > 1000000 ? "Platinum" :
record.amount > 500000 ? "Gold" :
record.amount > 100000 ? "Silver" : "Bronze"
`,
})
// Status indicator
Field.formula({
label: 'Health Score',
returnType: 'text',
expression: `
record.is_active && record.days_since_contact < 30 ? "Healthy" :
record.days_since_contact < 90 ? "At Risk" : "Critical"
`,
})// Days until close
Field.formula({
label: 'Days to Close',
returnType: 'number',
expression: 'daysBetween(record.close_date, today())',
})
// Contract end in 30 days
Field.formula({
label: 'Expiring Soon',
returnType: 'boolean',
expression: 'record.status == "active" && daysBetween(record.end_date, today()) <= 30',
})
// Age in days
Field.formula({
label: 'Age (Days)',
returnType: 'number',
expression: 'daysBetween(today(), record.created_date)',
})// Gross margin (computes a number; a formula's type is always 'formula' —
// declare the value type with returnType, and decimal places with scale)
Field.formula({
label: 'Gross Margin %',
returnType: 'number',
expression: 'record.revenue > 0 ? ((record.revenue - record.cost) / record.revenue) * 100 : 0',
scale: 2,
})
// Weighted pipeline
Field.formula({
label: 'Weighted Amount',
returnType: 'number',
expression: 'record.amount * (record.probability / 100)',
scale: 2,
})
// Total with tax
Field.formula({
label: 'Total with Tax',
returnType: 'number',
expression: 'record.subtotal * 1.0825', // 8.25% tax
scale: 2,
})// Uppercase
Field.formula({
label: 'Code',
returnType: 'text',
expression: 'upper(record.sku)',
})
// Has a company email
Field.formula({
label: 'Internal',
returnType: 'boolean',
expression: 'endsWith(lower(record.email), "@example.com")',
})// Formula: Lead score
Field.formula({
label: 'Lead Score',
returnType: 'number',
expression: `
(record.rating == "hot" ? 30 : record.rating == "warm" ? 20 : 10) +
(record.annual_revenue > 1000000 ? 25 : 0) +
(record.number_of_employees > 500 ? 20 : 0) +
(record.industry in ["technology", "finance"] ? 15 : 0)
`,
})✅ DO:
- Keep formulas simple and readable
- Add comments for complex logic
- Test edge cases (null, zero, empty)
- Use appropriate data types
❌ DON'T:
- Create circular references
- Use formulas for frequently changing data
- Nest too many IF statements
- Ignore null handling
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.
The previous custom recursive-descent engine
(packages/objectql/src/formula.ts, deleted in M9.5) had a Salesforce-style
function set. Mechanical translation:
| Legacy | CEL equivalent |
|---|---|
bare_field |
record.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.
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 same validateExpression
validator backs objectstack build and metadata registration (and a planned
validate_expression agent tool), so an expression is checked before it ships.
- Field Metadata — declaring
type: 'formula'fields - Seed Data — using
cel\...`` for dynamic install-time values - Hook Bodies — when to use a JS hook vs a CEL
condition packages/formula/— engine + stdlib source- North Star — architecture principles (typed metadata over custom DSLs)