ZenStack Plugin
Generate Zod schemas with UI metadata from your database models
Overview
@letar/zenstack-form-plugin generates type-safe Zod v4 schemas with .meta({ ui: {...} }) directly from your schema.zmodel — no manual schema writing needed.
npm install @letar/zenstack-form-pluginConfiguration
Add the plugin to your schema.zmodel:
plugin formSchema {
provider = '@letar/zenstack-form-plugin'
output = './src/generated/form-schemas'
}Then run:
npx zenstack generate@meta("form.*", value) Directives
Use the @meta field attribute directly on a field to configure form behavior:
model Product {
id String @id @default(cuid())
name String @meta("form.title", "Product Name") @meta("form.placeholder", "Enter product name")
price Decimal @meta("form.title", "Price") @meta("form.fieldType", "currency")
@meta("form.props.min", 0)
categoryId String @meta("form.title", "Category") @meta("form.relation.labelField", "name")
createdAt DateTime @default(now()) @meta("form.exclude", true)
}⛔ An object literal in @meta breaks zenstack generate entirely — Unhandled error: Unsupported attribute arg value: ObjectExpr. This is a limitation of ZenStack's own TS-schema
generator, not of this plugin. That's why form.props and form.relation (which used to take an
object) are expressed as a flat dot-path, one @meta call per key, as shown above — never
@meta("form.props", { min: 0 }). Scalars and arrays work fine in @meta; only bare object
literals are rejected.
Available Directives
| Directive | Description | Example |
|---|---|---|
@meta("form.title", "...") | Field label | @meta("form.title", "Name") |
@meta("form.placeholder", "...") | Placeholder text | @meta("form.placeholder", "Enter...") |
@meta("form.description", "...") | Help text | @meta("form.description", "Hint") |
@meta("form.fieldType", "...") | Component type | @meta("form.fieldType", "tags") |
@meta("form.props.<dotpath>", ...) | Constraints + UI props | @meta("form.props.min", 1) |
@meta("form.relation.<dotpath>", ...) | Relation select config | @meta("form.relation.labelField", "name") |
@meta("form.exclude", true) | Exclude from form | @meta("form.exclude", true) |
Generated Output
For the Product model above, the plugin generates:
// src/generated/form-schemas/Product.form.ts
import { z } from 'zod/v4'
export const ProductCreateFormSchema = z.object({
name: z.string().meta({
ui: { title: 'Product Name', placeholder: 'Enter product name' },
}),
price: z
.number()
.min(0)
.meta({
ui: { title: 'Price', fieldType: 'currency' },
}),
categoryId: z.string().meta({
ui: { title: 'Category', fieldType: 'relation', fieldProps: { labelField: 'name' } },
}),
})
export const ProductUpdateFormSchema = ProductCreateFormSchema.partial()
export type ProductCreateForm = z.infer<typeof ProductCreateFormSchema>Enum Labels
Doc comments on enum values become select options:
enum Status {
/// Active
ACTIVE
/// Archived
ARCHIVED
/// Draft
DRAFT
}Generates:
export const StatusFormSchema = z.enum(['ACTIVE', 'ARCHIVED', 'DRAFT']).meta({
ui: {
options: [
{ value: 'ACTIVE', label: 'Active' },
{ value: 'ARCHIVED', label: 'Archived' },
{ value: 'DRAFT', label: 'Draft' },
],
},
})Inheriting native ZModel validation attributes
Recommended path. Set validation through native ZModel attributes instead of form.props —
the ORM already applies them on create/update via @zenstackhq/zod, and the form plugin
inherits the same constraints into the client Zod schema, keeping a single source of truth:
portions Int @gte(1) @lte(100) @meta("form.title", "Portions")
slug String @startsWith("recipe-") @trim() @lower() @meta("form.title", "Slug")
email String @email @meta("form.title", "Email")Generates:
portions: z.number().int().min(1).max(100).meta({ ui: { title: 'Portions' } })
slug: withNative(z.string(), (s) => ZodUtils.addStringValidation(s, [...])).meta({ ui: { title: 'Slug' } })
email: z.string().email().meta({ ui: { title: 'Email' } })Supported: @email, @length, @gte/@gt/@lte/@lt, @regex, @startsWith, @endsWith,
@contains, @datetime, @date, @time, @url, @phone, @trim, @lower, @upper.
@length on a list (String[]) validates the element count, not string length. Decimal
fields only support @gte/@gt/@lte/@lt (ZodUtils.addDecimalValidation is incompatible
with the form's Decimal → z.number() contract).
form.props with the same constraint key (min, startsWith, etc., set via
@meta("form.props.min", ...)) wins over the native attribute — a deliberate escape hatch for
intentional client/server divergence, not the default way to set a constraint.
Cross-field validation with @@validate
Checks that depend on more than one field are declared on the model, not on a field:
model Booking {
id String @id @default(cuid())
title String
startsAt DateTime
endsAt DateTime
@@validate(endsAt > startsAt, "End date is before the start date", ["endsAt"])
}Generates:
export const BookingCreateFormSchema = withNative(
BookingBaseSchema,
(s) => ZodUtils.addCustomValidation(s, [{ name: '@@validate', args: [...] }]),
)Signature is @@validate(condition: Boolean, message: String?, path: String[]?), same as the
ZModel standard library. condition is an arbitrary boolean expression over the model's fields
(comparisons, &&/||, function calls like length/startsWith). message/path behave like
Zod's .refine() — path attaches the error to a specific field instead of the form-level error.
Limitations:
- Only
{Model}CreateFormSchemagets the check.{Model}UpdateFormSchemais built from an internal, non-exported{Model}BaseSchema(before.refine()) via.partial()—ZodEffects(what.refine()returns) has no.partial()method, and a partial payload often can't satisfy a check written for the full model anyway. Add a form-level check separately if the edit form needs the same validation. MemberAccessExpr(relation traversal,related.field) is not supported — it hasn't come up in this plugin's@@validateusage. Attempting to serialize one throws a clear codegen error rather than silently producing wrong runtime behavior.
@@strict() — implemented, unavailable on model
The plugin has codegen support for @@strict() (z.strictObject(...) instead of
z.object(...)), but it cannot be turned on for an actual model: the ZModel standard library only
allows @@strict() on type definitions (zenstack generate stops with "attribute '@@strict'
can only be used on type definitions" if you put it on a model). This is a ZModel language
limitation discovered by a live generate run, not a risk in the plugin's own code — the codegen
path is unit-tested and stays in place for when ZenStack extends where the attribute applies.
Smart Props Splitting
form.props values are automatically split into Zod constraints and UI props:
portions Int @meta("form.props.min", 1) @meta("form.props.max", 100) @meta("form.props.showValue", true)Generates:
portions: z.number()
.int()
.min(1) // ← Zod constraint
.max(100) // ← Zod constraint
.meta({ ui: { fieldProps: { showValue: true } } }) // ← UI propZod constraints: min, max, step, minLength, maxLength, pattern, email, url, uuid
UI props: everything else (count, allowHalf, showValue, layout, etc.)
Using with @letar/forms
Generated schemas work directly with Form.FromSchema:
import { Form } from '@letar/forms'
import { ProductCreateFormSchema } from '@/generated/form-schemas/Product.form'
function CreateProductForm() {
return (
<Form.FromSchema
schema={ProductCreateFormSchema}
onSubmit={async (data) => createProduct(data)}
submitLabel="Create Product"
/>
)
}Or with manual field layout:
<Form schema={ProductCreateFormSchema} onSubmit={handleSubmit}>
<Form.Field.String name="name" />
<Form.Field.Currency name="price" />
<Form.Field.Select name="categoryId" options={categories} />
<Form.Button.Submit>Save</Form.Button.Submit>
</Form>i18n Support
Enable multi-language form labels:
plugin formSchema {
provider = '@letar/zenstack-form-plugin'
output = './src/generated/form-schemas'
i18n = true
i18nOutput = './messages/form-schemas'
defaultLocale = 'ru'
locales = 'ru,en'
}Generates translation files:
// messages/form-schemas/ru.json
{
"Product": {
"name": { "title": "Название товара", "placeholder": "Введите название" }
}
}Default locale is overwritten on each generation. Other locales use merge strategy — preserving your translations.
Custom Validation Translations
English and Russian validation messages are built in. For other languages, create a translations file:
// i18n/form-validations.js
export default {
de: {
required: 'Pflichtfeld',
too_small: {
string: 'Mindestens {minimum} Zeichen',
number: 'Mindestens {minimum}',
},
too_big: {
string: 'Maximal {maximum} Zeichen',
number: 'Maximal {maximum}',
},
invalid_format: {
email: 'Ungültige E-Mail-Adresse',
url: 'Ungültige URL',
},
},
}Reference it in your schema:
plugin formSchema {
provider = '@letar/zenstack-form-plugin'
output = './src/generated/form-schemas'
i18n = true
defaultLocale = 'en'
locales = 'en,de'
validationTranslationsPath = './i18n/form-validations.js'
}Resolution order: custom file → built-in (en, ru) → English fallback.
See the ValidationTranslations type export for the full interface.
Auto-Excluded Fields
These fields are automatically excluded from generated schemas:
idfields (primary keys)createdAt,updatedAt(timestamps)- Fields with
@idattribute - Fields with
@relationattribute - Fields with
@omitattribute (hidden from the ORM client entirely) - Fields with
@computedattribute (computed server-side) - Fields with
@meta("form.exclude", true)
Links
- npm:
@letar/zenstack-form-plugin - GitHub: kamiletar/letar/tree/main/libs/zenstack-form-plugin
- @letar/forms: forms.letar.best