Custom Fields
Use defineFieldKind for the normal extension path. It lets you define parsing, validation, serialization, and small renderer hints without writing describe() or a custom primitive renderer.
import { z } from "zod";import { mountForm } from "mlform/kit";import { defineFieldKind, defineMLFormPlugin } from "mlform/view";import { baseFieldConfigSchema } from "mlform/schema";
const scoreField = defineFieldKind({ kind: "score", schema: baseFieldConfigSchema.extend({ kind: z.literal("score"), min: z.number().default(0), max: z.number().default(100), step: z.number().optional(), }), value: { default: () => 0, normalize: (value) => Number(value ?? 0), serialize: (value) => value, }, validateSync: ({ value, config }) => value < config.min || value > config.max ? ["Score is outside the allowed range."] : [], render: { widget: "number", hints: ({ config }) => ({ min: config.min, max: config.max, step: config.step ?? 1, unit: "%", }), },});
const domainPlugin = defineMLFormPlugin({ fields: [scoreField] });mountForm(container, { schema, transport, plugins: [domainPlugin] });baseFieldConfigSchema is the single source of truth for shared field properties such as id,
label, conditions, displayKey, mappedTo, and ui. Extend it with properties owned by the new
kind instead of repeating the base contract.
For nested configuration that needs the same backend-target syntax, import mappedToSchema from
mlform/schema. It is optional for top-level configs; use mappedToSchema.unwrap() when the nested
property must be present.
| Hook | Purpose |
|---|---|
schema |
Zod schema for field config. |
value.default |
Initial value when no default is provided. |
value.normalize |
Convert UI or host values into runtime values. |
value.serialize |
Convert runtime values into backend payload values. |
validate |
Return field-level error messages. May be async. |
definition |
Add advanced runtime, mapping, or submission behavior. |
render.widget |
Pick a built-in renderer shape like text, number, or select. |
render.hints |
Pass small UI hints to the built-in declarative renderer. |
value.normalize must be idempotent: normalizing an already normalized value must produce an
equal value. The runtime may normalize values more than once while it stabilizes conditional field
state. It fails early if a definition keeps changing its value during that process.
The definition option exposes the advanced definition hooks without leaving the normal kit
extension path: validateConfig, getNestedFieldReferences, getFieldReferences, validateRuntime, onValueChanged,
getMappedTargets, and getSubmissionEntries. The field kind, schema, value normalization, and
declarative validators remain owned by defineFieldKind and cannot be overridden there. A field
that implements getSubmissionEntries must declare every possible emitted target through
getMappedTargets; emitting an undeclared target fails submission.
Return undefined from getSubmissionEntries to use ordinary field serialization and mapping
for configurations that do not need custom submission entries. Return an empty array to emit
no model values.
Use getFieldReferences when a field’s configuration names other fields. Return each referenced
id and its path within the field config; schema normalization rejects missing targets before a
runtime is created.
Use defineFieldDefinition when you need full control over the definition itself, and pair it with
an explicit presenter or primitive component.