Skip to content
Forms, switch product

JSON schemas

A widget set has a second authoring surface besides its TypeScript namespace: JSON form definitions. The schema layer behind that surface is layered the same way the authoring pipeline is, so a widget set can publish its own schema tree instead of forking the gui one. @golemui/schemas owns the shared core resource and the tooling; @golemui/gui-schemas is the reference tree built on top of it.

The split follows the same rule as the rest of the widget set architecture. Core publishes exactly one $defs resource, common.schema.json, holding the structural pieces every widget set needs: baseWidget, dotPath, uid, reactiveExpression, localizable, chunkRef, include, exclude, boolOrWhen and on. Everything above that is yours, including the validation vocabulary.

That last point is why validators.schema.json sits in the gui tree rather than in core. A widget set that brings its own ValidatorFn describes its own config shape, and the only cross-layer requirement is the entry pointer: the file must expose #/$defs/validator, which is what the generated envelope refers to.

The tree on golemui.com has one core directory and one directory per implementation:

schemas/
core/
common.schema.json shared structural $defs, the only core resource
gui/
form.schema.json gui envelope (generated)
widgets.schema.json gui widget union + knownWidgetTypes (generated)
layout-widget.schema.json layout widget union (generated)
validators.schema.json the standard validator set (handwritten)
ranges.schema.json gui date and time range defs (handwritten)
components/*.schema.json one schema per widget type (handwritten)
core/ vendored copy of schemas/core (generated)
form.schema.json legacy alias for gui/form.schema.json, kept forever

Your widget set publishes the same shape under its own base URL, its idBase, which does not have to be on golemui.com. Each file’s $id is ${idBase} plus its path inside the tree, and every cross-file reference is relative (./core/common.schema.json#/$defs/baseWidget).

JSON authoring then points $schema at your envelope:

{
"$schema": "https://example.com/schemas/kendo/form.schema.json",
"states": { "review": "$form.review === true" },
"form": [{ "kind": "input", "type": "kendoText", "path": "customer.name" }],
"autocomplete": "off"
}

form is the only required property and additionalProperties is false. Note that form is an array: core also decodes a single root layout object there, but the schema does not accept it, so JSON written against the schema always uses the array form. Core wraps it in the synthetic flex root at initialization.

Two of the reserved core contracts show up directly in JSON authoring:

  • flex is the type of the synthetic root core creates for a bare widget array, so your tree needs a flex component schema even though authors rarely write it by hand.
  • repeater carries its template as a layout widget under props.template, and the paths of widgets inside the template use the <repeaterPath>.items.<field> grammar. A repeater at guests holds a text input at guests.items.name, and core rewrites that to guests.0.name per item when it materializes the template. JSON authors write the grammar explicitly, so your component schemas must accept it.

The tree uses plain $ref composition only. $dynamicRef would be the standard mechanism for a base schema whose widget section each implementation fills in, but VS Code’s JSON language service does not implement it, and editor support is the entire point of shipping a schema. That is why the envelope and the widget union are generated per implementation rather than inherited: the duplication is a template, not maintenance.

widgets.schema.json carries two things generated from one list, the formWidget union and the knownWidgetTypes enum. Generating both together removes the failure mode where the enum and the union drift apart, and the enum is what keeps the optional custom-widget fallback disjoint from the built-in branches.

A widget type that exists only in the builder and has no JSON representation can be listed in knownWidgetTypes without a schema branch. gui does this with renderer, whose props hold a render function that JSON cannot express: using it in a JSON form is now rejected with a real validation error instead of silently matching the custom fallback and failing at runtime.

Every implementation tree contains a generated copy of the core file under its own directory, for gui that is gui/core/common.schema.json. It is identical to its source apart from one line, its $id, which is rebased onto the implementation tree.

The copy exists because relative refs cannot cross npm package boundaries. With it, the tree resolves under both strategies that matter: absolute $id resolution (Ajv, editors fetching published URLs) and document-relative file resolution (a consumer pointing into node_modules). It is also what lets two widget sets load into one Ajv instance, since no two files share an $id.

In Ajv (the 2020 dialect), register every file under the tree keyed by its own $id, then compile the envelope:

import Ajv2020 from 'ajv/dist/2020';
const ajv = new Ajv2020();
for (const schema of everyFileInTheTree) {
if (!ajv.schemas[schema.$id]) {
ajv.addSchema(schema);
}
}
const validate = ajv.compile({
$ref: 'https://example.com/schemas/kendo/form.schema.json',
});

Three rules make that work: register leaves before aggregates, dedupe through ajv.schemas, and never call ajv.getSchema during registration. getSchema compiles on lookup, so calling it while refs are still unregistered throws MissingRefError.

Editors behave differently. VS Code resolves each file’s relative refs against its absolute $id, computes the published URLs and tries to download them, which fails offline, in untrusted workspaces, and anywhere the idBase is not actually hosted. The answer is the editor bundle, form.editor.schema.json: a generated, self-contained copy of everything reachable from the envelope, with no $ids and only same-document #/$defs/... pointers. Point a form file’s $schema at it during development, and regenerate after editing any handwritten component or validators schema, because the bundle inlines those too.