← All posts
Scaffy

One schema, two jobs: generating runtime validation from your types

Jun 26, 2026 · 8 min read · by Henners
Field definitions branching into two jobs: a TypeScript type inferred at compile time and a Zod validator enforced at request time

The very first post made a promise I’ve been making good on ever since: your schema is the source of truth. The posts since have shown that schema doing real work, picking the adapters, shaping the route table, deciding what a handler hands back. But there’s one job it hadn’t done yet, and it’s the one most people mean when they say “source of truth”: actually guarding the data. Nothing so far stopped you from writing a post with no title, or a number where a date should be. This post is about closing that gap, and doing it without writing a single validator by hand.

Here’s the setup. A write arrives as JSON, the handler parses it into an object, and at that point the object is just unknown. It might match the schema the developer declared in scaffy.config.ts, or it might be garbage. I could write a Zod schema per content type to check it, but that’s the same shape information twice: once in the field definitions, once in a parallel validator I’d have to keep in sync by hand. The field definitions already exist. So the move is to generate the validator from them.

Two jobs from one definition

The field definitions are plain data. field.text({ required: true }) returns { type: "text", required: true }, nothing more. At compile time those definitions infer the TypeScript type of an entry, which is job one. I want the exact same objects to also produce a runtime check, job two, so that the thing TypeScript trusts at compile time is the thing the API enforces at request time.

import { field } from "@scaffy-cms/core";

// the same definitions that infer your entry's TypeScript type
const fields = {
  title: field.text({ required: true }),
  views: field.number(),
  contact: field.email(),
};

That object is the input. The output I want is a Zod schema that accepts { title: "Hello", views: 3 } and rejects { views: "three" } with a message that names the offending field. One function bridges the two.

fieldsToZod, one field at a time

The whole transform is a walk over the fields. Each field definition becomes one Zod schema, and the collection of them becomes a z.strictObject. The interesting decisions are all in two small helpers: what schema a single field maps to, and whether it’s optional.

const leafSchema = (field: FieldDefinition): z.ZodType => {
  if ("blocks" in field) return blocksSchema(field);
  if ("itemType" in field) return z.array(fieldSchema(field.itemType));
  if ("schema" in field)
    return field.type === "references" ? z.array(z.string()) : z.string();

  switch (field.type) {
    case "number": return z.number();
    case "email": return z.email();
    default: return z.string();
  }
};

// `required` decides .optional(); everything else is the leaf above
const fieldSchema = (field: FieldDefinition): z.ZodType =>
  field.required ? leafSchema(field) : leafSchema(field).optional();

// strictObject, so an unknown key is a rejection, not a silent passenger
export const fieldsToZod = (fields: Record<string, FieldDefinition>) =>
  z.strictObject(
    Object.fromEntries(
      Object.entries(fields).map(([name, def]) => [name, fieldSchema(def)]),
    ),
  );

Read leafSchema top to bottom and it’s a sieve by shape, not by name. A field with a blocks key is the blocks case (its own section below). One with an itemType is an array, and notice it recurses: an array’s element validator is just fieldSchema of the inner type, so an array of emails validates each item as an email. A field carrying a schema is a reference, where references (plural) is an array of id strings and reference (singular) is one. Only after those structural checks does it fall through to the scalar switch, where number and email get their real Zod types and everything textual collapses to z.string().

Two choices in there are load-bearing. The first is strictObject rather than a plain object. A loose object would let unknown keys ride along and get persisted, so a typo’d titel or a stray field from an old client would quietly land in DynamoDB next to the real data. Strict mode turns that into a rejection, which is what I want: the schema is the allowlist, and anything not in it is an error, not a passenger. The second is the required split. fieldSchema only reaches for .optional() when the definition says the field isn’t required, so optionality is driven by the same flag the admin UI reads, not declared separately.

The field that didn’t fit: blocks

Everything above is tidy because each field maps to one fixed schema. Blocks broke that, and they’re worth the detour because the fix is the only genuinely awkward code in the file. A blocks field is a discriminated union: an array where each item is one of several different shapes, told apart by a type literal. A page body might be a heading block, then a paragraph, then an image, each with its own fields.

Zod ships z.discriminatedUnion for exactly this, and I tried it first. The problem wasn’t making it parse, it was the errors. When an item’s type matches no branch, a bare union reports failure against every branch at once, so a single wrong block produces a wall of “expected literal X” issues that say nothing useful. I wanted one precise message. So I dispatch on the discriminator myself: build a map of block name to its strict schema, then validate each array item by hand through a superRefine.

const blocksSchema = (field: BlocksFieldDefinition): z.ZodType => {
  const schemasByType = new Map(
    field.blocks.map((block) => [
      block.name,
      z.strictObject({ type: z.literal(block.name), ...shapeFromFields(block.fields) }),
    ]),
  );

  return z.array(
    z.unknown().superRefine((value, context) => {
      const blockType =
        typeof value === "object" && value !== null &&
        "type" in value && typeof value.type === "string"
          ? value.type
          : undefined;

      if (blockType === undefined)
        return context.addIssue({ code: "custom", message: 'Block is missing a string "type" field' });

      const schema = schemasByType.get(blockType);
      if (schema === undefined)
        return context.addIssue({ code: "custom", message: `Unknown block type "${blockType}"` });

      const result = schema.safeParse(value);
      if (!result.success)
        result.error.issues.forEach((issue) =>
          context.addIssue({ code: "custom", message: issue.message, path: issue.path }));
    }),
  );
};

The flow is read the type, look up its schema, run it. A missing or non-string type gets one clear message. A type that isn’t in the map gets Unknown block type "quote", naming the exact value. And when the type is valid but a field inside the block is wrong, I forward that block’s own issues up, keeping their path, so the error still points at the precise field deep inside the array rather than at the array itself. The nice part is the line building each block’s schema: ...shapeFromFields(block.fields) is the same machinery the top level uses, so a block’s inner fields validate by exactly the same rules as a root schema. Blocks are the one recursive corner, but they reuse everything else.

Where it runs

fieldsToZod is the engine; ContentValidationService is the thing handlers actually call. It’s constructed once per cold start in the factory from the config’s schemas and collections, the same module-level construction as the adapters two posts back, so the lookup maps are built once and reused on every warm invoke.

// factory.ts: built once per cold start, reused on every warm invoke
export const contentValidationService = new ContentValidationService(
  config.schemas,
  config.collections,
);

The service exposes one method per family, validateSchemaEntry and validateCollectionEntry, mirroring the two handler families from the last post. The collection version carries one extra check worth showing, because a collection groups schemas under one name and a write has to declare which schema it is via a type:

validateCollectionEntry(collectionName: string, type: string, data: unknown) {
  const collection = this.collectionsByName.get(collectionName);
  if (!collection) return failure({ /* Unknown collection */ });

  // the declared type must be a schema this collection actually holds
  if (collection.schema.name !== type) return failure({ /* Unknown type for collection */ });

  return this.validateAgainst(collection.schema, data);
}

So a collection write is rejected before any field validation if it claims to be a quote when the collection only holds post. The shape check and the membership check are different failures with different messages, and both happen here, off the hot path of the adapter.

In the handler: a 422 of its own

This gives the handler a third, distinct failure, and getting the status codes right matters more than it sounds. Parsing bad JSON is a 400: I couldn’t even read your request. Validation failing is a 422: I read it fine, it just doesn’t match the schema. The adapter failing is a 500: something on my side broke. The schema is now precisely the thing that draws the line between 400 and 422.

const validation = contentValidationService.validateSchemaEntry(schemaName, parsed.data);
if (!validation.success)
  return { statusCode: 422, body: JSON.stringify({ error: validation.errorMessage }) };

// pass validation.data, the Zod output, not the raw parsed body
const result = await databaseAdapter.createSchemaEntry(schemaName, validation.data);

One detail in that last line: the adapter receives validation.data, the value Zod returned, not the object the handler parsed. They’re the same fields, but routing the validated value through means the only thing that ever reaches the database is something that passed the gate. And the message in that 422 is readable, because a small formatZodIssues helper flattens Zod’s issue list into title: Required; views: Expected number, each issue prefixed with the field path. The client gets told what to fix, not just that something was wrong.

What it cost, and what’s next

The honest cost first: fieldsToZod rebuilds the Zod schema from the field definitions on every validate call. For a schema with blocks that’s a small tree of allocations per write. At Scaffy’s write volume, an admin saving an edit now and then rather than a firehose of traffic, it’s nothing I can measure, but it’s the obvious thing to memoize per schema name the day writes ever get hot. The structure makes that a one-line cache; I just haven’t needed it.

The deeper limit is that this validates shape, not meaning. It knows title must be a string and views must be a number, because that’s all a field definition carries today: a type and a required flag. It can’t yet say a title has to be under eighty characters, or that a publish date can’t be in the past, or that two fields have to agree. Those are constraints the field API doesn’t express yet, and when it does, this is the layer they’ll compile into, the same generate-from-the-schema path with more to generate.

There’s also a reference I waved past. A reference field validates as a string, an id, and a references field as an array of them. Checking the id is well-formed is not the same as checking the thing it points to exists, let alone fetching it. That resolution, turning an id into the entry it names, is the populate path, and it lives in the database adapter. Which is where this series goes next: out of the handler and into the single-table DynamoDB design, where these validated writes finally become rows.