Skip to content

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.

text
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) ​

PipeExampleOutput
currency[:code][:decimals]value | currency:"EUR":0€12,346
number[:decimals]n | number:11,234.6
percent[:decimals][:scaled]0.125 | percent:112.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:nname | truncate:20…
default:xnote | default:"None"fallback when empty
join:sep, length, first, last, take:n, sortBy:field:dir, filter:field:value, pluck:fieldlist 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.

ts
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():

ts
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' }] }