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
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.
{ "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.
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.
{ "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.
{ "type": "text", "value": "{{ customerName(@.customerId) }}" }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.
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.