Skip to content
Forms, switch product

Custom Validators

Custom validators are functions that follow the CustomValidatorSchemaFn interface. They take a configuration parameter and return a Zod schema.

import { z } from 'zod';
import type { CustomValidatorSchemaFn } from '@golemui/gui-validators';
export const allowedNames: CustomValidatorSchemaFn = (names: string[]) =>
z.string().check(
z.superRefine((val, ctx) => {
if (val && !names.includes(val)) {
ctx.addIssue({
code: 'custom',
message: `Name "${val}" is not in ${names.map((n) => `"${n}"`).join(', ')}`,
input: val,
});
}
}),
);

The validator is referenced by name from a widget’s validator block. Where you register that name with the engine depends on the path:

In the Programmatic API, register custom validators inside formConfig.customValidators:

import { allowedNames } from './custom-validators/allowed-names';
const config = {
formDef: [/* …your widgets… */],
formConfig: { customValidators: { allowedNames } },
};
// <GuiForm config={config} />

In the JSON path, custom validators are a top-level property of the config object — there is no formConfig wrapper for JSON forms.

import { allowedNames } from './custom-validators/allowed-names';
import formDef from './my-form.json';
const config = {
formDef,
customValidators: { allowedNames },
};
// <GuiForm config={config} />

Use validator: { type: 'custom', <validatorName>: <config> }:

import { gui } from '@golemui/gui-shared';
gui.inputs.textInput('user.name', {
validator: { type: 'custom', allowedNames: ['John', 'Jane'] },
});
{
"kind": "input",
"type": "textinput",
"path": "user.name",
"validator": { "type": "custom", "allowedNames": ["John", "Jane"] }
}

The form engine resolves allowedNames against the registered map (formConfig.customValidators for Programmatic, config.customValidators for JSON) at validation time. Referencing a rule that isn’t registered fails loudly — the engine throws Unknown custom validator rule "…" listing the registered rules, instead of silently skipping the check.

Cross-field validation with runtime functions

Section titled “Cross-field validation with runtime functions”

A CustomValidatorSchemaFn only sees the value of the field it’s attached to. To validate against another field — e.g. a confirmPassword that must equal an earlier password field — pair the custom validator with a runtime function on the widget. The runtime function reads $form and re-runs on every form-data change, feeding the live value of the other field into the validator’s config.

This combination is Programmatic-only: pure JSON forms cannot host a runtime function.

import { z } from 'zod';
import type { CustomValidatorSchemaFn } from '@golemui/gui-validators';
export const passwordsMatch: CustomValidatorSchemaFn = (other: string) =>
z.string().check(
z.superRefine((val, ctx) => {
if (val && val !== other) {
ctx.addIssue({
code: 'custom',
message: 'Passwords do not match',
input: val,
});
}
}),
);

other is the live value of the other password field, fed in by the runtime function below.

import { passwordsMatch } from './custom-validators/passwords-match';
const formConfig = {
customValidators: { passwordsMatch },
};

3. Wire it on the widget with a runtime function

Section titled “3. Wire it on the widget with a runtime function”
import { gui } from '@golemui/gui-shared';
gui.inputs.password('password', { label: 'Password' }),
gui.inputs.password('confirmPassword', ({ $form }) => ({
label: 'Confirm password',
validator: {
type: 'custom',
passwordsMatch: $form.password ?? '',
},
})),

Each time $form.password changes, the runtime function re-evaluates and the validator’s schema sees the new value.

Injecting validation issues from event handlers

Section titled “Injecting validation issues from event handlers”

Custom validators and runtime functions both run inside the engine. Sometimes the check has to happen in your application code instead — a rule that pure JSON forms can’t express declaratively, or a result that only arrives later, like a server telling you a username is taken. For those cases, an event handler can inject validation issues onto any widget by dispatching INJECT_VALIDATION_ISSUES through event.callback:

event.callback({
type: 'INJECT_VALIDATION_ISSUES',
payload: {
path: 'user.confirm', // the widget's data path
issues: ['Passwords do not match'], // or null to clear
},
});

Injected issues display like any validator error and make the form invalid until you clear them by injecting issues: null for the same path. They are not recalculated when data changes — your handler owns their lifecycle, so clear them on the happy path.

Because event handlers work identically for JSON and Programmatic forms, this is the way to get cross-field validation — like the passwords example above — into a pure JSON form, where runtime functions aren’t available.

Example: password confirmation in a JSON form

Section titled “Example: password confirmation in a JSON form”

Both password widgets fire the same checkPasswordMatch event on change:

{
"form": [
{
"kind": "input",
"type": "password",
"path": "user.password",
"label": "Password",
"on": { "change": "checkPasswordMatch" }
},
{
"kind": "input",
"type": "password",
"path": "user.confirm",
"label": "Confirm password",
"on": { "change": "checkPasswordMatch" }
}
]
}

The application’s event-handler map compares the two values and injects — or clears — the issue on user.confirm:

import type { FormEvent } from '@golemui/core';
const eventHandlers = {
checkPasswordMatch(event: FormEvent) {
const { password, confirm } = ((event.data as any).user ?? {}) as {
password?: string;
confirm?: string;
};
event.callback({
type: 'INJECT_VALIDATION_ISSUES',
payload: {
path: 'user.confirm',
issues:
confirm && confirm !== password ? ['Passwords do not match'] : null,
},
});
},
};