Skip to content

Schema ​

Describe an entity once as a FieldDefinition[]. The form, the Zod schema and the table columns are all derived from it, so they cannot drift apart.

Open in demo
Open in demo

Submit it empty and every required field reports itself. Put 99 in Seats and the constraint speaks with the field's own message.

The field list ​

ts
import type { FieldDefinition } from '@core/schema/types'

export const productFormSchema: FieldDefinition[] = [
  { key: 'name', type: 'text', label: 'products.fields.name', required: true, grid: 6 },
  {
    key: 'sku',
    type: 'text',
    label: 'products.fields.sku',
    required: true,
    grid: 6,
    validations: { pattern: '^[A-Z]{3}-\d{4}$', message: 'products.errors.sku' },
  },
  {
    key: 'price',
    type: 'number',
    label: 'products.fields.price',
    required: true,
    validations: { min: 0, max: 1000000 },
  },
  {
    key: 'category',
    type: 'select',
    label: 'products.fields.category',
    options: [
      { label: 'Audio', value: 'audio' },
      { label: 'Computing', value: 'computing' },
    ],
  },
  { key: 'createdAt', type: 'date', label: 'products.fields.createdAt', hideInForm: true },
]

label is a translation key. uiText resolves it where it renders and returns an unknown key verbatim, so a plain English label works too.

Field types ​

Fourteen, each mapped to a control and a Zod rule.

TypeControlNotes
textInput
emailInputValidated as an email address.
passwordInputMasked, with a show and hide toggle. Never a table column.
numberInputNumeric keyboard, digits only on key and paste.
textareaTextareaAlways full width.
selectSelectNeeds options.
comboboxSaComboboxType to filter a long list. Needs options.
multiselectSaMultiSelectSeveral values. min and max count selections.
dateCalendar in a popoverA YYYY-MM-DD string.
datetimeSaDateTimePickerDate and time.
checkboxCheckbox
switchSwitch
fileFileUploadConfigured by upload. Never a table column.
editorSaEditorRich text. Loaded on demand, so Tiptap stays out of the first chunk. Never a table column.

Every key ​

KeyTypeEffect
keystringThe property on the model.
typeFieldTypeOne of the fourteen above.
labelstringTranslation key or literal.
placeholderstringTranslation key or literal.
descriptionstringHelper text under the control.
requiredbooleanAlso rejects an empty string, and an empty list on a file field.
disabledboolean
defaultValueunknownThe form's initial value. Not a Zod default, so a missing key stays distinguishable from a default on a PATCH.
optionsFieldOption[]{ label, value, disabled? }, for select, combobox and multiselect.
uploadobjectfile only: endpoint, maxSize, acceptedTypes, multiple, maxFiles.
validationsFieldValidationsSee below.
visibleIfobject or function{ field, value } compares, or (data) => boolean decides.
grid1–12Column span on the twelve-column form grid.
groupstringSection or tab name. Drives the grouped, wizard and tabs layouts.
sortablebooleanTable column flag.
filterablebooleanTable column flag.
searchablebooleanTable column flag.
hideInTablebooleanKeep it out of the columns.
hideInFormbooleanKeep it out of the form, and out of validation.
renderfunctionCustom cell output.

Validations ​

KeyApplies toEffect
min / maxnumbers, dates, multiselectBounds, or the number of selections.
minLength / maxLengthtextLength bounds.
patterntextA regular expression source string, not a RegExp, so a schema stays serializable.
messageallReplaces the message for this field's constraints.

message covers constraints only. An empty required field and a wrong type keep the engine's own wording, whatever the field says about its bounds — so "The email field is required" stays consistent across the app while "Pick between 1 and 50 seats" is yours.

The default messages live under validation.* in the locale files and carry the field's label:

json
{
  "required": "The {field} field is required",
  "min": "The {field} must be {min} or more",
  "minLength": "The {field} must be at least {min} characters"
}

Placeholders ​

A field with no placeholder of its own gets one built from its label, from src/core/schema/fieldPlaceholders.ts:

ControlEnglish placeholder
text, email, password, number, textarea"Enter {label}"
select, combobox, multiselect, date"Select {label}"
The search box of a combobox, a multiselect, or a select with six options or more"Search {label}…"

The wording lives under field.* in the locale files, so every language has its own.

In English the label sits inside a sentence, so it is written in lower case: a field labeled "Full name" reads "Enter full name", a "Plan" select reads "Select plan", and a searchable one shows "Search plan…" in its search box. The validation messages follow the same rule: "The full name field is required". A word with more than one capital keeps them, so SKU and "API key" stay as they are. Other languages keep the label exactly as written, because German, for one, capitalizes every noun.

A field's own placeholder always wins over the generated one.

What derives from it ​

useSchema takes the field list and returns five things:

ts
import { useSchema } from '@core/schema/useSchema'

const { fieldList, formFields, tableColumns, zodSchema, fieldGroups } = useSchema(() => schema)
fieldListThe list as given.
formFieldsEverything without hideInForm.
tableColumnsEverything without hideInTable.
zodSchemaA Zod object built from formFields only.
fieldGroupsformFields bucketed by group, in first-seen order.

Validating only formFields is deliberate: a field hidden from the form cannot block a submit. tableColumns keeps hideInForm fields, because a column such as createdAt needs no input.

It takes a ref, a getter or a plain array, and everything it returns is computed, so a schema that changes with the language regenerates its messages without a reload.

To table columns ​

ts
import { schemaToColumns } from '@core/schema/schemaToColumns'

const columns = computed(() => schemaToColumns(productFormSchema))

include keeps named keys in a given order, exclude drops more on top of hideInTable, and locale overrides the tag used to format date cells. editor, file and password are never columns unless include asks for them. Dates are formatted at render time, not at call time, so they follow the language switcher.

To a form ​

vue
<script setup lang="ts">
import SchemaForm from '@core/schema/SchemaForm.vue'
import { productFormSchema } from '../types/product.schemas'
</script>

<template>
  <SchemaForm :schema="productFormSchema" @submit="save" />
</template>

The form, its layouts and the server-error path are their own page: Forms.

Conditional fields ​

ts
{ key: 'vatNumber', type: 'text', label: 'Tax number', visibleIf: { field: 'business', value: true } }
{ key: 'shipping', type: 'text', label: 'Shipping note', visibleIf: data => data.country !== 'local' }

A hidden field is out of the render and out of validation while it is hidden, so an invisible rule cannot block a submit.

Where to go next ​

  • Forms — SchemaForm, its layouts, drafts and server errors.
  • DataTable — what the generated columns feed.
  • Add a module — the CLI writes this file for you.