Skip to content

Schema

A FormSchema is the contract every layer reads: the runtime validates and submits from it, the view renders from it, and your backend agrees with it through mappedTo. It has exactly two properties; any other top-level property is rejected.

import type { FormSchema } from "mlform/schema";
const schema: FormSchema = {
fields: [
{
id: "temperature",
kind: "number",
label: "Temperature",
unit: "°C",
min: -40,
max: 60,
required: true,
mappedTo: "temperature",
},
{
id: "location",
kind: "category",
label: "Sensor location",
options: ["Indoor", "Outdoor"],
mappedTo: "location",
},
],
reports: [
{ id: "forecast", kind: "regressor", label: "Next-hour estimate", unit: "°C", mappedTo: "forecast" },
],
};

fields lists the inputs in order. reports is optional and lists the model outputs to show. Arrangement on screen is not part of the schema; it lives in layout.

Before a form exists, MLForm normalizes the schema against its registry. Normalization:

  • looks up each kind in the registry and parses the config with that kind’s Zod schema, applying defaults such as required: false;
  • resolves ids, rejects duplicates, and checks that every reference to another field exists, in conditions, mapped-category mappings, and custom definitions;
  • rejects incoherent constraints such as min greater than max on number, rating, and date fields, invalid text length bounds, an invalid text pattern, or minPoints greater than maxPoints;
  • rejects ambiguous outputs: duplicate displayKey values and overlapping submission paths.

Failures throw SchemaNormalizationError (with a path into the schema), or RegistryError for an unknown kind, from mountForm, createFormView, or createForm. Nothing renders from an invalid schema.

Explicit field and report ids are preserved exactly: customer_age, CustomerAge, and customer-age are different ids. Use the same string in lookups, initial values, conditions, mapped-category targets, and layout field/report references.

  • Explicit ids must be non-empty and contain no whitespace. The object keys __proto__, constructor, and prototype are reserved.
  • A field without id gets a lowercase kebab-case id from its label; a report without id gets one from its label, then its kind. Generated ids avoid explicit ids and receive a numeric suffix when needed (temperature-2), regardless of declaration order.
  • Duplicate explicit ids are an error. Field ids and report ids are separate namespaces.
  • Lookups and references use exact ids; there are no label or slug aliases.

Set explicit ids for anything your code, layout, or tests reference. Generated ids change when someone edits a label.

Three identities on one fieldA field config with id customer-age, displayKey Customer age and mappedTo age_years. The id addresses runtime state and layout, the display key names the value in displayValues, and the mapped target names it in modelValues. The label is user-facing copy and is never used as a key.FIELD CONFIG{ id: "customer-age", displayKey: "Customer age", mappedTo: "age_years", label: "Age", kind: "number",}Runtime idform.getField("customer-age")layout refs, errors, focus, state, testsDisplay keydisplayValues["Customer age"] = 42review, persistence, export; omitted if unsetMapped targetmodelValues.age_years = 42what the transport sends to the modellabel is copy for people. It can change without touching any contract.
MLForm never derives one identity from another. A field without mappedTo or valuePath sends no model value.

id, displayKey, and mappedTo are independent. MLForm never infers one from another, and it never matches backend results by id or label. Mental model explains the reasoning; Backend contract shows the request built from them.

Lookups follow the same split:

You have Use
A runtime id form.getField(id), form.getReport(id), layout nodes
A display key form.getFieldByDisplayKey(key)
A backend target form.getFieldByMappedTo(target, { backend })

Every field kind accepts these options in addition to its own. Field kinds lists the kind-specific ones.

Option Type Default Purpose
id string from label Exact runtime id; see the rules above.
kind string required Registered field kind.
label string required Visible label and accessible name. Copy, not a key.
description string none Help text behind the field’s help button.
showDescriptionInline boolean false Show description without opening the help button.
required boolean false Requires a value before submission.
defaultValue depends on kind kind default Initial value owned by the schema.
hidden, disabled, readOnly boolean false Static states; see Conditions.
hiddenWhen, disabledWhen, readOnlyWhen condition none Dynamic states from other values.
inactiveFieldPolicy "omit", "include", "reset-on-hide" form policy Whether hidden or disabled values are submitted.
includeInSubmission boolean true false keeps the field out of every submission record.
displayKey string none Key in displayValues. Must be unique.
mappedTo target or per-backend map none Key in modelValues. See below.
valuePath string or string[] none Nested modelValues path, used when there is no mappedTo.
asyncValidationDebounceMs number none Debounce for asynchronous validation of this field.
ui Record<string, unknown> none Renderer hints passed to the presenter, such as { input: "range" }.

mappedTo accepts three forms:

{ mappedTo: "age_years" } // a key in modelValues
{ mappedTo: "station.temperature" } // dots write a nested object
{ mappedTo: { forecast: "temp_c", alerts: "t" } } // one target per named backend

A non-negative integer is also accepted (mappedTo: 0); it is written as the key "0" of modelValues, so a positional model still needs the transport to build its array. In a per-backend map, a backend whose entry is missing or null uses the default entry; without a default entry the field sends nothing to that backend. Path segments __proto__, prototype, and constructor are rejected. Paths must not overlap: profile and profile.name in one schema is an error, even when they belong to different backends, because a submission without a backend includes every target.

Option Type Purpose
id string Exact runtime id, with the same rules as field ids. Defaults to label, then kind.
kind string Registered report kind.
label string Report heading.
description string Help text for the report.
mappedTo target or per-backend map Selects the backend output this report shows.
ui Record<string, unknown> Renderer hints; { showDescriptionInline: true } expands the description.

A schema is plain data as long as it uses declarative conditions; function conditions cannot be serialized. Validate data from outside your code with the same registry the form will use:

import { createBuiltinMlRegistry } from "mlform/builtins";
import { findUnknownKinds, toSchemaJsonSchema, validateSchema } from "mlform/schema";
const registry = createBuiltinMlRegistry();
const input: unknown = await (await fetch("/api/forecast/schema")).json();
const result = validateSchema(input, registry);
if (result.success) {
console.log(result.data.fields.map((field) => field.id)); // the normalized schema
} else {
console.error(result.issues); // [{ path: ["fields", 2, "max"], message: "…", code: "invalid-config" }]
}
const unknownKinds = findUnknownKinds(input, registry); // [{ section, index, kind, path }]
const jsonSchema = toSchemaJsonSchema(registry); // for editors and API documentation

validateSchema does not throw; its issues use the codes invalid-schema, invalid-config, and unknown-kind. toSchemaJsonSchema covers every kind in the registry. To include kinds added by plugins, pass the registry of a view created with those plugins (view.engineRegistry).

  • Set explicit ids and keep them stable. Saved drafts, layouts, and tests depend on them.
  • Write labels for people. Change them freely; nothing should depend on them.
  • Map every model input explicitly with mappedTo. When a model renames a feature, change mappedTo, not the id.
  • Use required for what the user must answer, not to work around backend defaults.
  • Put business defaults in defaultValue and session data in initialValues; see Initial values and drafts.
  • Version schemas at your API boundary when a model’s required inputs change.