Runtime Validation with a Mini Schema

Why Types Alone Are Not Enough

Types disappear at runtime. Data from HTTP requests, forms, files and environment variables can be anything. Libraries like Zod, Valibot and ArkType solve both problems at once: you write a schema, it validates at runtime, and the TypeScript type is inferred from the schema. Here is a ~40 line version to show how it works.

Example

class ValidationError extends Error {}
interface Schema<T> {
  parse(x: unknown, path?: string): T;
  optional(): Schema<T | undefined>;
}
type Infer<S> = S extends Schema<infer T> ? T : never;

function schema<T>(check: (x: unknown, path: string) => T): Schema<T> {
  return {
    parse: (x, path = "value") => check(x, path),
    optional: () => schema((x, path) => (x === undefined ? undefined : check(x, path))),
  };
}
const fail = (path: string, msg: string): never => { throw new ValidationError(`${path} ${msg}`); };

const z = {
  string: (min = 0) => schema((x, p) => (typeof x === "string" && x.length >= min ? x : fail(p, min ? `must be a string of at least ${min} chars` : "must be a string"))),
  number: (min = -Infinity) => schema((x, p) => (typeof x === "number" && x >= min ? x : fail(p, `must be a number >= ${min}`))),
  enum: <const V extends readonly string[]>(values: V) =>
    schema((x, p) => (values.includes(x as string) ? (x as V[number]) : fail(p, `must be one of ${values.join("|")}`))),
  array: <T>(item: Schema<T>) =>
    schema((x, p) => (Array.isArray(x) ? x.map((v, i) => item.parse(v, `${p}[${i}]`)) : fail(p, "must be an array"))),
  object: <S extends Record<string, Schema<unknown>>>(shape: S) =>
    schema((x, p) => {
      if (typeof x !== "object" || x === null) return fail(p, "must be an object");
      const out: Record<string, unknown> = {};
      for (const key in shape) out[key] = shape[key].parse((x as Record<string, unknown>)[key], `${p}.${key}`);
      return out as { [K in keyof S]: Infer<S[K]> };
    }),
};

const ProductSchema = z.object({
  name: z.string(3),
  price: z.number(1),
  category: z.enum(["electronics", "books", "fashion"]),
  tags: z.array(z.string()).optional(),
});
type Product = Infer<typeof ProductSchema>; // inferred, never written by hand

const good: Product = ProductSchema.parse(JSON.parse('{"name":"Kettle","price":1299,"category":"electronics","tags":["sale"]}'), "body");
console.log(good.name, good.category, good.tags);

for (const raw of ['{"name":"TV","price":20000,"category":"electronics"}', '{"name":"Kettle","price":0,"category":"toys"}', '{"name":"Book","price":299,"category":"books","tags":[1]}']) {
  try { ProductSchema.parse(JSON.parse(raw), "body"); } catch (e) { console.log("Invalid:", (e as Error).message); }
}

Output

Kettle electronics [ 'sale' ]
Invalid: body.name must be a string of at least 3 chars
Invalid: body.price must be a number >= 1
Invalid: body.tags[0] must be a string

The Real Zod

import { z } from "zod";

const ProductSchema = z.object({
  name: z.string().min(3),
  price: z.number().positive(),
  category: z.enum(["electronics", "books", "fashion"]),
  tags: z.array(z.string()).optional(),
});
type Product = z.infer<typeof ProductSchema>;

const result = ProductSchema.safeParse(await req.json());
if (!result.success) return Response.json(result.error.flatten(), { status: 400 });
const product: Product = result.data;

Tip: Validate at the edges of your system (HTTP handlers, form submits, env loading, message consumers). Inside, trust the types.