Skip to content

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.

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)
A required, well-formed email
{
"validations": [
{ "validator": "required" },
{ "validator": "email", "message": "Enter a valid address" }
]
}

message is optional and may be localized — see messages and locales below.

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 form
return 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 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.

A localized message
{ "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.

validateFormData is the backend entry point. It takes the same spec, the submitted payload, and an optional locale:

server/validate.ts
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
}

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 required behaves 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.

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.

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.

A project by Streamline Pulse