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.
Reach for 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. They carry the same standing as the definition, because you name and register them in your own process. Render data doesn’t.
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.
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.
API walkthrough
Registering
const funcs = {
/** @param {string} ym A year and month, `2026-08`. */
period: (ym) => {
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.
/**
* @param {string} locale
* @param {{ currency?: string; timeZone?: string; missing?: string }} [options]
*/
export function formatters(locale, { currency, timeZone, missing = '' } = {}) {
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: (/** @type {number | null} */ n) => (n == null ? missing : money.format(n)),
percent: (/** @type {number | null} */ n) => (n == null ? missing : pct.format(n)),
date: (/** @type {Date | string | number | null} */ value) => {
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.
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 display 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.