Guides
Validation
Validation is attached to the spec, not to the UI. That single decision is what lets the browser and your backend reach the same verdict from the same file — no schema kept in sync by hand, no rule that only exists client-side.
Declarative validators
Section titled “Declarative validators”Each brick carries a validations array. They compile to a JSON Schema
(Ajv + ajv-errors + ajv-formats) at render time:
| Validator | Applies to | value |
|---|---|---|
required |
any | — |
minLength / maxLength |
string | number |
pattern |
string | regex source |
email / url |
string | — |
min / max |
number | number |
minItems / maxItems |
array | number |
custom |
any | — (uses customValidator) |
{ "validations": [ { "validator": "required" }, { "validator": "email", "message": "Enter a valid address" } ]}message is optional and may be localized — see
messages and locales below.
Custom validators
Section titled “Custom validators”When the eleven built-ins are not enough, custom runs a snippet in the
rules sandbox:
// `value` is this field's value, `dataMap` is the whole formreturn value !== dataMap.oldEmail || 'Must differ from the current address'The return value decides the outcome:
| Returned | Result |
|---|---|
true, undefined, null |
passes |
| a string | fails, and the string becomes the message |
an Error |
fails with error.message |
| anything else | fails with the configured message, or the built-in default |
Messages and locales
Section titled “Messages and locales”Messages resolve in a cascade: the author’s message first, then FormKrafter’s
built-in defaults for the active locale. English and French ship in the box.
{ "validator": "required", "message": { "en": "Required", "fr": "Obligatoire" } }Register more languages at startup:
import { registerValidationMessages } from '@streamline-pulse/formkrafter-core'
registerValidationMessages('de', { required: 'Pflichtfeld', minLength: 'Mindestens {value} Zeichen',})The {value} placeholder is interpolated with the validator’s parameter.
The same verdict on the server
Section titled “The same verdict on the server”validateFormData is the backend entry point. It takes the same spec, the
submitted payload, and an optional locale:
import { validateFormData } from '@streamline-pulse/formkrafter-core'import type { BrickSpec } from '@streamline-pulse/formkrafter-core'
export function checkSubmission(spec: BrickSpec, payload: unknown) { const { valid, errors } = validateFormData(spec, payload, 'fr') if (!valid) throw new Error(JSON.stringify(errors)) return payload}// The renderer exposes the same pass as a methodconst { valid, errors } = await formEl.validate()It reproduces three frontend behaviors that are easy to get wrong in a hand-written backend check:
- Collection rows validate individually, and report as
contacts[0].email. - Empty strings count as missing, so
requiredbehaves consistently with what the user saw. - Fields hidden by a rule are excluded. A required field on step 3 that a rule hides can never block a submission it is not part of.
When a rule is not enforced
Section titled “When a rule is not enforced”A spec can carry validations that never run. valid: true then means nothing
objected, not the rules passed — a distinction that stays invisible until
bad data is already stored. validateFormData reports it through warnings:
const { valid, errors, warnings } = validateFormData(spec, payload)
if (warnings) { console.warn(`${formId} accepts submissions without some of its rules:`) for (const warning of warnings) console.warn(` • ${warning}`)}warnings is absent on a healthy spec, so its mere presence is the signal. It
never changes valid: the verdict stands, it is just weaker than it looks. Two
causes produce it — an input carrying validations but no dataType, which
leaves nothing to build a schema from, and a schema that failed to compile, for
instance a pattern whose regex is invalid.
Linting specs before they ship
Section titled “Linting specs before they ship”warnings catches the problem at submission time. lintSpec catches it before
the spec is ever stored — it walks a spec and returns everything structurally
wrong with it, with no data required:
import { lintSpec } from '@streamline-pulse/formkrafter-core'
for (const issue of lintSpec(spec)) { console.error(`[${issue.code}] ${issue.path} — ${issue.message}`)}issue.code |
What it means |
|---|---|
validations-without-data-type |
The brick has rules but no dataType, so none of them are enforced |
input-without-key |
The value has nowhere to go in the form data |
duplicate-key |
Two bricks write to the same key; one value overwrites the other |
collection-without-children |
A collection with no bricks to repeat |
Each issue carries code, path, key and a human-readable message. An
empty array means the spec is sound. It is cheap enough to run in CI over every
spec you seed or generate, and exiting non-zero on a non-empty result turns a
class of silent data bugs into a failed build.
Next steps
Section titled “Next steps”A project by Streamline Pulse