Formhaus form definition 1.0
This page specifies the JSON format that @formhaus/core reads, and how FormEngine interprets it. The JSON Schema describes the shape; this page describes the behaviour. For the TypeScript types, see the Definition Reference.
Conventions
The key words MUST, MUST NOT, SHOULD and MAY are used as described in RFC 2119. They appear only where FormEngine enforces the rule by throwing an error or by its runtime behaviour.
- Core means
FormEnginefrom@formhaus/core. - Renderer means a UI layer that draws a definition:
@formhaus/react,@formhaus/vue, the Figma plugin, or a custom renderer built on core. - Value means an entry in
engine.values, keyed by fieldkey. - Empty means
undefined,null,''or[]unless a section says otherwise.
Versioning
This document describes Formhaus form definition 1.0. @formhaus/core 0.9.x implements it. A definition carries no version marker.
- Additive changes, such as a new optional property or a new built-in field type, are minor versions.
- Removing or renaming a property, or changing the meaning of an existing one, is a major version.
- The JSON Schema
$id,https://formhaus.dev/schema/form-definition.json, is the canonical identifier of the format. It is unversioned and always describes the latest version. A major version publishes the previous schema under a versioned URL and states it in this section.
Document structure
A definition is a JSON object. The JSON Schema rejects unknown properties at every level. Core ignores them.
| Property | Type | Required | Meaning |
|---|---|---|---|
$schema | string | no | Editor hint. Core ignores it. |
id | string | yes | Form identifier. Renderers use it to detect a new form. |
title | string | yes | Form title for host UI and Figma output. Renderers do not display it. |
submit | action | yes | The submit action on the last step. |
cancel | action | no | A cancel action. Renderers show it on every step. |
fields | field[] | no | Fields of a single-step form. |
steps | step[] | no | Steps of a multi-step form. |
A form is multi-step when steps is a non-empty array. Otherwise it is single-step and uses fields. A definition with neither is a valid empty form.
A definition MUST NOT have both a non-empty fields and a non-empty steps. The FormEngine constructor throws on it.
Fields
| Property | Type | Meaning |
|---|---|---|
key | string, required | Key of the value. Keys are flat: "address.city" stays one key. |
type | string, required | Field type. |
label | string, required | Label text. |
placeholder | string | Placeholder text. |
helperText | string | Hint text below the field. |
defaultValue | any | Initial value. See Defaults. |
show | condition[] | Visible when all conditions match. |
showAny | condition[] | Visible when at least one condition matches. |
validation | validation | Validation rules. |
options | { value: string, label: string }[] | Choices for select, radio, multiselect and autocomplete. |
optionsFrom | string | Name of a renderer options provider. |
optionsDependsOn | string[] | Field keys whose changes reload optionsFrom. |
autoAdvance | boolean | See Auto-advance. |
accept | string | Accepted file types for file. |
rows | number | Row count for textarea. |
mask | string | Input mask. Built-in renderers use it as the placeholder when placeholder is absent. |
inputMode | text, numeric, tel, email | Virtual keyboard hint. |
Field keys SHOULD be unique across the whole definition. Fields with the same key share one value. validateDefinition() warns about duplicates.
Field types
type is any string. The built-in renderers provide these types:
| Type | Value written by built-in renderers |
|---|---|
text, email, phone, password, textarea, autocomplete | string |
number | number, or '' when the input is cleared |
select, radio | string, the selected option value |
multiselect | string[] of selected option values |
checkbox, switch | boolean |
file | a File, or null |
date | string YYYY-MM-DD |
datetime | string YYYY-MM-DDTHH:mm, without a timezone |
Any other string is a custom type. A renderer provides its component through its components option. The built-in React and Vue renderers show an "Unsupported field type" placeholder for a type without a component. Core does not interpret type, except that an unchecked checkbox or switch (false) counts as empty for required.
Options
options is a static list. optionsFrom names an options provider passed to the renderer. Built-in renderers call the provider with all current values, and again each time a field in optionsDependsOn changes. The returned options replace options. Core does not read options, optionsFrom or optionsDependsOn, and does not check that a value is one of the options.
Conditions
A condition tests the value of one field:
| Property | Type | Matches when |
|---|---|---|
field | string, required | Key of the field to test. |
eq | string, number or boolean | value === eq |
neq | string, number or boolean | value !== neq |
in | (string or number)[] | the list contains the value |
notIn | (string or number)[] | the list does not contain the value |
notEmpty | boolean | true: the value is not empty |
Each condition uses one operator. If several are present, only the first one in the order eq, neq, in, notIn, notEmpty applies. A condition without an operator, or with notEmpty: false, always matches.
Comparison is strict and without type coercion: "1" does not equal 1. in and notIn test the value itself, so an array value never matches in. false and 0 count as not empty for notEmpty. in: [] never matches; notIn: [] always matches.
Combining conditions
An object with show and showAny is visible when:
- every condition in
showmatches, and showAnyis absent or empty, or at least one of its conditions matches.
An empty show array always passes. An empty showAny array is treated as absent. The same rule applies to steps and routes.
Visibility
A field is visible when its own conditions pass and, in a multi-step form, its step is on the active path.
Core clears the value of a field that becomes hidden. The key is removed from engine.values and its error is removed. Clearing runs on transitions: when the engine is constructed, after reset(), after setValue() and after Skip. It cascades: if a cleared value hides another field or step, that value is cleared too. Every field of a hidden step is cleared. A field that becomes visible again starts empty; its defaultValue is not restored.
Clearing reacts to a field becoming hidden, not to writes into a hidden field. Without routes, a value passed to setValue() for a field that is already hidden stays in engine.values until a field its conditions reference changes, or until reset(). With routes, every setValue() re-checks all conditions, so such a value is cleared at once. A hidden value is never validated or submitted.
In a form with routes, fields on steps that are off the active path keep their values. See Routes.
Hidden fields are not validated and not submitted.
Without routes, field and step conditions see all values, including values of later steps.
Defaults and initial values
When the engine is constructed or reset, values are built in this order:
defaultValueof every field that has one.- The initial values passed to
new FormEngine(definition, initialValues)orreset(values). They override defaults. - Hidden fields are cleared.
Initial values for keys that do not belong to any field stay in engine.values but are not submitted.
Validation
validation holds these rules:
| Rule | Type | Applies to | Default message |
|---|---|---|---|
required | boolean or string | any value | This field is required |
minLength | number | string length, array length | Must be at least N characters (items for arrays) |
maxLength | number | string length, array length | Must be at most N characters (items for arrays) |
min | number | number values | Must be at least N |
max | number | number values | Must be at most N |
pattern | string | String(value) | Invalid format |
matchField | string | any value | Fields must match |
validator | string | any value | message returned by the validator |
Each rule except required and validator has a matching message property: minLengthMessage, maxLengthMessage, minMessage, maxMessage, patternMessage, matchFieldMessage. A string required is its own message.
Core evaluates one field as follows and returns the first error:
- If the value is empty, return the
requirederror whenrequiredistrueor a non-empty string, otherwise no error. No other rule runs. For validation, empty also includesfalseforcheckboxandswitch. - For a string or array:
minLength, thenmaxLength. For a number:min, thenmax. Other value types skip these rules. A numeric string is not converted. pattern, compiled withnew RegExp(pattern)without flags and without implicit anchors. An invalid pattern is ignored at runtime and reported byvalidateDefinition().matchField: the value is compared with===to the value of the named field. A field on a skipped step has no value here. In a form with routes, a field off the active path has no value either.validator: the named function from thevalidatorsoption is called with the value and the values thatmatchFielduses. A non-empty string result is the error;nullor''is no error, andvalidateField()returnsnullfor both. An unregistered name is ignored at runtime, and the constructor logs a warning for it. The validator never receives an empty value, because step 1 returns first.
Validation runs on Continue for the visible fields of the current step, and on Submit for the visible fields of every non-skipped step on the active path. Renderers do not validate on blur.
Steps
| Property | Type | Meaning |
|---|---|---|
id | string, required | Step identifier. |
title | string, required | Step heading. |
description | string | Text below the heading. |
fields | field[], required | Fields of the step. |
show, showAny | condition[] | Step visibility. |
routes | route[] | Ordered forward destinations. |
next | action or false | Continue action. |
back | action or false | Back action. |
skip | action | Skip action. See Skip. |
Step ids MUST be unique. The constructor throws on duplicates.
A hidden step is left out of navigation, progress, validation and submission, and its field values are cleared.
In a form with routes, step show/showAny SHOULD reference fields of earlier steps only. A step condition sees only the values of earlier steps on the path, so a field of the same or a later step has no value there. validateDefinition() warns about such conditions; the constructor does not throw.
Actions
An action is an object:
| Property | Type | Meaning |
|---|---|---|
label | string, required | Button text. |
variant | primary, secondary, text | Button style. |
action | string | Identifier for custom action components. |
disabled | condition[] | Disabled when all conditions match. |
The five action slots are:
submitreplaces Continue on the last step of the active path, and is the only primary action of a single-step form.nextsets the Continue label.next: falsehides Continue on that step. It does not block navigation, and it never hides Submit on the last step. Renderers do not show Skip on a step withnext: false.backsets the Back label.back: falsehides Back. Back never appears on the first step.skipshows a Skip action on that step.cancelshows a Cancel action that calls the renderer's cancel handler. Core has no cancel operation.
Built-in renderers draw Continue and Submit as primary, Back as secondary, and Skip and Cancel as text buttons. variant changes the style of back, skip and cancel. Built-in renderers evaluate disabled only on submit, against engine.values, not the active projection. Values of fields off the active path still count. An empty disabled array never disables. Default labels are Continue, Back, Skip and Submit.
Routes
A route is { "to": string | null, "show"?: condition[], "showAny"?: condition[] }. Routing is enabled for the whole form when at least one step has a non-empty routes array. Without routes, the active path is every visible step in declaration order.
Rules enforced by the constructor:
toMUST be the id of a later declared step, ornull. Unknown, self and backward targets throw. Routes cannot form cycles.- Route conditions MUST reference fields of the same step or an earlier step.
Route selection on a step:
- Routes are tried in order. A route matches when its conditions pass and its target step is visible. A route to a hidden step is passed over.
- The first match wins. A route without conditions always matches, so it serves as a fallback when it is last.
- A matching
to: nullmakes this step the last step of the path. Submit appears there. It does not submit the form. - If no route matches, the path continues with the next declared step.
Branches converge only through routes. A branch step without an exit route falls through to the next declared step, which may be a sibling branch. validateDefinition() warns when a route target has no unconditional route and the next declared step is another target of the same step.
Active path
With routes, core computes the active path from the first declared step:
- Start with an empty set of path values.
- A step whose
show/showAnyfails against the path values of earlier steps is passed over and the next declared step is tried. - A visible step joins the path. Its visible fields with defined values are added to the path values. Field conditions in that step see the earlier path values and every value of the same step.
- The next step comes from route selection, evaluated against the path values.
The path values are the active projection. Route conditions, step conditions, field visibility, validation, lifecycle hooks and submission use the active projection, not engine.values. Values of fields on steps off the path stay in engine.values, so returning to a branch restores them, but they cannot select a route or enter the payload.
When a value change removes the current step from the path, core moves to the nearest earlier step of the old path that is still on the new path. Back moves to the previous step of the current path.
Skip
Skip on a step with skip:
- Resets the step's fields to their
defaultValue, or removes them when there is none, and removes their errors. - Marks the step as skipped and moves to the next step of the path computed from the reset values. Skip does not run field validation or
onStepValidate.skipStepAsync()runsonBeforeStepChangeandonAfterStepChangewithreason: 'skip';falsefrom the before-hook cancels the skip and changes nothing. - A skipped step is excluded from submit validation. Its fields are excluded from the payload and from the values that validation and lifecycle hooks receive.
On the last step, skipStepAsync(submit) submits the form without that step and runs the submit hooks. Built-in renderers pass their submit handler, so their Skip submits. skipStep() and skipStepAsync() without a handler return false there. Continue or Submit on a skipped step, or a changed value of one of its fields, includes it again. validateDefinition() warns about skip on a step with next: false or in a form with one step.
Auto-advance
autoAdvance: true lets a renderer advance when the user commits an answer. The built-in radio commits on click, Space and Enter; arrow keys only select. Commit validates the whole current step and then moves forward. Initial values and setValue() never advance, and the last step never auto-submits. Other built-in types ignore autoAdvance. Custom fields commit through onCommit in React and the commit event in Vue.
Submit payload
getSubmitValues() and the submit handler receive an object with:
- every visible field with a value other than
undefined, on every step of the active path, or every visible field of a single-step form; null,'',falseand[]values included;- fields of skipped steps, hidden fields, fields off the active path and keys that are not field keys excluded.
Keys are field keys as written. The payload is not nested and not type-converted.
Definition checks
The FormEngine constructor throws on:
- both
fieldsandstepsnon-empty; - an invalid route target or route condition field;
- duplicate step ids.
validateDefinition(definition) returns warnings for the issues above and for duplicate field keys, conditions on unknown fields, circular show conditions, invalid pattern regexes, skip without a Continue or later step, branch fall-through, and step conditions on the same or a later step in a form with routes. The constructor logs these warnings with console.warn, together with a warning for each validator name missing from the validators option.
JSON Schema
The JSON Schema is published at https://formhaus.dev/schema/form-definition.json and ships as @formhaus/core/schema.json. A definition MAY set $schema to that URL for editor validation and completion. The schema checks shape only. Rules that depend on other parts of the definition, such as route targets, are checked by validateDefinition().
Minimal single-step definition:
{
"$schema": "https://formhaus.dev/schema/form-definition.json",
"id": "contact",
"title": "Contact",
"submit": { "label": "Send" },
"fields": [
{ "key": "name", "type": "text", "label": "Name", "validation": { "required": true } }
]
}Complete example
This definition is normative. It uses conditions, defaults, validation, routes with convergence, to: null, skip and action overrides.
{
"$schema": "https://formhaus.dev/schema/form-definition.json",
"id": "account-application",
"title": "Open an account",
"submit": { "label": "Open account" },
"cancel": { "label": "Cancel", "variant": "text" },
"steps": [
{
"id": "kind",
"title": "Account type",
"next": false,
"back": false,
"fields": [
{
"key": "accountType",
"type": "radio",
"label": "Account type",
"autoAdvance": true,
"validation": { "required": "Choose an account type" },
"options": [
{ "value": "business", "label": "Business" },
{ "value": "personal", "label": "Personal" },
{ "value": "waitlist", "label": "Not sure yet" }
]
}
],
"routes": [
{ "to": null, "show": [{ "field": "accountType", "eq": "waitlist" }] },
{ "to": "business", "show": [{ "field": "accountType", "eq": "business" }] },
{ "to": "personal" }
]
},
{
"id": "business",
"title": "Company",
"fields": [
{ "key": "company", "type": "text", "label": "Company name", "validation": { "required": true, "minLength": 2 } },
{
"key": "employees",
"type": "number",
"label": "Employees",
"validation": { "min": 1, "max": 10000, "maxMessage": "Contact sales for more than 10000 employees" }
}
],
"routes": [{ "to": "contact" }]
},
{
"id": "personal",
"title": "About you",
"fields": [
{ "key": "name", "type": "text", "label": "Full name", "validation": { "required": true } },
{ "key": "birthDate", "type": "date", "label": "Date of birth" }
],
"routes": [{ "to": "contact" }]
},
{
"id": "contact",
"title": "Contact",
"back": { "label": "Edit details" },
"fields": [
{ "key": "channel", "type": "select", "label": "Preferred channel", "defaultValue": "email", "options": [
{ "value": "email", "label": "Email" },
{ "value": "phone", "label": "Phone" }
] },
{
"key": "email",
"type": "email",
"label": "Email",
"validation": { "required": true, "pattern": "^[^@\\s]+@[^@\\s]+$", "patternMessage": "Enter a valid email" }
},
{
"key": "confirmEmail",
"type": "email",
"label": "Confirm email",
"validation": { "required": true, "matchField": "email", "matchFieldMessage": "Emails must match" }
},
{
"key": "phone",
"type": "phone",
"label": "Phone",
"inputMode": "tel",
"show": [{ "field": "channel", "eq": "phone" }],
"validation": { "required": true }
},
{ "key": "terms", "type": "checkbox", "label": "I accept the terms", "validation": { "required": "Accept the terms to continue" } }
]
},
{
"id": "newsletter",
"title": "Newsletter",
"skip": { "label": "Not now" },
"fields": [
{ "key": "subscribe", "type": "switch", "label": "Send me product news", "defaultValue": false },
{
"key": "topics",
"type": "multiselect",
"label": "Topics",
"showAny": [{ "field": "subscribe", "eq": true }],
"validation": { "required": "Pick at least one topic" },
"options": [
{ "value": "product", "label": "Product updates" },
{ "value": "events", "label": "Events" }
]
}
]
}
]
}With accountType: "personal", the active path is kind, personal, contact, newsletter. Values entered earlier on business stay in engine.values and are not submitted. With accountType: "waitlist", the first route ends the path at kind, and Submit appears there. Skipping newsletter submits without subscribe and topics.