Escape
HomeDocsHost functions

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

report.js
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.

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.js
/**
 * @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.

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.

Knowing what a report calls

A compiled report lists the registry functions it uses.

check.js
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.

Policy is yours. The engine stays out of it.

npm install quario
Getting started
© 2026 quario · KvK 61815977