Forms from JSON Schema
fromJsonSchema converts a JSON Schema that describes your form data into a GolemUI form
definition. It is a pure function. Call it at runtime with a schema from an API, or run the
convert command at build time to write a form file that you then edit by hand. The MCP tools
json_generate_from_schema and json_generate_from_openapi use
the same function.
The result is a JSON form definition. formDef accepts a JSON definition on both APIs, so you can
pass the result to <gui-form> on the Programmatic API too.
Installation
Section titled “Installation”npm i @golemui/schemas @golemui/gui-schemas@golemui/schemas holds the converter, and it does not depend on any widget set. The widgets come
from a preset. @golemui/gui-schemas holds the preset of the gui widget set. Keep both packages on
the same version as your other @golemui/* packages.
Converting at runtime
Section titled “Converting at runtime”import { fromJsonSchema } from '@golemui/schemas/json-schema';import { guiPreset } from '@golemui/gui-schemas/json-schema';
const schema = await fetch('/api/order.schema.json').then((response) => response.json(),);const { formDefinition, diagnostics } = fromJsonSchema(schema, { preset: guiPreset(),});
const config = { formDef: formDefinition };formDefinition has the shape { $schema, states?, form }. diagnostics lists what the form
cannot express exactly, see Diagnostics. The function imports no Node modules, so it
runs in Node, in the browser and in Workers.
The demo below is the form fromJsonSchema writes for this
order schema,
with no edits by hand. The schema has a discriminated oneOf, two if/then/else conditions, a
dependentRequired and an array of $ref items.
How the gui preset maps a schema
Section titled “How the gui preset maps a schema”The preset chooses one widget per schema node:
| Schema node | Widget |
|---|---|
enum, or oneOf / anyOf of const values | select up to 6 options, dropdown above |
const string or number | read-only textinput or number, with the value as default |
string with format: date, date-time or time | dateInput, dateTimePicker or timeInput |
string with format: password, writeOnly: true, or a name with password, secret, apiKey or token | password |
string with contentMediaType: text/markdown | markdown |
string with a maxLength of 200 or more | textarea |
| any other string | textinput |
number or integer | number |
boolean | checkbox, which starts as false when the property is required |
| array of objects | repeater, with maxItems as its limit |
| array of choices | multiList up to 6 options, multiDropdown above |
| array of strings or numbers | tags, which writes strings, so number items give item-type-mismatch |
tuple (prefixItems) | a row of widgets, one per position |
| object | flex column |
Every input also takes these fields from its schema:
| Schema | Widget field |
|---|---|
title | label. Without a title, the property name: deliveryDate becomes “Delivery Date”. |
description | props.hint |
the first entry of examples | props.placeholder, on text, number, select, dropdown and tags inputs |
default, or the value an enclosing object default has for it | defaultValue |
readOnly: true | readonly: true |
The constraints become a gui validator:
| Type | Constraints |
|---|---|
| string | required, const, enum, minLength, maxLength, pattern, and format for email, hostname, ipv4, ipv6, uri, uuid, duration |
| number | required, const, enum, minimum, maximum, exclusiveMinimum, exclusiveMaximum, multipleOf |
| boolean | required, const, enum |
| array | required, minItems, maxItems, uniqueItems |
Two of these do not mean exactly the same as in JSON Schema. A gui required rejects an empty value,
and JSON Schema required only asks for the property to be present. The date, date-time and
time formats are not checked, because the date and time widgets write their own value format.
What the converter reads
Section titled “What the converter reads”- Drafts. draft-04, draft-07 and 2020-12 schemas, and OpenAPI 3.0 schema objects. Older keywords are read in their 2020-12 form.
$ref. References into$defs,definitions, and OpenAPIcomponents/schemas. Pass the whole OpenAPI document asrefRootwhen you convert one schema of it. A recursive reference stops aftermaxRefDepthexpansions, 2 by default, with arecursive-refwarning.allOf. The parts are merged. When they disagree on a keyword, the first value wins and the converter reportsallof-conflict.- Nullable types.
type: ["string", "null"]and OpenAPI 3.0nullable: trueare read as the non-null type.
not, contains, propertyNames, patternProperties, unevaluatedProperties,
unevaluatedItems, $dynamicRef and $recursiveRef have no form equivalent. The form does not
check them, and the converter reports unsupported-keyword. An additionalProperties with a schema
gets the same warning, because no widget edits free-form keys.
Unions
Section titled “Unions”A oneOf or anyOf of objects becomes a selector plus the properties of every branch. The selector
is the property that tells the branches apart:
- OpenAPI
discriminator.propertyName, when the schema has one. - Otherwise, the first property that has a different
constin every branch.
The options of the selector are labelled with the branch titles. Each branch property is built
once, with an include.when condition on the branches that define it:
"payment": { "oneOf": [ { "title": "Card", "type": "object", "required": ["method", "cardNumber"], "properties": { "method": { "const": "card" }, "cardNumber": { "type": "string", "title": "Card number", "pattern": "^[0-9]{16}$" } } }, { "title": "Bank transfer", "type": "object", "required": ["method", "iban"], "properties": { "method": { "const": "bank" }, "iban": { "type": "string", "title": "IBAN" } } } ]}[ { "kind": "input", "type": "select", "path": "payment.method", "label": "Method", "validator": { "type": "string", "required": true }, "props": { "options": [ { "label": "Card", "value": "card" }, { "label": "Bank transfer", "value": "bank" } ] } }, { "kind": "input", "type": "textinput", "path": "payment.cardNumber", "label": "Card number", "include": { "when": "$form.payment?.method === \"card\"" }, "validator": { "type": "string", "required": true, "pattern": "^[0-9]{16}$" } }, { "kind": "input", "type": "textinput", "path": "payment.iban", "label": "IBAN", "include": { "when": "$form.payment?.method === \"bank\"" }, "validator": { "type": "string", "required": true } }]The form removes the data of hidden widgets on submit, so the submitted value holds only the branch
the user chose. Inside an array, the condition reads the row through $item.
A union without a discriminator renders every branch property, optional and always visible, and the
converter reports no-discriminator.
Conditions
Section titled “Conditions”if/then/else, dependentRequired, dependentSchemas and draft-07 dependencies on an object are
compiled into form logic:
- A property that only a branch defines is built once, with an
include.whencondition. It is placed right after the last property the condition reads. - A declared property that a branch constrains gets a state validator. The branch condition becomes
a form state, named
if01,dep01and so on, and the constraints become avalidator.<state>entry.
For example, this if/then/else changes the postal code pattern by country and adds a state
field for the United States:
"if": { "properties": { "country": { "const": "US" } }, "required": ["country"] },"then": { "properties": { "zip": { "pattern": "^[0-9]{5}$" }, "state": { "type": "string", "title": "State", "enum": ["CA", "NY", "TX"] } }, "required": ["zip", "state"]},"else": { "properties": { "zip": { "pattern": "^[0-9]{4} ?[A-Z]{2}$" } } }state gets "include": { "when": "$form.country === \"US\"" }. zip is declared on the object, so
it gets one validator per branch:
{ "states": { "if01": "$form.country === \"US\"", "if02": "!($form.country === \"US\")" }, "form": [ { "kind": "input", "type": "textinput", "path": "zip", "label": "Postal code", "validator.if01": { "type": "string", "required": true, "pattern": "^[0-9]{5}$" }, "validator.if02": { "type": "string", "pattern": "^[0-9]{4} ?[A-Z]{2}$" } } ]}A field takes one state validator at a time. A field can become required under any number of
conditions, because one state joins them with ||. It can also take its constraints from the
branches of one if. Constraints from a second condition are not applied, and the converter
reports overlay-limit.
An if compiles when it uses properties, required, const, enum, not with const or
enum, numeric bounds and nested properties. Any other if gives an if-unsupported warning,
and its branch properties are always shown and optional. As in JSON Schema, a tested property that
is not in if.required passes when it is absent. The if-vacuous note points this out.
Inside an array, a condition can show and hide widgets through $item. States cannot read the row,
so conditional constraints inside an array are not applied, and the converter reports
conditional-validator-in-repeater.
Choosing other widgets
Section titled “Choosing other widgets”Four layers change what the preset builds. For each node, the first layer that names a widget
builds it:
- A path override:
overrides: { "address.street": { "widget": "textarea" } }. Inside arrays the path uses theitemstoken, for examplelines.items.quantity. The root is''. - A definition override:
overrides: { "$defs/Address": { ... } }, for every node that comes from that definition. The key is$defs/<name>also for a definition indefinitionsor in OpenAPIcomponents/schemas. - The
x-golemuikeyword in the schema. Rename it with thevendorKeywordoption, or ignore it withvendorKeyword: false. - The rules, in order: first your
rules, then the preset rules.
Overrides, x-golemui and JSON rules take the same fields. The fields other than widget are set
on the built widget, and the most specific layer is applied last. Only props is merged key by key.
| Field | Effect |
|---|---|
widget | The widget type to build, e.g. "radiogroup" or "toggle". |
label | The widget label. |
props | Merged into the widget props, key by key. |
validator | Replaces the widget validator. Write the whole validator, with its type. |
defaultValue | The default value. |
readonly | Makes the widget read-only. |
size | The relative width, from 1 to 12. |
uid | The widget uid. |
skip | true leaves the node out of the form. |
order | The order of the properties of an object. '*' stands for the others. |
In the schema, the same fields go under x-golemui:
"bio": { "type": "string", "title": "Bio", "x-golemui": { "widget": "markdown", "props": { "autoGrow": true } }}JSON rules
Section titled “JSON rules”A JSON rule has a match object and the fields above. It is plain JSON, so it also works in a
.json config file and in an MCP call:
const { formDefinition } = fromJsonSchema(schema, { preset: guiPreset(), rules: [ { match: { $type: 'string', format: 'email' }, props: { icon: 'mail' } }, { match: { $type: 'number', $name: { $regex: 'amount|Amount' } }, widget: 'currency', }, { match: { $name: 'id' }, readonly: true }, ], overrides: { notes: { widget: 'textarea', props: { autoGrow: true } } },});A rule without widget only adds its fields to the widget that the next matching rule builds. The
first rule above puts a mail icon on every e-mail field, and the third makes every id read-only.
match compares schema keywords by deep equality, or with an operator. Keys that start with $
match fields of the node instead of the schema:
| Key | Matches |
|---|---|
| any keyword | The schema keyword, e.g. "format": "email". |
$type | The node type: string, number, integer, boolean, array, object. |
$path | The form data path, as a glob. * is one segment, ** any number. |
$name | The property name. |
$defName | The name of the definition the node came from. |
$required | true when the parent object requires the property. |
$inRepeater | true inside the items of an array. |
| Operator | Matches when |
|---|---|
{ "$in": [a, b] } | The value equals one of the entries. |
{ "$exists": true } | The value is present. With false, it is absent. |
{ "$regex": "pattern" } | The value is a string that matches the pattern. |
Function rules
Section titled “Function rules”When a JSON rule cannot express the choice, write a rule as a pair of functions. This one builds a
currency input from a custom x-currency keyword and copies its value into the props:
import type { Rule } from '@golemui/schemas/json-schema';
const currencyRule: Rule = { name: 'x-currency', when: (node) => typeof node.schema['x-currency'] === 'string', build: (node, context) => ({ kind: 'input', type: 'currency', path: node.path, label: context.label(node), validator: context.validator(node), props: { currency: node.schema['x-currency'] }, }),};node has the resolved schema, the type, the form data path, the property name, the
defName, required and the parent node. context builds the parts of a widget:
| Method | Returns |
|---|---|
context.label(node) | The widget label. |
context.validator(node) | The validator of the preset for the node. |
context.enumOptions(node) | The { label, value } options of an enumeration. |
context.item(node) | The item node of an array node. |
context.children(node) | The child nodes in render order: the properties of an object, or the positions of a tuple. |
context.next(node) | The widget the next matching rule builds, to change one detail of it. |
context.build(node) | The widget for any node, built with every layer. |
context.buildChildren(node) | The widgets of the properties of an object node. |
context.diagnostic(entry) | Reports something the rule could not express exactly. |
build can return one widget, an array of widgets, null to render nothing, or undefined to let
the next matching rule build the node. A rule that throws is skipped in the same way, and the
converter reports rule-error.
Options
Section titled “Options”fromJsonSchema(schema, options) takes:
| Option | Default | Description |
|---|---|---|
preset | required | The widget set preset, e.g. guiPreset(). |
rules | [] | JSON and function rules, checked in order before the preset rules. |
overrides | {} | Fields by form data path or by $defs/<name>. |
refRoot | the schema | The document that $ref pointers resolve against. |
vendorKeyword | x-golemui | The schema keyword read as an override, or false to ignore it. |
maxRefDepth | 2 | How many times a $ref can repeat on one branch. |
maxNodes | 2000 | The conversion stops after this many nodes. |
transform | none | A function that receives the form definition and returns the final one. |
guiPreset(options) takes:
| Option | Default | Description |
|---|---|---|
submitAction | true | Appends a submit button. |
submitLabel | 'Submit' | The label of the submit button. |
rootLayout | 'vertical' | 'vertical', 'horizontal' or 'grid' for the top-level widgets. |
enumThresholds | { radio: 0, select: 6 } | Up to radio options a radiogroup, up to select a select, more a dropdown. |
objectTitle | 'none' | Shows an object title as a markdownText heading or an alert. |
schemaUrl | https://golemui.com/schemas/form.schema.json | The value written to $schema. |
objectTitle: 'markdownText' needs a markdown dependency. Without one,
the heading renders nothing.
From the command line
Section titled “From the command line”The convert command writes the form definition of a schema file to standard output, or to the
file --out names:
npx @golemui/schemas convert schemas/signup.schema.json \ --preset @golemui/gui-schemas/json-schema --out src/forms/signup.form.json| Flag | Description |
|---|---|
--preset <module> | The preset module of the widget set, e.g. @golemui/gui-schemas/json-schema. |
--out <file> | The file to write. Without it, the definition goes to standard output. |
--force | Replaces an existing --out file. |
--pointer <pointer> | Converts the subschema at this JSON pointer, e.g. /components/schemas/User. |
--config <file> | The config file. Defaults to json-schema.config.mjs or .json in the current directory. |
--fail-on <level> | error exits with 1 on an error diagnostic. warning exits with 1 on an error or a warning. |
A generated form is often edited by hand, so the command does not replace an existing --out file
without --force. --pointer resolves the $refs of the subschema against the whole document, so
it converts one schema of an OpenAPI file.
The settings can also live in a config file. Its paths are relative to the config file, and flags
win over it. A .mjs config can pass the preset itself and function rules:
import { guiPreset } from '@golemui/gui-schemas/json-schema';
export default { input: 'schemas/signup.schema.json', output: 'src/forms/signup.form.json', preset: guiPreset({ submitLabel: 'Sign up' }), rules: [{ match: { format: 'email' }, props: { icon: 'mail' } }], overrides: { newsletter: { widget: 'toggle' } },};A .json config names the preset module and passes its options as presetOptions:
{ "input": "schemas/signup.schema.json", "output": "src/forms/signup.form.json", "preset": "@golemui/gui-schemas/json-schema", "presetOptions": { "submitLabel": "Sign up" }}The other config fields are pointer, vendorKeyword, maxRefDepth, maxNodes and, in a .mjs
config, transform.
Diagnostics
Section titled “Diagnostics”An unsupported schema does not make the conversion throw. Anything the form cannot express exactly
is listed in diagnostics. Each entry has a severity, a code, a message, the form data
path and a JSON pointer into the input:
{ "severity": "warning", "code": "no-discriminator", "message": "No property tells the `anyOf` branches apart (a `const` in every branch, or `discriminator.propertyName`). Every branch property is rendered, optional and always visible.", "path": "contact", "pointer": "/properties/contact"}The severity says how the node is affected. error means the node is not rendered. warning means
it is rendered in an approximate way. info is a note. The CLI writes one line per diagnostic to
standard error.
| Code | Severity | Meaning |
|---|---|---|
root-not-object | error | Only an object schema at the root can become a form. |
invalid-schema | error | A value is not a JSON Schema object or boolean. |
unresolved-ref | error | A $ref does not resolve inside the document, e.g. a reference to another file. |
unsupported-shape | error | No rule can build the node. |
invalid-property-name | error | The property name cannot be part of a form path. |
duplicate-path, duplicate-uid | error | Two widgets have the same path or uid. The later one is removed. |
empty-repeater | error | The array items have no property the form can render. |
max-nodes | error | The schema has more than maxNodes nodes. The rest is not rendered. |
no-discriminator | warning | No property tells the union branches apart. |
branch-schema-conflict | warning | Branches define the same property differently. The first definition is used. |
if-unsupported | warning | The if has no expression equivalent. The branch properties are always shown. |
nested-conditional | warning | A condition inside a condition branch is not applied. |
overlay-limit | warning | A field gets constraints from several conditions, so only the first is applied. Also reported when the constrained field is not built as one input. |
conditional-validator-in-repeater | warning | Conditional constraints inside an array are not applied. |
recursive-ref | warning | A $ref repeats more than maxRefDepth times. The form stops there. |
allof-conflict | warning | The allOf parts disagree on a keyword. The first value is used. |
multi-type | warning | The schema allows several types. The form uses the first one. |
unsupported-keyword | warning | A keyword has no form equivalent, so the form does not check it. |
numeric-property-name | warning | The form writes a numeric property name as an array index. |
invalid-hint | warning | The x-golemui value is not an object, so it is ignored. |
rule-error | warning | A rule or a widget builder threw. The next rule is used. |
empty-object | warning | The object has no property the form can render, so it is left out. |
item-type-mismatch | warning | The tags widget writes strings, and the schema expects other items. |
if-vacuous | info | The if also holds when a tested property is absent. |
custom-widget | info | An override names a widget the preset does not know. A plain input is built. |
local-datetime | info | The date-time widget writes no UTC offset. |
item-constraints-dropped | info | The tags widget does not check the constraints of each item. |
tuple-extra-items | info | The form edits the fixed positions of a tuple only. |
See also
Section titled “See also”- Generating from a schema - the same converter through the MCP server.
- States - the form states that conditions compile into.
- Widgets Reference - the props of each widget, for
propsin overrides and rules. - Form Chunks - split a generated form across several files.