---
title: 'Validation'
description: 'Write attribute validation with Zod, see what flexkit deploy compiles it to, and know where the rules are enforced today.'
---

import { Callout } from 'nextra/components';

# Validation

Attribute validation is written with Zod in `flexkit.config.ts`, compiled to JSON Schema by `flexkit deploy`, and enforced by the Studio form. You author the rule; the Studio generates the message.

## Authoring

`validation` receives the Zod namespace and returns a schema:

```ts
import { defineEntity } from '@flexkit/schema';

export const products = defineEntity({
  name: 'product',
  plural: 'products',
  attributes: [
    {
      name: 'name',
      label: 'Name',
      scope: 'local',
      dataType: 'string',
      inputType: 'text',
      validation: (z) => z.string().min(1),
    },
    {
      name: 'code',
      label: 'Code',
      scope: 'global',
      dataType: 'string',
      inputType: 'text',
      validation: (z) => z.string().min(3).max(12).regex(/^[A-Z0-9-]+$/),
    },
    {
      name: 'contactEmail',
      label: 'Email',
      scope: 'global',
      dataType: 'string',
      inputType: 'text',
      validation: (z) => z.email(),
    },
    {
      name: 'rating',
      label: 'Rating',
      scope: 'global',
      dataType: 'int',
      inputType: 'number',
      validation: (z) => z.number().int().min(1).max(5),
    },
    {
      name: 'status',
      label: 'Status',
      scope: 'global',
      dataType: 'string',
      inputType: 'select',
      options: {
        list: [
          { label: 'Draft', value: 'draft' },
          { label: 'Published', value: 'published' },
        ],
      },
      validation: (z) => z.enum(['draft', 'published']),
    },
  ],
});
```

Do not pass messages: `z.string().min(1, { error: 'Name is required' })` deploys, but the string is ignored. See [Generated messages](#generated-messages).

## What deploy does

`flexkit deploy` calls each `validation` function, converts the resulting Zod schema to a JSON Schema (draft-07) fragment and sends that fragment with the attribute. The config file itself never reaches the server.

Before anything is sent, the CLI checks that every fragment only uses rules the Studio form can enforce. A rule it cannot express fails the deploy locally with an error that names the entity and attribute, for example:

```text
Attribute "price" of entity "product": validation uses refine(), superRefine() or check(), which cannot be expressed as JSON Schema. Use built-in Zod checks such as min(), max(), length(), regex() or email().
```

The backend compiles every fragment again with Ajv and rejects a broken one with a `422` at `schema[i].attributes[j].validation`, so a hand-edited or outdated payload cannot deploy a rule the form would then ignore.

## Supported rules

| Zod                                                                     | JSON Schema                                                                 |
| ----------------------------------------------------------------------- | --------------------------------------------------------------------------- |
| `z.string()` with `.min()`, `.max()`, `.length()`, `.regex()`           | `type: 'string'`, `minLength`, `maxLength`, `pattern`                       |
| `z.email()`, `z.url()`, `z.uuid()`                                      | `format: 'email' \| 'uri' \| 'uuid'`                                        |
| `z.number()` with `.min()`, `.max()`, `.gt()`, `.lt()`, `.multipleOf()` | `type: 'number'`, `minimum`, `maximum`, `exclusiveMinimum`, `exclusiveMaximum`, `multipleOf` |
| `z.number().int()`                                                      | `type: 'integer'`                                                           |
| `z.enum([...])`, `z.literal(...)`                                       | `enum`, `const`                                                             |
| `z.object({...})`                                                       | `type: 'object'`, `properties`, `required` (unknown keys are stripped)      |
| `z.array(...)` with `.min()`, `.max()`                                  | `type: 'array'`, `items`, `minItems`, `maxItems`                            |
| `.optional()`, `.nullable()`                                            | omitted from `required` / `anyOf` with `type: 'null'`                       |
| `.describe()`                                                           | `description`, `title`                                                      |

The complete keyword allowlist is `type`, `minLength`, `maxLength`, `pattern`, `format`, `minimum`, `maximum`, `exclusiveMinimum`, `exclusiveMaximum`, `multipleOf`, `enum`, `const`, `properties`, `required`, `additionalProperties`, `items`, `minItems`, `maxItems`, `anyOf`, `oneOf`, `not`, `description` and `title`. Supported formats are `email`, `uri`, `uuid`, `date-time`, `date` and `time`.

## Unsupported rules

These fail the deploy before anything is sent:

| Rule                                                | Why                                                                                   |
| --------------------------------------------------- | ------------------------------------------------------------------------------------- |
| `.refine()`, `.superRefine()`, `.check()`           | Arbitrary code has no JSON Schema equivalent.                                         |
| `.transform()`, `z.custom()`                        | Same: there is no function to run in the form.                                        |
| `z.date()`                                          | Dates are stored as strings. Use `z.iso.datetime()` or `z.iso.date()`.                |
| `z.ipv4()`, `z.cuid()` and other string formats     | Only `email`, `uri`, `uuid`, `date-time`, `date` and `time` are enforced by the form. |

Rules the form cannot enforce belong in an [automation](/docs/automations) that checks writes, or in a [skill](/docs/ai/skills) that teaches agents the constraint.

## Generated messages



The Studio does not use authored messages. It builds one message per field from the attribute `label` and the failing rule, and shows the first failure:

| Rule                                 | Example message                      |
| ------------------------------------ | ------------------------------------ |
| `minLength` of 1 on an empty value   | Name is required                     |
| `minLength` above 1                  | Code must be at least 3 characters   |
| `maxLength`                          | Code must be at most 12 characters   |
| `pattern`                            | Code has an invalid format           |
| `format: 'email'`                    | Email must be a valid email address  |
| `minimum` and `maximum` together     | Rating must be between 1 and 5       |
| `minimum` alone                      | Price must be at least 0             |
| `type: 'integer'`                    | Position must be a whole number      |
| `multipleOf`                         | Quantity must be a multiple of 6     |
| `minItems`                           | Tags needs at least 2 items          |
| `enum` / `const`                     | Status is invalid                    |

An empty value on a field whose rule rejects it always shows "`<Label>` is required". Choose labels that read well in a sentence.

<Callout type="warning">
  **Zod 4 ordering.** `.int()` returns a fresh schema, so checks chained before it are lost. Write
  `z.number().int().min(0).max(5)`, not `z.number().min(0).max(5).int()`.
</Callout>

<Callout type="info">
  Root-level `.optional()` has no JSON Schema equivalent and is dropped. That is rarely a problem: an empty string still
  passes a plain `z.string()`, and only `.min(1)` makes a text field required. Inside `z.object()` properties,
  `.optional()` is kept as usual.
</Callout>

## Where validation runs today

- **Studio form**: enforced. The form converts the deployed fragment back to a validator; hidden and read-only fields, and fields on hidden [field groups](/docs/schema/field-groups), are skipped.
- **GraphQL API, imports, automations**: not enforced yet. API clients can write values the form would reject. Use `isRequired: true` when a value must exist at the API level (the generated field becomes non-null), and keep critical invariants in an automation until API-level enforcement ships.

## How agents see it

The deployed fragment is part of the project schema. Agents and API clients read it through the schema endpoint (`GET /<projectId>/api/schema`) and through the MCP `search_schema` tool, so a well-labelled rule doubles as documentation: an agent asked to create a product learns that `rating` is an integer between 1 and 5 before it writes.

## Related

- [Attribute Reference](/docs/schema/attribute-reference)
- [Hidden and read-only fields](/docs/schema/conditional-fields)
- [flexkit deploy](/docs/cli/deploy)


---

[View full sitemap](/docs/sitemap.md)
