send.webforms.toRead the docs →

how-todevelopersvalidation

One form schema for validation, TypeScript, Zod and OpenAPI

The HTML says the field is optional, the backend says required, the TypeScript type forgot it exists. One list of fields, generated outward, ends that drift.

WebForms 2 min read

Keep one list of a form's fields, with each field's type, whether it is required, its options and limits, and generate everything else from it: the server-side check that accepts or refuses a submission, TypeScript types for code that reads submissions, a Zod schema for client-side validation, JSON Schema, and an OpenAPI document for the endpoint. When every artefact comes from the same source, they cannot disagree.

Why do form definitions drift?

A typical contact form is defined four times. The HTML has required attributes and input types. The backend has its own checks, written once and rarely revisited. The frontend has a validation library with its own rules, and the code that reads submissions has a type someone wrote by hand. Add a phone field and three of those four need an edit. The one that gets missed decides whether you lose submissions (the backend refuses something the form allows) or accept junk (the backend allows something the form was meant to stop).

What does a single form schema contain?

Less than you might expect. For each field: a name, a type (text, textarea, email, tel, url, number, date, select, radio or checkbox), a label, whether it is required, the options for a select or radio, numeric or length limits, an optional pattern, and a sentence describing what the field is for. That last part is easy to skip and is what makes the schema useful to people reading your API docs and to AI agents filling the form.

fields
[
  { "name": "name", "type": "text", "required": true, "max": 80 },
  { "name": "email", "type": "email", "required": true, "description": "Where we reply" },
  { "name": "plan", "type": "select", "options": ["Starter", "Team", "Enterprise"] },
  { "name": "message", "type": "textarea" }
]

What can you generate from it?

contact.ts
export type ContactSubmission = {
  name: string;
  /** Where we reply */
  email: string;
  plan?: "Starter" | "Team" | "Enterprise";
  message?: string;
};
contact.schema.ts
import { z } from "zod";

export const contactSubmissionSchema = z
  .object({
    name: z.string().max(80),
    email: z.string().email(),
    plan: z.enum(["Starter", "Team", "Enterprise"]).optional(),
    message: z.string().optional(),
  })
  .passthrough();
  • JSON Schema, for tools and other languages.
  • An OpenAPI document for the form's endpoint, so API clients and agents that read API descriptions know exactly what to send.
  • Labelled HTML with browser validation attributes and AI-agent descriptions, ready to paste.

How do you turn on validation without breaking a live form?

  1. Start from what the form actually sends: infer the fields from recent submissions rather than from memory.
  2. Save the schema with validation off. Nothing changes for visitors yet.
  3. Check the generated HTML against your page; fix whichever side is wrong.
  4. Turn on rejection. Non-matching submissions now get a 422 listing each problem, which a script can show next to the field.
  5. Decide what happens to fields outside the schema: keep them (safer while a site is changing) or refuse them (tighter).

Doing it in WebForms

Every WebForms form has a Fields and schema editor in its settings. If the form already has submissions, Start from those suggests the fields and types it has seen. Below the editor are the generated HTML, TypeScript, Zod and JSON Schema with copy buttons, and every form has a public schema.json and openapi.json at its endpoint that stay current as you edit. Rejecting non-matching submissions is a separate switch, off by default. The same schema is available over the API and to AI assistants over MCP.

Questions

Should form validation happen on the client or the server?
Both, for different reasons. Client-side checks help people fix mistakes before sending; server-side checks are the only ones a script cannot skip. Generating both from one schema keeps them in agreement.
Can I generate a Zod schema from a form definition?
Yes. A field list with types, required flags, options and limits maps directly to Zod: emails to z.string().email(), selects to z.enum, numbers to z.coerce.number with min and max, and optional fields to .optional().
Will turning on validation break my existing form?
It can, if the schema is stricter than what the form sends today. Start with validation off, compare against recent submissions, then turn it on.

Define fields once in WebForms

The form schema editor validates submissions and generates TypeScript, Zod, JSON Schema, OpenAPI and agent-ready HTML. It can even suggest the fields from submissions you already have.