@letar/forms

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-plugin

Configuration

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 entirelyUnhandled 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

DirectiveDescriptionExample
@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}CreateFormSchema gets the check. {Model}UpdateFormSchema is 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 @@validate usage. 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 prop

Zod 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:

  • id fields (primary keys)
  • createdAt, updatedAt (timestamps)
  • Fields with @id attribute
  • Fields with @relation attribute
  • Fields with @omit attribute (hidden from the ORM client entirely)
  • Fields with @computed attribute (computed server-side)
  • Fields with @meta("form.exclude", true)

On this page