@jarenjs/forms
Framework-agnostic form generation for JSON Schema. Turns a schema into a renderable field tree and validates it in three layers, one stack:
- Per field, every keystroke — cheap, synchronous checks powered directly by
@jarenjs/coreprimitives (grapheme-aware string lengths, unicode patterns, deep equality) and the canonical format-tester registry of@jarenjs/formats: everything one field can know about itself. - Cross field, every keystroke — visibility, enablement, computed values, and preemptive assertions expressed as Jaren JSON Query documents in an
x-formannotation, compiled once per model by@jarenjs/jsonand evaluated per keystroke as cheap closures. - Authoritative, on submit — the complete compiled schema validation with
@jarenjs/validate, which ownsrequiredcombinations,dependentSchemas,if/then/else,unevaluatedProperties, and (via the$querykeyword) the very same cross-field rules.
No DOM, no framework: render the model with React, Vue, vanilla JS or anything else. Forms never imports the validator — apps wire the authoritative layer themselves. See it in action in the Jaren playground.
Usage
import {
buildFormModel,
createInitialData,
validateField,
parseFieldInput,
setValueAtPointer,
} from '@jarenjs/forms';
const schema = {
type: 'object',
title: 'Sign up',
properties: {
username: { type: 'string', minLength: 3, pattern: '^[a-z0-9_]+
The form model
buildFormModel(schema) resolves local $refs (#/$defs/...), merges allOf branches, and returns a tree of field descriptors:
Property
Meaning
pointer
JSON pointer into the data (/user/name)
label
title or a humanized property name (firstName → "First Name")
kind
string number integer boolean enum const object array
control
Rendering hint: text email url password textarea number checkbox select date color json
required
Whether the parent object requires this property
constraints
minLength/maxLength/pattern/format/minimum/maximum/multipleOf/minItems/...
rules
The raw x-form rules annotation, if any (see below)
enumValues / constValue / defaultValue / placeholder
Values for the UI
children
Child fields (object kinds)
item / tuple
Item template and tuple prefix fields (array kinds)
Field kinds are inferred from structural keywords when type is absent, and format maps to input controls and placeholders through the same registry the preemptive validation uses (getFormatInfo).
Layer 1 — preemptive per-field validation
validateField(field, value) returns [{ keyword, message }] using @jarenjs/core directly:
- strings: grapheme-aware
minLength/maxLength (getStringLength), unicode pattern (createRegExp, cached), and 50+ format testers from the @jarenjs/formats formatTesters registry — the same name → predicate table the authoritative validator's format compilers wrap, so both layers accept exactly the same strings
- numbers: type/integer checks, bounds,
multipleOf
- enum/const: deep equality (
equalsDeep)
- arrays:
minItems/maxItems/uniqueItems (isUniqueDeepArray)
validateAllFields(model, data) walks the whole tree and returns a { '/pointer': errors } map — ideal for rendering inline errors next to every field.
Layer 2 — x-form rules: cross-field behavior per keystroke
One namespaced annotation keyword — safe under every metaschema, invisible to validators — on any subschema. Its members are Jaren JSON Query documents (a bare RFC 9535 JSONPath string is the degenerate query):
{ "type": "object",
"properties": {
"company": { "type": "string" },
"vatId": { "type": "string",
"x-form": { "visible": { "$ne": ["$.company", ""] },
"assert": { "$or": [ { "$eq": ["$.company", ""] },
{ "$ne": ["$.vatId", ""] } ] },
"message": "VAT id is required for companies" } },
"total": { "type": "number",
"x-form": { "computed": { "$sum": "$.lines[*].amount" } } }
} }
Recognized members — unknown members are ignored for forward compatibility:
Member
Kind
Meaning
visible
EBV query
Should the field be shown?
enabled
EBV query
Should the field accept input?
assert
EBV query
Cross-field preemptive validation
computed
query
The field's derived value, mapped to plain JSON
message
string
Shown when assert fails
Rules compile once per model and evaluate per keystroke:
import { buildFormModel, compileFormRules, evaluateFormRules } from '@jarenjs/forms';
const model = buildFormModel(schema);
const rules = compileFormRules(model); // throws on malformed rules, with the field pointer
// per keystroke, after updating `data`:
const state = evaluateFormRules(rules, data);
// { '/vatId': { visible: true, errors: [{ keyword: 'x-form/assert',
// message: 'VAT id is required for companies' }] },
// '/total': { computed: 20 } }
The rule query context
Every rule kind shares one context:
$ — the input document is the whole form data root: cross-field is the point.
$value — the field's current value, bound as an external per evaluation. An absent field binds null (undefined is not a JSON value).
$pointer — the field's data pointer string ('/vatId').
These two externals are the whole vocabulary: any other free name in a rule is a compile-time error naming it.
visible/enabled/assert are asserted by effective boolean value (EBV, QUERY-FORMAT.md §2.2): the empty sequence is false, a singleton counts per its type, a multi-item sequence is runtime error JQ2003. Runtime errors follow a fixed policy: visible/enabled fail open (evaluate to true — a broken rule must never hide data or lock a control), assert fails closed (an assertion that cannot be computed has not been satisfied), and computed leaves the value absent.
Array item templates
A rule on an array item template (/lines/-/amount) compiles once and evaluates per element of the actual array, binding $value/$pointer per index — results are keyed by the expanded pointer (/lines/2/amount). That compiled-once/dispatch-per-node generalization now exists as the @jarenjs/json/jslt $apply engine: a future forms computed-view layer can generalize x-form.computed into schema-dispatched view-model stylesheets without changing forms' validator-independent boundary.
Schema literals in rules
Rules may use the query engine's schema operators ($valid/$as) by passing the same compileTypeTest hook the engine defines (QUERY-FORMAT.md §8.11) — this is the only door through which a validator reaches forms, and the app holds the key:
import { createTypeTestCompiler } from '@jarenjs/validate/query'; // app-side, not a forms dependency
const rules = compileFormRules(model, { compileTypeTest: createTypeTestCompiler() });
Composing rule errors with field errors
validateAllFields and evaluateFormRules stay separate on purpose (a render loop usually wants them at different times). Both speak the same error shape, so merging is one spread per pointer:
const fieldErrors = validateAllFields(model, data); // layer 1
const ruleState = evaluateFormRules(rules, data); // layer 2
const errorsAt = (pointer) => [
...(fieldErrors[pointer] ?? []),
...(ruleState[pointer]?.errors ?? []),
];
Layer 3 — write the rule once, enforce it on submit
The same constraint can be spelled twice — x-form.assert for keystroke feedback, the $query keyword for authoritative submit validation — or written once and copied:
import { formRulesToQueryAssertions } from '@jarenjs/forms';
// pure schema-to-schema transform: every x-form.assert is copied into a
// root-level $query (joined to an existing one through allOf), with
// value/pointer rebound to the field's location
const submitSchema = formRulesToQueryAssertions(schema);
import { JarenValidator } from '@jarenjs/validate'; // app-side
const validate = new JarenValidator().compile(submitSchema);
validate({ company: 'ACME', vatId: '' }); // false - the vatId assert, now authoritative
Item-template asserts quantify with $every over the actual elements. Two divergences from the keystroke path are inherent to the copy: on submit an absent field binds $value to the empty sequence (not null), and $pointer for template elements stays the template pointer (element indexes are a render-time notion).
Data helpers
Form data keeps plain JSON semantics — an untouched field is absent, not an empty string. Pointers parse and read through the @jarenjs/json compiled pointer engine (RFC 6901, one implementation repo-wide); reads hit a compiled-getter cache and allocate nothing:
createInitialData(model) — defaults and const values filled in, everything else absent
parseFieldInput(field, raw) — input coercion ('' → undefined, numeric strings → numbers, enum options → typed values)
getValueAtPointer / setValueAtPointer / appendItem / removeItemAt — immutable updates addressed by JSON pointer
createItemValue(field.item) — starter value for a new array item
Development
Unit tests live in test/forms/ at the repository root. See the repository README for the full Jaren documentation, and the ROADMAP for planned forms work (rule dependency memoization, hidden-field pruning on submit, computed views through JSLT).
},
email: { type: 'string', format: 'email' },
age: { type: 'integer', minimum: 13 },
},
required: ['username', 'email'],
};
// 1. Build the field tree once
const model = buildFormModel(schema);
// model.children -> [{ pointer: '/username', label: 'Username', control: 'text',
// required: true, constraints: {...} }, ...]
// 2. Start with the schema's defaults (untouched fields stay absent)
let data = createInitialData(model);
// 3. On every keystroke: coerce the raw input and validate the field
const field = model.children.find((f) => f.key === 'email');
const value = parseFieldInput(field, 'not-an-email'); // '' -> undefined, numbers -> Number, ...
const errors = validateField(field, value);
// [{ keyword: 'format', message: 'Must be a valid email' }]
data = setValueAtPointer(data, field.pointer, value); // immutable update
// 4. On submit (or continuously): the authoritative validation
import { JarenValidator } from '@jarenjs/validate';
const validate = new JarenValidator({ skipErrors: false, collectErrors: true }).compile(schema);
const result = validate(data); // { valid, errors: [{ instancePath, keyword, message, ... }] }
The form model
__INLINE_CODE_10__ resolves local __INLINE_CODE_11__s (__INLINE_CODE_12__), merges __INLINE_CODE_13__ branches, and returns a tree of field descriptors:
| Property | Meaning |
|---|---|
| __INLINE_CODE_14__ | JSON pointer into the data (__INLINE_CODE_15__) |
| __INLINE_CODE_16__ | __INLINE_CODE_17__ or a humanized property name (__INLINE_CODE_18__ → "First Name") |
| __INLINE_CODE_19__ | __INLINE_CODE_20__ __INLINE_CODE_21__ __INLINE_CODE_22__ __INLINE_CODE_23__ __INLINE_CODE_24__ __INLINE_CODE_25__ __INLINE_CODE_26__ __INLINE_CODE_27__ |
| __INLINE_CODE_28__ | Rendering hint: __INLINE_CODE_29__ __INLINE_CODE_30__ __INLINE_CODE_31__ __INLINE_CODE_32__ __INLINE_CODE_33__ __INLINE_CODE_34__ __INLINE_CODE_35__ __INLINE_CODE_36__ __INLINE_CODE_37__ __INLINE_CODE_38__ __INLINE_CODE_39__ |
| __INLINE_CODE_40__ | Whether the parent object requires this property |
| __INLINE_CODE_41__ | __INLINE_CODE_42__/__INLINE_CODE_43__/__INLINE_CODE_44__/__INLINE_CODE_45__/__INLINE_CODE_46__/__INLINE_CODE_47__/__INLINE_CODE_48__/__INLINE_CODE_49__/... |
| __INLINE_CODE_50__ | The raw __INLINE_CODE_51__ rules annotation, if any (see below) |
| __INLINE_CODE_52__ / __INLINE_CODE_53__ / __INLINE_CODE_54__ / __INLINE_CODE_55__ | Values for the UI |
| __INLINE_CODE_56__ | Child fields (object kinds) |
| __INLINE_CODE_57__ / __INLINE_CODE_58__ | Item template and tuple prefix fields (array kinds) |
Field kinds are inferred from structural keywords when __INLINE_CODE_59__ is absent, and __INLINE_CODE_60__ maps to input controls and placeholders through the same registry the preemptive validation uses (__INLINE_CODE_61__).
Layer 1 — preemptive per-field validation
__INLINE_CODE_62__ returns __INLINE_CODE_63__ using __INLINE_CODE_64__ directly:
- strings: grapheme-aware __INLINE_CODE_65__/__INLINE_CODE_66__ (__INLINE_CODE_67__), unicode __INLINE_CODE_68__ (__INLINE_CODE_69__, cached), and 50+ __INLINE_CODE_70__ testers from the __INLINE_CODE_71__ __INLINE_CODE_72__ registry — the same name → predicate table the authoritative validator's format compilers wrap, so both layers accept exactly the same strings
- numbers: type/integer checks, bounds, __INLINE_CODE_73__
- enum/const: deep equality (__INLINE_CODE_74__)
- arrays: __INLINE_CODE_75__/__INLINE_CODE_76__/__INLINE_CODE_77__ (__INLINE_CODE_78__)
__INLINE_CODE_79__ walks the whole tree and returns a __INLINE_CODE_80__ map — ideal for rendering inline errors next to every field.
Layer 2 — __INLINE_CODE_81__ rules: cross-field behavior per keystroke
One namespaced annotation keyword — safe under every metaschema, invisible to validators — on any subschema. Its members are Jaren JSON Query documents (a bare RFC 9535 JSONPath string is the degenerate query):
__CODE_BLOCK_1__Recognized members — unknown members are ignored for forward compatibility:
| Member | Kind | Meaning |
|---|---|---|
| __INLINE_CODE_82__ | EBV query | Should the field be shown? |
| __INLINE_CODE_83__ | EBV query | Should the field accept input? |
| __INLINE_CODE_84__ | EBV query | Cross-field preemptive validation |
| __INLINE_CODE_85__ | query | The field's derived value, mapped to plain JSON |
| __INLINE_CODE_86__ | string | Shown when __INLINE_CODE_87__ fails |
Rules compile once per model and evaluate per keystroke:
__CODE_BLOCK_2__The rule query context
Every rule kind shares one context:
- __INLINE_CODE_88__ — the input document is the whole form data root: cross-field is the point.
- __INLINE_CODE_89__ — the field's current value, bound as an external per evaluation. An absent field binds __INLINE_CODE_90__ (__INLINE_CODE_91__ is not a JSON value).
- __INLINE_CODE_92__ — the field's data pointer string (__INLINE_CODE_93__).
These two externals are the whole vocabulary: any other free name in a rule is a compile-time error naming it.
__INLINE_CODE_94__/__INLINE_CODE_95__/__INLINE_CODE_96__ are asserted by effective boolean value (EBV, QUERY-FORMAT.md §2.2): the empty sequence is __INLINE_CODE_97__, a singleton counts per its type, a multi-item sequence is runtime error __INLINE_CODE_98__. Runtime errors follow a fixed policy: __INLINE_CODE_99__/__INLINE_CODE_100__ fail open (evaluate to __INLINE_CODE_101__ — a broken rule must never hide data or lock a control), __INLINE_CODE_102__ fails closed (an assertion that cannot be computed has not been satisfied), and __INLINE_CODE_103__ leaves the value absent.
Array item templates
A rule on an array item template (__INLINE_CODE_104__) compiles once and evaluates per element of the actual array, binding __INLINE_CODE_105__/__INLINE_CODE_106__ per index — results are keyed by the expanded pointer (__INLINE_CODE_107__). That compiled-once/dispatch-per-node generalization now exists as the __INLINE_CODE_108__ __INLINE_CODE_109__ engine: a future forms computed-view layer can generalize __INLINE_CODE_110__ into schema-dispatched view-model stylesheets without changing forms' validator-independent boundary.
Schema literals in rules
Rules may use the query engine's schema operators (__INLINE_CODE_111__/__INLINE_CODE_112__) by passing the same __INLINE_CODE_113__ hook the engine defines (QUERY-FORMAT.md §8.11) — this is the only door through which a validator reaches forms, and the app holds the key:
__CODE_BLOCK_3__Composing rule errors with field errors
__INLINE_CODE_114__ and __INLINE_CODE_115__ stay separate on purpose (a render loop usually wants them at different times). Both speak the same error shape, so merging is one spread per pointer:
__CODE_BLOCK_4__Layer 3 — write the rule once, enforce it on submit
The same constraint can be spelled twice — __INLINE_CODE_116__ for keystroke feedback, the __INLINE_CODE_117__ keyword for authoritative submit validation — or written once and copied:
__CODE_BLOCK_5__Item-template asserts quantify with __INLINE_CODE_118__ over the actual elements. Two divergences from the keystroke path are inherent to the copy: on submit an absent field binds __INLINE_CODE_119__ to the empty sequence (not __INLINE_CODE_120__), and __INLINE_CODE_121__ for template elements stays the template pointer (element indexes are a render-time notion).
Data helpers
Form data keeps plain JSON semantics — an untouched field is absent, not an empty string. Pointers parse and read through the __INLINE_CODE_122__ compiled pointer engine (RFC 6901, one implementation repo-wide); reads hit a compiled-getter cache and allocate nothing:
- __INLINE_CODE_123__ — defaults and __INLINE_CODE_124__ values filled in, everything else absent
- __INLINE_CODE_125__ — input coercion (__INLINE_CODE_126__ → undefined, numeric strings → numbers, enum options → typed values)
- __INLINE_CODE_127__ / __INLINE_CODE_128__ / __INLINE_CODE_129__ / __INLINE_CODE_130__ — immutable updates addressed by JSON pointer
- __INLINE_CODE_131__ — starter value for a new array item
Development
Unit tests live in __INLINE_CODE_132__ at the repository root. See the repository README for the full Jaren documentation, and the ROADMAP for planned forms work (rule dependency memoization, hidden-field pruning on submit, computed views through JSLT).