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.
[
{ "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?
export type ContactSubmission = {
name: string;
/** Where we reply */
email: string;
plan?: "Starter" | "Team" | "Enterprise";
message?: string;
};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?
- Start from what the form actually sends: infer the fields from recent submissions rather than from memory.
- Save the schema with validation off. Nothing changes for visitors yet.
- Check the generated HTML against your page; fix whichever side is wrong.
- Turn on rejection. Non-matching submissions now get a 422 listing each problem, which a script can show next to the field.
- 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.