Escape

Host functions

Intent

Presentation policy the four format kinds can’t express belongs to you. A registered function is ordinary host code called from an expression, which makes Intl a formatter registry with no dependency and no plugin system.

Use one when no kind says what you mean. A month with no day is the canonical case, since none of the four date forms prints one.

Design decisions

You pass a registry per compile rather than configuring one globally. report(schema, functions) and plan(schema, functions) both take it, so one instance can compile two documents against different registries.

Registered functions count as trusted configuration. You name and register them in your own process, so they’re host code. The definition that calls them and the data they read aren’t trusted.

A registered name shadows a built-in. A host that already supplies its own round or sum keeps it, and the engine doesn’t fight for the name.

A formatted value is a string, on every surface. The moment a function presents a number, the typed seam disappears. In a workbook that cell is text with no number format, even where a kind is also declared.

The engine states no policy of its own. No locale defaults beyond the instance’s, no currency table, no missing-value marker. What a report shows for an absent value is a decision about your data, not about reporting.

An asynchronous function settles before the walk, not during it. The engine starts every such call together, waits for all of them, and only then lets a target read the first event. The walk itself stays synchronous, so a target stays an ordinary stream consumer. That’s also why the rules below are narrow. The calls have to be knowable before the walk starts.

A function’s own throw keeps its class. It surfaces behind the path it failed at, and isDiagnostic is false for it. The engine can’t tell its own verdict from your function’s failure and declines to guess.

A value with no text form is a render error. A function returning a plain object, a Map or a Set would print [object Object] into the document on the page and in the worksheet alike. The engine refuses it and names the cell, as in detail[0].value [{{ @.address }}]: a value with no text form. Return a string, a number, a Date, or a class with its own toString. The rule reads the value, so a data field answers to it as well. Every registry entry must be a function too, and plan reports one that isn’t at functions.<name>.

API walkthrough

Registering

report.ts
const funcs = {
  // '2026-08' → 'August 2026'
  period: (ym: string) => {
    const [year, month] = ym.split('-').map(Number)
    return new Intl.DateTimeFormat('en-GB', { month: 'long', year: 'numeric' })
      .format(new Date(year, month - 1, 1))
  },
}

const report = q.report(definition, funcs)

Call it like any function in an expression.

a payslip heading
{ "type": "text", "value": "Pay period {{ period($.input.period) }}", "style": { "size": 11 } }

Building formatters once

Construct each Intl formatter once rather than once per cell. A report with two thousand rows builds two thousand formatters otherwise.

formatters.ts
type Options = { currency?: string; timeZone?: string; missing?: string }

export function formatters(locale: string, { currency, timeZone, missing = '' }: Options = {}) {
  const money = new Intl.NumberFormat(locale, { style: 'currency', currency })
  const pct = new Intl.NumberFormat(locale, { style: 'percent', maximumFractionDigits: 1 })
  const day = new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeZone })

  return {
    currency: (n: number | null) => (n == null ? missing : money.format(n)),
    percent: (n: number | null) => (n == null ? missing : pct.format(n)),
    date: (value: Date | string | number | null) => {
      if (value == null) return missing
      const d = value instanceof Date ? value : new Date(value)
      return Number.isNaN(d.getTime()) ? missing : day.format(d)
    },
  }
}

Treat a percent input as a ratio, so 0.125 presents as 12.5%, matching what the percent kind already does. Give every formatter one missing policy rather than scattering fallbacks through the document.

Marking an absence

An empty avg, min or max is null, and a formatter that renders null as an empty string hides that. Where the absence has to be visible, branch in the document and call the formatter only for the value.

an explicit marker
{ "type": "text", "value": "Average {{ $.average == null ? '—' : currency($.average) }}",
  "style": { "size": 10 } }

Test with == null. Using || would replace a measured 0 as well.

Fetching what the row doesn’t carry

A registered function may be async. That’s how a cell shows a customer name, an exchange rate or a stock level the report’s own data never selected. Write the call as the whole value of a cell in a detail row, the band whose data selected the rows. Take its arguments from that row.

a detail cell, over `$.orders[*]`
{ "type": "text", "value": "{{ customerName(@.customerId) }}" }
report.ts
const funcs = {
  customerName: async (id: string) => (await getCustomer(id)).name,
}

const report = q.report(definition, funcs)
const output = await report.render(target, data)

The engine calls the function once for every row it renders. It starts every call together and waits for all of them before the first event reaches a target. The engine never fetches a row that where or take removed.

Three rules hold, and the engine refuses each one where you declare it.

The call is the whole value of a cell. {{ lookup(@.id).name }} reads a property of a promise, and {{ "#" + lookup(@.id) }} joins one to a string. Both are errors that name the function and the cell.

The arguments come from the row. An argument reads @, $.params, $.input or a literal, because the engine settles a call against the row alone. A group handle, an aggregate, and a running value are out of reach. The engine therefore refuses an asynchronous call in every position that steers the walk. Those are where, sort, a group’s by, take, an aggregate, a running value, visible, a style, a group’s label, and a page band. A synchronous function keeps every scope, and these rules leave it alone.

A render settles the calls, and a stream can’t. render() settles them and resolves the target’s output. stream() returns events as you pull them, so it can wait for nothing. It reports the call it reached and names the cell.

Two things stay yours. The engine calls the function once per row, so a function that answers the same for many rows remembers its own answers. It also starts every call at once, so a report over ten thousand rows starts ten thousand calls. A function that must limit them limits them itself.

A function that rejects fails the render. The error names the function and the cell, and keeps the class the function threw.

Knowing what a report calls

A compiled report lists the registry functions it uses.

check.ts
for (const fn of report.functions) console.log(fn.name, fn.arity, fn.doc ?? '')

Signatures arrive in call-first-seen order. arity is the declared parameter count, and doc is the function’s own doc string where it carries one. Use it to check a definition against the registry you are about to hand it.

What not to write a function for

Don’t write one for a number or a date the four kinds already present. format keeps the value typed, so a workbook cell stays computable and a page still reads as money. A function turns that same cell into text everywhere.

Don’t write one to round for presentation either. The built-in round settles the stored number, which is what the workbook keeps.

The dividing line is whether the engine can carry the value through to the target. When it can, declare a kind. When it can’t, register a function and accept that the result is text.