← All posts
2 min read

A single Zod schema gives you a static TypeScript type and a runtime validator. One source of truth. A class of bugs gone.

When a TypeScript type meets untrusted JSON - from a form, a third-party API, an LLM response - the type is a comment. The runtime knows nothing about it. The type checker can't reach past the network. So we end up with two definitions for the same shape: the type, and a hand-rolled validator. They drift. Bugs follow.

Zod fixes this by making one definition produce both.

The pattern

The schema is the type. The schema is the validator. There is no second source.

ts
import { z } from "zod";

export const caseStudyFrontmatter = z.object({
  title: z.string().min(3),
  slug: z.string().regex(/^[a-z0-9-]+$/, "kebab-case"),
  oneLine: z.string().min(10).max(200),
  role: z.string(),
  stack: z.array(z.string()).min(1),
  liveUrl: z.string().url().optional(),
  order: z.number().int().nonnegative(),
});

export type CaseStudyFrontmatter = z.infer<typeof caseStudyFrontmatter>;

The static type comes from z.infer. The runtime validator comes from .parse(). They cannot drift, because they are the same thing.

Where this earns its keep

Three places. All three are zones where untrusted JSON meets your types.

1. LLM output

You ask Gemini to return JSON. Gemini complies - sometimes. Sometimes it returns prose. Sometimes it returns JSON with one field misnamed. A schema catches all of these and lets you retry with a corrective system message before the bad data touches your domain logic.

2. Form payloads

A <form> is hostile input by default. Zod runs the same check on the server that ran on the client, so a malicious actor sending a hand-crafted POST gets the same rejection a real user gets - without a second hand-rolled validator.

3. Build-time content

MDX frontmatter is just YAML. YAML is not typed. A schema turns it into a typed value at build time, with line-and-column errors when a post is malformed.

A small but real example

Suppose your blog accepts cross-posts. Some posts are originals; some are republished from dev.to with a canonical pointing back. The schema describes both states clearly:

ts
const post = z.object({
  title: z.string().min(3),
  externalCanonical: z.string().url().optional(),
  syndicatedTo: z.array(
    z.object({
      platform: z.enum(["devto", "medium", "hashnode"]),
      url: z.string().url(),
    })
  ).optional(),
});

If externalCanonical is set, your sitemap excludes the post (SEO weight goes elsewhere). If syndicatedTo is set, your post page renders an "Also published on" footer. These behaviors aren't documented in a comment - they're enforced by the schema and consumed by typed code.

zod.dev

Zod - the docs

Comprehensive reference for the schema-as-validator-as-type pattern.

The takeaway

Treat schemas as contracts, not comments. Make the type and the validator the same artifact. Let the build break loudly when content is malformed. You'll remove a category of bug - drift between what your types claim and what your runtime accepts - that no amount of testing will fully cover.

Also published on

Related