Custom field types
The built-in types cover the native form controls. When your forms need a value of another shape, like a list of tags or an amount of money, register a field type of your own. Below you will learn how.
TIP
Many specific fields don't need a type of their own. A star rating is a number with "ui": { "widget": "rating" }, and your renderer picks the component by the widget. Register a type when the value or its checks differ.
Defining a type
defineFieldType() defines a type, and createSchemaBuilder() registers it under a name. Your types merge over the built-in ones by name, so a type named text replaces the built-in one:
const tags = defineFieldType({
kind: 'array',
itemKind: 'string',
props: v.object({ max: v.optional(v.number()) }),
schema: (field) =>
v.pipe(v.array(v.string()), v.maxLength(field.max ?? 10)), // field.max is typed
meta: { label: 'Tags', description: 'A list of short texts' },
})
const builder = createSchemaBuilder({ fieldTypes: { tags } })A definition can now use it, with its props next to the field's other keys:
{ "name": "labels", "type": "tags", "label": "Labels", "max": 5 }A type takes:
kind: the kind of its value, which decides the rules that fit it and what counts as empty. The built-in kinds arestring,number,boolean,date,array,file,objectandnone, for a type without a value, like a heading.props: a Valibot object schema of the extra keys the type takes. Unknown keys are issues.schema()andkindget the field with its props parsed and typed.schema(field, ctx): the Valibot schema of the value, before the field's rules. Every type with a value needs one.itemKind: for a type of kindarray, the kind of each item. A rule for that kind, but not for arrays, then runs on each item and reports on the field.defaultRules: rules every field of the type gets, like theemailrule of theemailtype. A rule of the field with the same name replaces one.required(message): replaces the kind's required check. Returnnullfor none, like a yes or no radio that acceptsfalse.finalize(field): an action that runs after every rule, likev.toDate()for a date that outputs aDate.meta: alabeland adescriptionfor people, which the JSON Schema and a type picker use.
The FieldTypeDef reference has the details.
A kind from the props
kind can be a function of the field, for a type whose value depends on its props, like a select whose options are numbers:
const choice = defineFieldType({
// A choice with number values holds a number, so number rules fit it.
kind: (field) =>
typeof field.values[0] === 'number' ? 'number' : 'string',
props: v.object({ values: v.array(v.union([v.string(), v.number()])) }),
schema: (field) => v.picklist(field.values),
})Types that hold fields
A type whose value holds other fields sets container: true, and builds them with ctx.buildFields(fields, path), or ctx.buildField(field, path) for the items of a list. Its kind is object or array:
const fieldset = defineFieldType({
kind: 'object',
container: true,
props: v.object({
legend: v.optional(v.string()),
fields: v.array(v.looseObject({ type: v.string() })),
}),
// The builder checks the child fields like any others, so their issues have their full path.
schema: (field, ctx) =>
ctx.buildFields(field.fields, [...ctx.path, 'fields']),
})- The builder checks the child fields like any others, so their issues have their full path, and form rules, layouts and conditions reach them by their dot paths.
- A container's value is never empty: a missing one becomes
{}or[], and its rules always run. - Renderers get it with the role
groupfor kindobject, orarrayfor kindarray, so it renders like a group or a list. - The builder's
getInitialInput()andwalkFields()read your own containers like the built-ingroupandarray.
Your own kinds
A kind says what counts as empty with isEmpty, and can add a required check, like the string kind's v.nonEmpty(). Register kinds of your own with the kinds option, by name, next to the built-in ones. See KindDef.
Testing a type
To test a type on its own, give its schema() a context from createBuildContext(). Its buildFields and buildField use the default registries, and throw a DefinitionError when the child fields have issues:
const field: JsonField = { name: 'tags', type: 'tags', max: 3 }
const schema = tags.schema?.(
{ ...field, max: 3 },
createBuildContext({ path: ['fields', 0] }),
)Mistakes
Mistakes in the registry throw a TypeError from createSchemaBuilder(), like an unknown kind, a container of kind string, a type with a value but no schema(), or props that use a key every field already has, like label. The reserved keys lists them.