zmdbzero-maintenance data layer
Docs Benchmarks Anti-patterns OpenAPI
Docs / Ecosystem integrations

ZodSupported

Zod declares a schema and infers a type from it. zmdb goes the other way: the type _is_ the schema, and the check is generated at compile time. Both directions work; they interoperate at the edges.

The shape of the difference#

// Zod: schema first, type derived
const User = z.object({ id: z.number(), email: z.string().email() });
type User = z.infer<typeof User>;
const parsed = User.parse(body);

// zmdb: type first, checker generated
interface User {
  id: number;
  email: string;
}
const parsed = assert<User>(body);
Zodzmdb validator
Source of truththe schema valuethe TypeScript type
Runtime cost of the schemabuilt at module loadnone — a compile-time literal
Build stepnonerequired (AOT setup)
Dynamic (runtime-defined) schemasyesno
Refinements.refine()refine
Transforms.transform()transform
Failure mode if misconfiguredn/athrows: no runtime type witness

That last row is the one to internalise. A generic zmdb call cannot recover its erased type argument when the transformer is absent, so it throws rather than guessing. See Gotchas.

Using both in one codebase#

Perfectly reasonable, and common during a migration. Keep the boundary explicit:

// Zod for the dynamic parts — a user-defined form, a plugin manifest
const formSchema = buildZodFromUserConfig(config);

// zmdb for the fixed parts — your own DTOs
const dto = assert<CreatePostDto>(body);

The dynamic case is the one zmdb genuinely cannot do: a type parameter must be known at compile time, so a schema assembled at runtime has to be interpreted by something. Zod is a good answer for that; so is evalRule for simple rules, or ajv over JSON Schema.

Feeding a zmdb schema to Zod#

If you have a declared table and want a Zod validator for it — say a route already validating with Zod — go through JSON Schema:

import { toJsonSchema } from '@zmdb/schema/openapi';

const jsonSchema = toJsonSchema(users, 'create');
// then use a json-schema-to-zod converter, or ajv directly

toJsonSchema(schema, variant) covers entity | create | update | get | list | search, so the create-shaped schema already omits serial columns and respects defaultTo. See OpenAPI Schemas.

Often the simpler move is to skip Zod for that route: the DTO types are already derived from the schema, and assert<CreateDTO<User>>(body) needs no bridge at all.

Migrating off Zod#

Incrementally, one boundary at a time.

1. Keep the inferred type, drop the schema. Where the schema is only used for parse, the type it inferred is what you actually wanted:

// before
const User = z.object({ id: z.number(), email: z.string() });
type User = z.infer<typeof User>;

// after
interface User {
  id: number;
  email: string;
}

2. Replace parse with assert, safeParse with validate.

User.parse(body)      → assert<User>(body)
User.safeParse(body)  → validate<User>(body)   // { success, data?, errors? }

3. Translate the refinements you actually rely on. Format checks that Zod gives you as methods are validate() rules or a refine predicate. Decide explicitly which ones matter — z.string().email() is a regex, and half the codebases that call it do not need it.

⚠️ Warning

Zod coerces nothing by default and neither does assert. But z.coerce.number() has no direct equivalent — use coerce explicitly, and only at a boundary where the input is genuinely stringly-typed (query strings, form bodies).

4. Add the canary test before you trust any of it.

it('the transformer is running', () => {
  expect(is<{ id: number }>({ id: 'x' })).toBe(false);
});

Without this, step 2 replaces working validation with a runtime failure in the first untransformed build path. The canary finds that during the build rather than in production.

When to stay on Zod#

There is no prize for having one validator.

---

See also: AOT Setup · Refine & Transform · Gotchas