Why wellformed?
Validation as data, not code, and the situations where that matters, from dynamic forms to multi-runtime validation, untrusted input, AI agents, and audited rules.
Why wellformed?
A typical validation schema is a program. wellformed's schema is data. That one difference decides what you can do with your validation rules: store them, load them by id, send them to another language, generate them, and audit them. When your rules need to do any of that, they have to be data.
The short version
If your validation rules are known at compile time and only ever run in one TypeScript app, a closure-based library like Zod is great. The moment the rules are defined elsewhere, run somewhere else, guard a boundary, are generated by an agent, or have to be stored and audited, they need to be data.
Validation as data, not code
In a closure-based library, a schema is an in-memory JavaScript object whose
rules are functions: .refine(fn), .transform(fn), .regex(re). Functions do
not serialize. Zod, for example:
import { z } from "zod";
const schema = z.object({ ssn: z.string().regex(/^\d{9}$/) });
JSON.stringify(schema); // "{}" ... the rules are goneThe logic only exists inside the process that built it. wellformed compiles to JSON instead, and the serialized form is the complete rule set: every predicate, transform, error code, and cross-field rule, as plain data.
import { schemaToJSON, w } from "wellformed-ts";
const schema = w.object({ ssn: w.string().digitsOnly().ssn() }).toSchema("1.0");
schemaToJSON(schema); // a full JSON program you can store, send, and replayEverything below is a situation where that property does real work that a closure cannot.
Where it fits
The form is defined at runtime
The structure of the form is not known when you ship your app. It is authored, configured, or generated later, so you cannot write one TypeScript schema per form. The rules have to be stored with the form definition and re-executed every time someone renders or submits it. This is the broadest case, and it is not just PDFs:
- Form builders and no-code tools. A Typeform, Google Forms, Jotform, or your own internal builder, where a user drags fields and sets validation at runtime.
- Multi-tenant SaaS with custom fields. Each customer defines their own onboarding, intake, or application fields. You cannot hardcode a schema per tenant.
- Surveys and questionnaires. Clinical intake, research, NPS, eligibility checks, often with conditional requirements (skip logic) expressed as cross-field rules.
- CMS and config-driven forms. Marketing or operations edits a lead form or intake flow without a deploy.
- Documents and regulated forms. PDF AcroForms, tax packets, and insurance applications are the case where the form is literally a file. One example, not the whole story.
The schema is a row you store next to the form definition, load by id, and run in whatever renders or receives the form. See Storing rules in practice below.
The same rules run in more than one place
You validate in the browser for instant feedback, then re-validate authoritatively on the server, and the server is not always JavaScript. Without portable rules, you write the validator twice and the two copies drift.
- TypeScript frontend, Rust backend. Author once, validate in the browser and natively in a Rust service with identical results.
- Edge and origin. Validate at a Cloudflare Worker or edge function, then again at the origin, from the same schema.
- Polyglot services. A schema shared between a Node API and a Rust data pipeline or batch worker.
- Multiple clients. Ship one schema and validate it the same way in every client runtime.
The IR deserializes and runs in both the TypeScript and Rust runtimes with the same predicates, transforms, and error codes. See the Rust runtime.
Untrusted input arrives at a boundary
Something hands you raw JSON you did not author. You want to validate it, with field-level error codes, before you trust it or deserialize it into a typed structure.
- API request bodies. Return
EMAIL_INVALIDon the right field, not a generic deserialization failure. - Third-party webhooks. Validate inbound payloads from partners before you act on them.
- CSV and bulk imports. Validate uploaded spreadsheets row by row with domain predicates and per-cell error codes.
- Queue and event messages. Validate messages off Kafka or SQS in whatever language the consumer runs.
In Rust you validate the raw serde_json::Value directly, so wrong types,
missing fields, unknown keys, and cross-field violations come back as structured
errors instead of a parse failure. See the Axum boundary guide.
AI generates and operates the software
Increasingly an agent builds the form, writes the rules, and processes the submissions. That only works if validation is something a model can produce and you can safely run. A closure is neither: an LLM emitting arbitrary JavaScript is unverifiable and unsafe to execute, and an agent cannot inspect or repair a function it did not write.
- A safe generation target. A model reliably fills a fixed vocabulary of predicates and transforms. It does not reliably write correct, safe JavaScript. There is nothing arbitrary to execute.
- Inspectable and diffable. An agent reads, explains, and edits the rules as JSON, instead of reverse-engineering functions.
- A repair loop. Generate, validate, read the stable error codes and field
paths, fix. An agent can branch on
EIN_REQUIRED. It cannot branch on a sentence that might get reworded. - Validate the model's own output. Check a tool call or structured extraction before you act on it, and hand the same contract back to the model to constrain or repair it.
Validation is a constrained data format, safe to generate, store, and execute
against a trusted runtime, with no code generation and no eval. See
AI Integration.
Rules must be stored, versioned, and audited
The rules themselves are an artifact you manage, not lines buried in a deploy.
- Compliance and regulated forms. W-9 2024 versus 2025, a state disclosure by jurisdiction and year. The submission must record which rules judged it.
- A schema registry. A central store of validation contracts, loaded by id, the way you would manage shapes in a schema registry.
- Provenance and replay. Re-validate a historical record against the exact rules in effect when it was submitted.
- Reviewable rule changes. Diff a validation change in a pull request, because it is a JSON diff, not a refactor of opaque functions.
The rules are data, so they version, diff, and travel with the records they judged. See Version Schemas Explicitly.
Storing rules in practice
When forms are defined at runtime, the rules are a row, whatever defined the form:
forms
──────────────────────────────────────────────────
id version schema (jsonb)
builder_form_42 7 { "version": "1.0", "root": { ... } }
acme_onboarding 3 { "version": "1.0", "root": { ... } }
survey_nps 1 { "version": "1.0", "root": { ... } }A user saves a form in your builder, or a tenant edits their fields, and it is live with no deploy. At runtime you load the rules by id and parse them into a validator:
import { parseSchema, validate } from "wellformed-ts";
const row = await db.forms.get({ id: "builder_form_42", version: 7 });
const schema = parseSchema(JSON.stringify(row.schema)); // row.schema is jsonb
const result = validate(schema, submission);Because the schema is data, you can also store its version on each submission. A record validated last year can be re-checked against the exact rules in effect then, and a rule change is a JSON diff you can review. For tax, KYC, and healthcare workflows, "which rules judged this submission" becomes a question your data can answer.
Why not a central validation service?
You could put validation behind a service everyone calls, but that only sharpens the need for stored rules. The service still has to load the rules for a given form by id, and the only thing you can load by id is data, not a closure. Storing the IR lets each runtime validate locally, with no extra network hop.
Domain-heavy fields
Across every case above, real forms validate real-world identifiers, and writing those by hand is where validation code goes to die. wellformed ships the predicates built in:
- Fintech. SSN, EIN, ITIN, ABA routing, IBAN, CUSIP, card Luhn checks, and money normalization.
- Healthcare. ICD-10, NDC, and related code formats.
- Govtech and tax. TINs, filing status, US state and ZIP.
See the full predicate list.
When you do not need wellformed
If everything runs in one TypeScript app, the forms are hardcoded, nothing is stored, nothing crosses a language boundary, no agent is involved, and nothing needs an audit trail, reach for Zod. wellformed is for when one of those assumptions breaks.