When Your Zod Schema Lives in the Database: A Builder Pattern Story

This is a story about a form that validated itself, not because the schema was hardcoded, but because we built it at runtime.

I was working on a feature where admins could spin up their own forms. Drag a field in, mark it required, set a max length, save, ship. By the time an end-user opened that form, the rules had been sitting in our database as JSON for an hour.

The frontend had no clue what schema it was validating until the user clicked “Open Form.” And because some fields were conditionally hidden, it had to re-build the schema every time the form state changed. Hidden fields shouldn’t validate. Visible ones should. The shape of “valid” was a moving target. 😶

If you’ve ever wished you could write validation rules as data and assemble them into a schema only when you needed to (not at build time, not at import time, but at the exact millisecond a user taps a button)… let’s get rolling…

The Curious Case of the Schema That Wasn’t There

Here’s the thing about Zod (and Yup, and Joi, and basically every schema library): the docs assume you know your schema at write-time. You type z.object({ email: z.string().email() }), export it, import it wherever, .parse() your data. Clean. Static. Honest work.

That works beautifully when the form is yours. But mine wasn’t. Mine was the admin’s. And the admin’s “form” was really a JSON blob like:

[
{ "name": "email", "type": "string", "validations": { "required": true, "format": "email" } },
{ "name": "dob", "type": "date", "validations": { "required": true, "isDateLessThan": "today" } },
{ "name": "bio", "type": "string", "validations": { "maxLength": 50 } }
]

I couldn’t import { formValidationSchema } from ‘@/(folder)/formSchema’. There was no ./formSchema. There was a row in Postgres and a fetch call. The data was the spec. My job was to turn that data into a real, callable, parse-able Zod object, and do it again, slightly differently, every time a checkbox got ticked and a field disappeared.

Roughly the pipeline I needed:

JSON config from DB


build schema ◄── re-runs when visible fields change


validate


error map → render under each field

The hardcoded approach died the moment a field was conditional. Conditional means “this field exists only if country === ‘IN’”. Which means the schema itself depends on form state. There is no one schema. There are infinite schemas, one per state, and I have to manufacture the right one on demand.

the dependency loop that broke my hardcoded approach

Why Builder Fits Here

(If you’ve read my Facade Pattern story, you already know I have a soft spot for design patterns that show up in real codebases instead of textbook UML diagrams. This is another one.)

Builder shows up when an object is too complicated, too optional, or too situational to construct in a single call. Instead of one giant constructor that takes 14 arguments (half of them undefined), you collect the pieces step by step. Then, only when you say so, you assemble the final thing.

Picture a build-your-own-bowl restaurant. You walk up. You don’t shout a 9-word order at the chef. You point at brown rice. Then chicken. Then black beans, corn, pico, a little guac, no sour cream, extra hot sauce. Each pointing-at-something is a method call. The bowl itself doesn’t exist yet. It’s a tray of intentions. The bowl exists the moment you say “that’s it.” That moment is .build(). 🥣

Me assembling a Zod schema one topping at a time 🥣

Collect choices. Defer construction. Materialize on demand.

In my case, the “choices” were rows of JSON config from the database. The “bowl” was a Zod schema. And .build() was the call that turned [{ name, type, validations }, ...] into z.object({...}) right before validation ran. A chef can’t pre-make every possible bowl in advance. The combinations explode, and the choices belong to the customer. So you build per-order.

The Solution

Let’s start with a generic, non-Zod example so the shape of the pattern lands clearly.

Before: one giant constructor

const rows = runQuery({
table: 'users',
columns: ['id', 'name'],
where: { active: true },
limit: 10,
orderBy: 'created_at',
joins: undefined,
groupBy: undefined,
having: undefined,
});

You either pass everything or pass undefined for everything you don’t care about. Adding an option means changing every caller.

Fluent assembly just hits different

After: A Fluent Builder

const rows = query('users')
.select('id', 'name')
.where({ active: true })
.limit(10)
.build();

Each method tucks a piece into an internal object and returns this. Nothing actually runs until .build(). That’s the deferred-construction trick.

Now the same shape, applied to my form-validation problem:

import { z, ZodTypeAny } from 'zod';
type FieldConfig = {
name: string;
type: 'string' | 'number' | 'date';
validations?: {
required?: boolean;
format?: 'email';
maxLength?: number;
isDateLessThan?: 'today' | string;
};
};
function buildZodSchema(fields: FieldConfig[]) {
const shape: Record = {};
for (const field of fields) {
let s: ZodTypeAny =
field.type === 'number' ? z.coerce.number() :
field.type === 'date' ? z.coerce.date() :
z.string();
const v = field.validations ?? {};
if (v.format === 'email' && s instanceof z.ZodString) {
s = (s as z.ZodString).email('Enter a valid email');
}
if (v.maxLength != null && s instanceof z.ZodString) {
s = (s as z.ZodString).max(v.maxLength, `Max ${v.maxLength} chars`);
}
if (v.isDateLessThan === 'today') {
s = s.refine(
(val) => val instanceof Date && val < new Date(),
{ message: 'Date must be before today' },
);
}
// required is the LAST switch, it flips optional off
if (v.required) {
if (s instanceof z.ZodString) s = (s as z.ZodString).min(1, 'Required');
} else {
s = s.optional();
}
shape[field.name] = s;
}
return z.object(shape);
}
  • Each field starts as a base type (string / number / date), the “base” of the bowl.
  • Toppings layered on (email, maxLength, isDateLessThan): each is a small, isolated translation from a JSON rule to a Zod method.
  • required is applied last so we can cleanly choose between .min(1) and .optional().
  • Nothing got “built” until z.object(shape). That’s our .build().

Now the calling code is small and lazy:

const visible = allFields.filter((f) => isVisible(f, formState));
const schema = buildZodSchema(visible);
const result = schema.safeParse(formState);

Every time visibility changes, we throw the old schema away and build a fresh one. Cheap, correct, and the rules still live in the database where the admin puts them.

Quick Checks & Tips

A few things I’d tell past-me before reaching for this pattern:

  1. Build lazily. Don’t construct the schema on every keystroke. Build it on blur or on submit, or inside a memo keyed on the visible-field signature.
  2. Build only what’s relevant. If 30 fields are configured but 6 are visible, build a schema with 6 keys. Fewer rules to evaluate, fewer phantom errors to hide.
  3. Keep each rule translation tiny. One if per validation key. Easy to add the next rule, easy to delete a wrong one. Resist the urge to make one mega-function with branching nests.
  4. Default to safe. If a JSON rule is unrecognized, log it but don’t crash. Admins will eventually type things you didn’t plan for and that’s definitely gonna happen.
  5. Know when NOT to use Builder. A 3-field signup form with fixed fields does not need one. Builder earns its keep when configuration is external: coming from a CMS, a database, a feature flag, a user choice.

Where Else Can We Apply This?

  • Runtime form validation. In my form builder, every stage of the multi-step form gets a freshly-built Zod schema from the JSON config of just the visible questions, and re-builds whenever conditional logic flips a field on or off. The full pipeline (config → visibility → schema → errors → UI) lives in my upcoming Form Renderer walkthrough.
  • Query builders. Knex, Kysely, Prisma’s .where().select().orderBy(): fluent assembly, deferred SQL. Same shape, different domain.
  • Animation timelines. Framer Motion and GSAP timeline builders: .to().to().to() chained, then .play(). Each .to() is an intent, .play() is the bowl.
  • HTTP request builders. request.url(x).header(y).body(z).send(): composition without a 14-argument constructor full of undefineds.
  • Test data factories. userFactory().withSubscription().asAdmin().build(): your test reads like a sentence about the user instead of a JSON dump.

End Game

You don’t wake up one day and say: “Today I’ll implement the Builder Pattern.” It happens naturally when the world starts pushing back on your hardcoded assumptions.

The triggers I’ve noticed:

  • Validation rules checked into a CMS or admin panel instead of code.
  • Configurations growing past 8 parameters, most of them optional.
  • Functions where you find yourself passing undefined, undefined, undefined, true, undefined.

When any of those show up, you don’t need a clever abstraction. You need a tray, a few methods, and a .build() at the end.

If you’ve shipped a feature where the schema, the query, or the UI shape lived outside your codebase, I’d love to hear how you tamed it in the comments.

Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.