---
title: "Host functions — quario"
description: "Registering your own functions for the presentation policy quario leaves to you, and where the trust boundary puts them."
url: "https://getquario.com/docs/diving-deeper/host-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.

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

```ts title="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.

```json title="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.

```ts title="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.

```json title="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.

```json title=“a detail cell, over `$.orders[*]`”
{ “type”: “text”, “value”: ”{{ customerName(@.customerId) }}” }
```

```ts title="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.

```ts title="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.

```json
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https://getquario.com/"},{"@type":"ListItem","position":2,"name":"Docs","item":"https://getquario.com/docs"},{"@type":"ListItem","position":3,"name":"Host functions","item":"https://getquario.com/docs/diving-deeper/host-functions"}]}
```
