Host Functions
Reactive expressions are deliberately a small language — paths, operators, ternaries. That is enough for $form.total > 100, but not for currency formatting or a tax calculation. Host functions close that gap: you pass a map of plain functions to the form, and expressions call them through the $fn namespace.
Register the function in your application code:
const functions = { grandTotal: (items = []) => items.reduce((total, item) => total + (item.price ?? 0), 0),};Then call it from any expression in the form definition:
{ "text": "Grand total: {{ $fn.grandTotal($form.lineItems) }}" }The form definition declares which value to show — your typed, testable application code decides how to compute it.
The function contract
Section titled “The function contract”A host function is any function that derives a result from its arguments — the key in the map is the name expressions use after $fn.:
import type { ExpressionFunction, ExpressionFunctions,} from '@golemui/gui-shared';
// type ExpressionFunction = (...args: any[]) => unknown;// type ExpressionFunctions = Record<string, ExpressionFunction>;
const functions: ExpressionFunctions = { lineTotal: (item) => (item?.quantity ?? 0) * (item?.unitPrice ?? 0), formatMoney: (amount, currency) => new Intl.NumberFormat('en-US', { style: 'currency', currency: currency ?? 'USD', }).format(amount), hasItems: (items) => (items?.length ?? 0) > 0,};Three rules govern what you can put in that map:
- Pure. Derive the result only from the arguments — no mutation of the values you receive, no network calls, no writes to outside state. Arguments arrive as live references into form data, so a function that mutates them corrupts the store.
- Synchronous. Expressions are evaluated inside the reducer and nothing is awaited. An
asyncfunction returns aPromise, which renders as[object Promise]in a text slot and is nevertruein a condition — fetch the data in your application and pass it in throughmetainstead. - Cheap. Expressions re-evaluate on every state change, and expressions inside a repeater run once per row. There is no memoization — keep the body to arithmetic, formatting, and array reduction.
Wiring functions into your form
Section titled “Wiring functions into your form”In the Programmatic API, functions go inside formConfig.functions.
import { gui } from '@golemui/gui-shared';import { GuiForm } from '@golemui/gui-react';
const functions = { grandTotal: (items = []) => items.reduce((sum, item) => sum + (item.price ?? 0), 0),};
const config = { formDef: [ gui.displays.alert({ uid: 'total', text: 'Total: {{$fn.grandTotal($form.items)}}' }), ], formConfig: { functions },};
export function MyForm() { return <GuiForm config={config} />;}import { Component } from '@angular/core';import { CommonModule } from '@angular/common';import { gui } from '@golemui/gui-shared';import { FormComponent } from '@golemui/gui-angular';
@Component({ imports: [CommonModule, FormComponent], selector: 'app-my-form', template: `<gui-form [config]="config"></gui-form>`,})export class MyForm { protected config = { formDef: [ gui.displays.alert({ uid: 'total', text: 'Total: {{$fn.grandTotal($form.items)}}' }), ], formConfig: { functions: { grandTotal: (items = []) => items.reduce((sum, item) => sum + (item.price ?? 0), 0), }, }, };}import { LitElement, html } from 'lit';import { customElement } from 'lit/decorators.js';import { gui } from '@golemui/gui-shared';import '@golemui/gui-lit';
@customElement('my-form')export class MyForm extends LitElement { config = { formDef: [ gui.displays.alert({ uid: 'total', text: 'Total: {{$fn.grandTotal($form.items)}}' }), ], formConfig: { functions: { grandTotal: (items = []) => items.reduce((sum, item) => sum + (item.price ?? 0), 0), }, }, };
override createRenderRoot() { return this; }
override render() { return html`<gui-form .config=${this.config}></gui-form>`; }}<script setup lang="ts">import { gui } from '@golemui/gui-shared';import { GuiForm } from '@golemui/gui-vue';
const functions = { grandTotal: (items = []) => items.reduce((sum, item) => sum + (item.price ?? 0), 0),};
const config = { formDef: [ gui.displays.alert({ uid: 'total', text: 'Total: {{$fn.grandTotal($form.items)}}' }), ], formConfig: { functions },};</script>
<template> <GuiForm :config="config" /></template>import '@golemui/gui-components/index.css';import '@golemui/gui-lit';import { gui } from '@golemui/gui-shared';
const form = document.getElementById('app-form');form.config = { formDef: [ gui.displays.alert({ uid: 'total', text: 'Total: {{$fn.grandTotal($form.items)}}' }), ], formConfig: { functions: { grandTotal: (items = []) => items.reduce((sum, item) => sum + (item.price ?? 0), 0), }, },};Functions cannot live in a JSON form definition, so they go into the functions property of the config object, as a sibling of formDef.
{ "form": [ { "uid": "total", "kind": "display", "type": "alert", "props": { "text": "Total: {{$fn.grandTotal($form.items)}}" } } ]}import { GuiForm } from '@golemui/gui-react';import formDef from './my-form.json';
const config = { formDef, functions: { grandTotal: (items = []) => items.reduce((sum, item) => sum + (item.price ?? 0), 0), },};
export function MyForm() { return <GuiForm config={config} />;}import { Component } from '@angular/core';import { CommonModule } from '@angular/common';import { FormComponent } from '@golemui/gui-angular';import formDef from './my-form.json';
@Component({ imports: [CommonModule, FormComponent], selector: 'app-my-form', template: `<gui-form [config]="config"></gui-form>`,})export class MyForm { protected config = { formDef, functions: { grandTotal: (items = []) => items.reduce((sum, item) => sum + (item.price ?? 0), 0), }, };}import { LitElement, html } from 'lit';import { customElement } from 'lit/decorators.js';import '@golemui/gui-lit';import formDef from './my-form.json';
@customElement('my-form')export class MyForm extends LitElement { config = { formDef, functions: { grandTotal: (items = []) => items.reduce((sum, item) => sum + (item.price ?? 0), 0), }, };
override createRenderRoot() { return this; }
override render() { return html`<gui-form .config=${this.config}></gui-form>`; }}<script setup lang="ts">import { GuiForm } from '@golemui/gui-vue';import formDef from './my-form.json';
const config = { formDef, functions: { grandTotal: (items = []) => items.reduce((sum, item) => sum + (item.price ?? 0), 0), },};</script>
<template> <GuiForm :config="config" /></template>import '@golemui/gui-components/index.css';import '@golemui/gui-lit';import formDef from './my-form.json';
const form = document.getElementById('app-form');form.config = { formDef, functions: { grandTotal: (items = []) => items.reduce((sum, item) => sum + (item.price ?? 0), 0), },};Calling functions from expressions
Section titled “Calling functions from expressions”The syntax is $fn.functionName(arg1, arg2). $fn is available everywhere the expression language is:
| Context | Example |
|---|---|
| String interpolation slots | "Total: {{$fn.grandTotal($form.items)}}" |
| i18n dynamic params | params: { total: '$fn.grandTotal($form.items)' } |
Inline when on include, exclude, disabled, readonly | include: { when: '$fn.hasItems($form.items)' } |
| Named state expressions | states: { hasItems: '$fn.hasItems($form.items)' } |
Arguments are ordinary expressions, so you can pass form paths, whole scope objects, literals, and the results of other host functions:
{ "text": "{{ $fn.formatMoney($fn.grandTotal($form.lineItems), $form.currency) }}"}Nothing is passed implicitly — a function receives exactly the arguments the expression lists, and a zero-argument call such as {{$fn.isWeekendPromo()}} is valid.
Inside repeaters
Section titled “Inside repeaters”Widgets in a repeater’s props.template also see $item and $index, and both are valid arguments. The expression is evaluated once per row — each row gets its own result:
{ "kind": "display", "type": "markdown-text", "props": { "md": "**Line {{$index + 1}}** \n{{ $fn.formatMoney($fn.lineTotal($item), $form.currency) }}" }}The same applies to conditions — include: { when: '$fn.isEven($item.quantity)' } hides and shows individual rows independently.
Note that named states declared in formConfig.states are evaluated at form level, so they receive $fn but never $item or $index. Per-row logic belongs in an inline when. See Repeater - $item and $index.
Error handling
Section titled “Error handling”A host function that throws, and a call to a name you never registered, fail the same way — at evaluation time. What happens next depends on where the expression lives.
| Context | Behavior when the call fails |
|---|---|
| Text slots and i18n params | The form reports { status: 'errored', code: 40 } through formHealth and stops re-rendering. |
| Named state expressions | The state is treated as inactive. formHealth stays ok. |
include / exclude / disabled / readonly when | The error propagates out of dispatch to your application code. |
The asymmetry is deliberate — a broken text slot is a definition bug you want surfaced loudly, while a state expression that cannot be evaluated simply is not active. Because the third case surfaces as an uncaught exception, guard your arguments rather than relying on a catch.
Referencing a function without calling it does not throw. $fn.missing evaluates to undefined, so {{$fn.formatMoney ? 'yes' : 'no'}} is a safe feature check — but $fn.missing() is a TypeError.
Limits
Section titled “Limits”- The map is read once, at initialization. It is captured when the form store is created — replacing
functionson an already-mounted form has no effect, so re-initialize the form instead. - Names are not validated ahead of time. The MCP server and the JSON schema have no knowledge of which functions your host supplies, so any
$fn.anything(...)passes linting and only fails at runtime — keep the names in the form definition and the map in sync. functionsis host config, not form data. It is never part of the serialized form definition, so it does not appear inform.schema.jsonand is not carried by a form you save or send over the wire.- Runtime functions do not receive
$fn. Their callback already runs in your application code, so call the function directly.
See also
Section titled “See also”- String Interpolation — the
{{...}}slots where$fnis most often used. - States — named conditions and inline
whenexpressions. - Dependencies — the sibling config entry for injecting third-party helpers into widgets.
- Runtime Functions — the Programmatic alternative when you need a full callback instead of an expression.