Dynamic bindings
Anywhere text appears you can write {{ expression }}; binding fields in the inspector hold an expression directly. The Data panel and the field picker insert them for you, so most users never type one.
The expression language
Small, safe, and statically checkable. No JavaScript, no eval, no prototype access.
client.firstName
portfolio.holdings[0].name
portfolio.holdings.value → list of every holding's value
sum(portfolio.holdings.value)
portfolio.value | currency:"GBP":0
client.dateOfBirth | date:"d MMMM yyyy"
client.isRetired ? "Retired" : "Working"
client.middleName | default:"—"Operators
== != < <= > >= + - * / %, and or not, in, a ? b : c. Equality is forgiving ("7" == 7), comparisons on null are false, division by zero yields null, never an exception.
Formatters (pipes)
| Pipe | Example | Output |
|---|---|---|
currency[:code][:decimals] | value | currency:"EUR":0 | €12,346 |
number[:decimals] | n | number:1 | 1,234.6 |
percent[:decimals][:scaled] | 0.125 | percent:1 | 12.5% |
date[:pattern] | d | date:"dd MMM yyyy" | 20 May 2026 |
datetime[:pattern] | d | datetime:"HH:mm" | 14:30 |
boolean[:yes][:no] | b | boolean:"Yes":"No" | Yes |
upper, lower, title, initials, truncate:n | name | truncate:20 | … |
default:x | note | default:"None" | fallback when empty |
join:sep, length, first, last, take:n, sortBy:field:dir, filter:field:value, pluck:field | list helpers | |
round:n, abs, pluralize:one:many |
Date patterns: yyyy yy MMMM MMM MM M dd d EEEE EEE HH H hh h mm ss a, or the named styles short, medium, long, full. Locale, currency and time zone come from the template settings and can be overridden per render.
Functions
sum count avg min max first last length coalesce isEmpty isNotEmpty contains startsWith endsWith concat round abs number string if now year daysBetween.
Globals
page.number, page.total (resolved per page), now, theme.name, theme.logo, theme.colors.*, theme.meta.* (anything you put in the theme's meta, e.g. company name or regulatory text).
Calculations, without the expression
Your users get arithmetic whether or not they get the expression editor. Every bound property's panel has a Worked out tab: a starting value and then steps — add, subtract, multiply by, divide by, and add or subtract as a percentage — each applied to everything before it. No brackets appear anywhere, because the designer does the bracketing.
It is a view of an ordinary expression, not a second way to store one. The template still holds sum(invoice.lineItems.amount) * (1 + coalesce(invoice.taxRate, 0)), so nothing downstream — validation, rendering, either PDF backend — knows this exists.
import { fromExpression, toExpression, type Calculation } from "@broadpaper/core";
// Read a template's expression back as steps, for an interface to show.
const calc = fromExpression('sum(invoice.lineItems.amount) * (1 + coalesce(invoice.taxRate, 0))');
// → { start: { kind: "aggregate", fn: "sum", path: "invoice.lineItems.amount" },
// steps: [{ op: "addPercent", term: { kind: "field", path: "invoice.taxRate" } }] }
const total: Calculation = {
start: { kind: "aggregate", fn: "sum", path: "invoice.lineItems.amount" },
steps: [{ op: "subtractPercent", term: { kind: "field", path: "invoice.retention" } }],
format: 'currency:"GBP"'
};
toExpression(total);
// → sum(invoice.lineItems.amount) * (1 - coalesce(invoice.retention, 0)) | currency:"GBP"Two properties are worth relying on.
fromExpression returns null for anything it cannot rebuild character for character. A conditional, a function it does not model, a string in the middle of the arithmetic: all refused. An editor that offered to edit an expression it would then re-emit differently would rewrite a document on being looked at, so the designer shows those read-only instead, named after the fields they read.
A percentage step is emitted null-safe. coalesce(rate, 0), so a business that has not set a VAT rate gets an invoice without VAT rather than one with a hole where the total was.
With features.expressions: false the Worked out tab is the only way to author arithmetic, and that is the intended shape for a non-technical audience. With it on, the expression box sits beside it for the cases the builder deliberately does not model.
Scopes
Inside a repeater or table, the current item is available under the alias you chose (holding, row…) plus item, index (1-based), first, last, count. Aliases shadow outer names; validation warns if an alias hides a data source.
Static type checking
typeCheck(expr, scope) resolves the result type from the schemas without any data, which powers the inspector's warnings, the picker's format suggestions and validate():
import { typeCheck, createSchemaScope } from "@broadpaper/core";
typeCheck("client.firstName | currency", createSchemaScope(dataSources));
// { type: "string", issues: [{ code: "type-mismatch", message: 'Formatter "currency" expects number or currency but received string' }] }