Skip to content
Forms, switch product

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.

Terminal window
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.

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.

The preset chooses one widget per schema node:

Schema nodeWidget
enum, or oneOf / anyOf of const valuesselect up to 6 options, dropdown above
const string or numberread-only textinput or number, with the value as default
string with format: date, date-time or timedateInput, dateTimePicker or timeInput
string with format: password, writeOnly: true, or a name with password, secret, apiKey or tokenpassword
string with contentMediaType: text/markdownmarkdown
string with a maxLength of 200 or moretextarea
any other stringtextinput
number or integernumber
booleancheckbox, which starts as false when the property is required
array of objectsrepeater, with maxItems as its limit
array of choicesmultiList up to 6 options, multiDropdown above
array of strings or numberstags, which writes strings, so number items give item-type-mismatch
tuple (prefixItems)a row of widgets, one per position
objectflex column

Every input also takes these fields from its schema:

SchemaWidget field
titlelabel. Without a title, the property name: deliveryDate becomes “Delivery Date”.
descriptionprops.hint
the first entry of examplesprops.placeholder, on text, number, select, dropdown and tags inputs
default, or the value an enclosing object default has for itdefaultValue
readOnly: truereadonly: true

The constraints become a gui validator:

TypeConstraints
stringrequired, const, enum, minLength, maxLength, pattern, and format for email, hostname, ipv4, ipv6, uri, uuid, duration
numberrequired, const, enum, minimum, maximum, exclusiveMinimum, exclusiveMaximum, multipleOf
booleanrequired, const, enum
arrayrequired, 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.

  • 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 OpenAPI components/schemas. Pass the whole OpenAPI document as refRoot when you convert one schema of it. A recursive reference stops after maxRefDepth expansions, 2 by default, with a recursive-ref warning.
  • allOf. The parts are merged. When they disagree on a keyword, the first value wins and the converter reports allof-conflict.
  • Nullable types. type: ["string", "null"] and OpenAPI 3.0 nullable: true are 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.

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:

  1. OpenAPI discriminator.propertyName, when the schema has one.
  2. Otherwise, the first property that has a different const in 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.

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.when condition. 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, dep01 and so on, and the constraints become a validator.<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.

Four layers change what the preset builds. For each node, the first layer that names a widget builds it:

  1. A path override: overrides: { "address.street": { "widget": "textarea" } }. Inside arrays the path uses the items token, for example lines.items.quantity. The root is ''.
  2. A definition override: overrides: { "$defs/Address": { ... } }, for every node that comes from that definition. The key is $defs/<name> also for a definition in definitions or in OpenAPI components/schemas.
  3. The x-golemui keyword in the schema. Rename it with the vendorKeyword option, or ignore it with vendorKeyword: false.
  4. 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.

FieldEffect
widgetThe widget type to build, e.g. "radiogroup" or "toggle".
labelThe widget label.
propsMerged into the widget props, key by key.
validatorReplaces the widget validator. Write the whole validator, with its type.
defaultValueThe default value.
readonlyMakes the widget read-only.
sizeThe relative width, from 1 to 12.
uidThe widget uid.
skiptrue leaves the node out of the form.
orderThe 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 } }
}

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:

KeyMatches
any keywordThe schema keyword, e.g. "format": "email".
$typeThe node type: string, number, integer, boolean, array, object.
$pathThe form data path, as a glob. * is one segment, ** any number.
$nameThe property name.
$defNameThe name of the definition the node came from.
$requiredtrue when the parent object requires the property.
$inRepeatertrue inside the items of an array.
OperatorMatches 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.

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:

MethodReturns
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.

fromJsonSchema(schema, options) takes:

OptionDefaultDescription
presetrequiredThe 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>.
refRootthe schemaThe document that $ref pointers resolve against.
vendorKeywordx-golemuiThe schema keyword read as an override, or false to ignore it.
maxRefDepth2How many times a $ref can repeat on one branch.
maxNodes2000The conversion stops after this many nodes.
transformnoneA function that receives the form definition and returns the final one.

guiPreset(options) takes:

OptionDefaultDescription
submitActiontrueAppends 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.
schemaUrlhttps://golemui.com/schemas/form.schema.jsonThe value written to $schema.

objectTitle: 'markdownText' needs a markdown dependency. Without one, the heading renders nothing.

The convert command writes the form definition of a schema file to standard output, or to the file --out names:

Terminal window
npx @golemui/schemas convert schemas/signup.schema.json \
--preset @golemui/gui-schemas/json-schema --out src/forms/signup.form.json
FlagDescription
--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.
--forceReplaces 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:

json-schema.config.mjs
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.

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.

CodeSeverityMeaning
root-not-objecterrorOnly an object schema at the root can become a form.
invalid-schemaerrorA value is not a JSON Schema object or boolean.
unresolved-referrorA $ref does not resolve inside the document, e.g. a reference to another file.
unsupported-shapeerrorNo rule can build the node.
invalid-property-nameerrorThe property name cannot be part of a form path.
duplicate-path, duplicate-uiderrorTwo widgets have the same path or uid. The later one is removed.
empty-repeatererrorThe array items have no property the form can render.
max-nodeserrorThe schema has more than maxNodes nodes. The rest is not rendered.
no-discriminatorwarningNo property tells the union branches apart.
branch-schema-conflictwarningBranches define the same property differently. The first definition is used.
if-unsupportedwarningThe if has no expression equivalent. The branch properties are always shown.
nested-conditionalwarningA condition inside a condition branch is not applied.
overlay-limitwarningA 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-repeaterwarningConditional constraints inside an array are not applied.
recursive-refwarningA $ref repeats more than maxRefDepth times. The form stops there.
allof-conflictwarningThe allOf parts disagree on a keyword. The first value is used.
multi-typewarningThe schema allows several types. The form uses the first one.
unsupported-keywordwarningA keyword has no form equivalent, so the form does not check it.
numeric-property-namewarningThe form writes a numeric property name as an array index.
invalid-hintwarningThe x-golemui value is not an object, so it is ignored.
rule-errorwarningA rule or a widget builder threw. The next rule is used.
empty-objectwarningThe object has no property the form can render, so it is left out.
item-type-mismatchwarningThe tags widget writes strings, and the schema expects other items.
if-vacuousinfoThe if also holds when a tested property is absent.
custom-widgetinfoAn override names a widget the preset does not know. A plain input is built.
local-datetimeinfoThe date-time widget writes no UTC offset.
item-constraints-droppedinfoThe tags widget does not check the constraints of each item.
tuple-extra-itemsinfoThe form edits the fixed positions of a tuple only.