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);| Zod | zmdb validator | |
|---|---|---|
| Source of truth | the schema value | the TypeScript type |
| Runtime cost of the schema | built at module load | none — a compile-time literal |
| Build step | none | required (AOT setup) |
| Dynamic (runtime-defined) schemas | yes | no |
| Refinements | .refine() | refine |
| Transforms | .transform() | transform |
| Failure mode if misconfigured | n/a | throws: 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 directlytoJsonSchema(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.
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#
- Schemas defined at runtime.
- A toolchain with no zmdb build route — Bun or an esbuild-only pipeline. Metro uses the React Native wrapper.
- Heavy use of Zod's ecosystem (
zod-to-openapi, form libraries binding to Zod schemas).
There is no prize for having one validator.
---
See also: AOT Setup · Refine & Transform · Gotchas