
Engineering
One JSON schema, a Rust struct, a zod validator, and a form
FastYoke Engineering · 8 min read · Jul 27, 2026
- Architecture
- Schema
- Frontend
The problem
Count the places a single field is described in a typical CRUD feature. There's the database column and its type. There's the server-side struct the request deserializes into. There's the server-side validation that checks the value is sane. There's the client-side validation that checks it again before the request leaves the browser. And there's the form control itself — the label, the input type, whether it's required. That's five descriptions of one field, hand-written in four different files, in two languages.
Every one of those is a copy, and copies drift. Someone widens the column but forgets the struct. Someone tightens the client validation but not the server's, so a scripted request sails past a rule the UI pretended to enforce. Someone adds a field to the form and a matching column but never wires up validation, and now the field accepts anything. None of these are exotic bugs. They're the ambient tax of describing the same thing five times and trusting five edits to stay in agreement forever.
The problem gets structurally worse on a platform like FastYoke, where the entities aren't ones we defined at build time — they're ones our customers define at runtime. A tenant invents an "Inspection" entity with its own fields this afternoon. There is no build-time struct to hand-write, no form component a frontend engineer can sit down and author, because the shape didn't exist when we shipped. If every field needs five hand-written descriptions, a user-defined entity is impossible.
Why validate in both places, not one
Before the mechanism, the principle it has to satisfy — because "we already checked it in the browser" is a classic and dangerous piece of reasoning.
Client-side validation exists for the human. It gives immediate, local feedback — this field is required, that date is malformed — without a network round-trip, and it's what makes a form feel responsive instead of punishing. But it is fundamentally advisory, because the client is controlled by the caller. Anyone can open developer tools, script the endpoint directly, replay a request with a tweaked body, or send raw JSON that never touched the form at all. A rule enforced only in the browser is a rule enforced only against users who are being polite.
Server-side validation exists for the system. It's the boundary that
actually holds, because it runs regardless of what sent the request — the
official form, a partner's API integration, a bulk import, a curl from a
laptop. This is the same principle
OWASP states plainly:
input validation must happen server-side, because client-side checks are
for user experience, not security.
So we need validation in both places — one for the person, one for the system. The failure mode that creates, if you're not careful, is two hand-maintained rule sets that drift apart: a field the UI accepts and the server rejects, or worse, one the UI blocks but the server would have taken, handing a direct API caller a capability the form never offered.
Where drift creeps back in
Notice which layers share a home. The stored shape, the server-side struct, and the form generator all live in one codebase and one language; keeping them aligned is mostly a matter of reading the same definition. The zod validator lives in TypeScript, on the far side of a language boundary — and that seam is where drift wants to creep back in.
A single source of truth is only worth anything if the client-side mirror can't quietly fall out of step with the server-side struct. So the boundary needs more than good intentions; it needs a rule about which side is authoritative and a habit that keeps the other side honest against it. That rule is the last piece of the design below.
How FastYoke approaches it
The single artifact is a JSON schema describing an entity — its fields, their types, which are required, their constraints. That document isn't a convenience; it's the definition of the entity, and four very different consumers read the one copy.
The stored shape comes from the schema. SQLite is FastYoke's tenant
datastore, and it has no native JSON column type the way some databases do.
So an entity's payload is stored as TEXT — the JSON serialized to a
string — and parsed back into structure at the application layer on the way
out. The schema is what tells the application how to interpret those bytes:
what fields to expect, what types to coerce them to. The column is dumb on
purpose; the schema carries the meaning.
The server-side struct comes from the schema. When a request arrives, its payload has to become typed data the Rust backend can work with — deserialized, its types checked, its required fields confirmed present. That deserialization is governed by the same schema. A field the schema calls a date is a date the server refuses to accept as the string "asap," regardless of what sent the request.
The client-side validator comes from the schema. Before the browser sends anything, the form validates the input against the same field definitions, expressed as a zod schema. Zod is a TypeScript-first validation library where the schema is the runtime check and the static type at once, so the shape the form validates and the shape the code believes it has cannot disagree.
The rendered form comes from the schema. The form itself — every label, input control, required marker, and inline error — is generated from the schema. For a user-defined entity there is no hand-authored form component anywhere in the frontend. Add a field to the schema and the field appears in the form, correctly typed and correctly validated, without a frontend engineer writing a line.
The Rust struct is authoritative; the zod schema mirrors it. This is the discipline that closes the language-boundary seam. The zod schema is treated not as an independently authored second opinion but as an exact reflection of the Rust deserialization struct — field for field, same names, same optionality, same bounds. Its job is to answer before the round-trip exactly as the server would answer after it, so a payload the browser blesses is one the server accepts, and vice versa. When the Rust struct changes — a field added, a constraint tightened — the zod schema changes in the same motion, and review looks for that pairing the way it looks for a tenant scope on a query: a mismatch between the two is a defect, not a style preference.
The reward for all of this is the thing the whole design is chasing: one place to change a field. Widen a constraint, and the stored shape, the server's deserialization, the client's validation, and the form all move together — because they were never separate descriptions to begin with. They were one schema read four times.
What to watch out for
Schema-driven generation is not the same as a good form for free. A generated form is correct — right fields, right types, right validation — but it lays fields out in the order and grouping the schema declares them. For a data-entry back office that's exactly right; for a polished customer-facing form you may still want deliberate control over sectioning, help text, and progressive disclosure. Generation gets you a correct form immediately; a considered form is still design work, and it's worth being clear-eyed that the two aren't identical.
The other thing to watch is that the "TEXT column plus application-layer
parsing" arrangement puts the burden of shape entirely on the application.
The database will store any string you hand it; it is the schema-driven
parse and validate on the way in that keeps a payload honest, and the
schema-driven read on the way out that makes sense of it. That's a
deliberate trade — flexibility for user-defined entities in exchange for
the application, not the column, owning correctness — and it only pays off
as long as the schema stays the single thing everyone reads. The moment a
piece of code parses that JSON by hand with its own idea of the shape, the
single source of truth quietly stops being single.
Where to go next
Schema-driven UI is the same instinct as the rest of FastYoke: write the definition down once as data, and let every layer read it instead of re-deriving it. It's the natural companion to modeling workflows explicitly — see finite state machines are the right abstraction for the transition side of the same schema, and type-level multi-tenancy for how the server keeps every one of these records scoped to the tenant that owns it. To build one, the entities and form builder docs are the place to start, and pricing shows which tiers unlock custom entities.