Skip to Content
SchemaValidation

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:

TypeScript
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.

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:

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

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

RuleWhy
.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 formatsOnly email, uri, uuid, date-time, date and time are enforced by the form.

Rules the form cannot enforce belong in an automation that checks writes, or in a skill 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:

RuleExample message
minLength of 1 on an empty valueName is required
minLength above 1Code must be at least 3 characters
maxLengthCode must be at most 12 characters
patternCode has an invalid format
format: 'email'Email must be a valid email address
minimum and maximum togetherRating must be between 1 and 5
minimum alonePrice must be at least 0
type: 'integer'Position must be a whole number
multipleOfQuantity must be a multiple of 6
minItemsTags needs at least 2 items
enum / constStatus 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.

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().

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.

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, 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.

Last updated on