panzacoder/zodvex(opens in a new tab)
Language: TypeScript
A small library for interacting with Zod and Convex together, with Zod Codecs and Zod v4 in mind.
- stars
- 43
- watchers
- 2
- forks
- 1
- open issues
- 17
- Last updated
- 10/07/2026
Language: TypeScript
A small library for interacting with Zod and Convex together, with Zod Codecs and Zod v4 in mind.
Use Zod v4 as your schema language for Convex — define your data once and use it end to end, with automatic function validation and a codec-aware database.
Your Zod schemas are the source of truth — tables, function arguments, and return types defined once, used database to frontend. Two things make that real:
Functions execute your Zod schemas. Wrappers parse declared arguments before the handler and encode declared returns. Zod refinements and codec logic run alongside Convex’s structural checks. Parsing and encoding have different transform/default behavior; current wrappers use synchronous Zod operations.
Modeled database reads run full Zod validation. The wrapped ctx.db parses modeled documents on reads and encodes insert/replace values through the model schema. Codecs live in the schema itself, so handlers can work with Date objects while Convex stores plain values.
What you gain over Convex out of the box:
| Convex | with zodvex | |
|---|---|---|
| Type safety | end-to-end inference | same — driven by your Zod schemas |
| Runtime validation | structural checks | Zod parsing/encoding for declared function schemas and modeled data |
| End-to-end validation | per-function validators | shared Zod definitions across configured client, function, and database paths |
| Client calls | useQuery(api.fn) infers types |
registry args encoded and results parsed, with a configurable decode-error policy |
| Dates & custom types | number timestamps |
Date objects and custom codecs (zx.date(), zx.codec()) |
| Rows on read | as stored | modeled documents parsed & decoded through your schema |
| Row-level rules & audit | build your own | .withRules() / .audit() on ctx.db |
All of it wired once with initZodvex — see Features.
npm install zodvex zod convex convex-helpers
Peer dependencies:
zod (^4.3.6)convex (^1.28.0)convex-helpers (^0.1.104)// convex/models.ts import { z } from 'zod' import { zx, defineZodModel } from 'zodvex' export const EventModel = defineZodModel('events', { title: z.string(), startDate: zx.date(), endDate: zx.date().optional(), location: z.string().optional(), })
defineZodModelis client-safe — you can import it in React.
Two one-time files: build your Convex schema from your models, and create your function builders.
// convex/schema.ts import { defineZodSchema } from 'zodvex/server' import { EventModel } from './models' export default defineZodSchema({ events: EventModel, })
// convex/functions.ts import { initZodvex } from 'zodvex/server' import { query, mutation, action, internalQuery, internalMutation, internalAction, } from './_generated/server' import schema from './schema' export const { zq, zm, za, ziq, zim, zia } = initZodvex(schema, { query, mutation, action, internalQuery, internalMutation, internalAction, })
initZodvex returns builders for all six Convex function types, with ctx.db wrapped for automatic codec encode/decode. You write these two files once and rarely touch them again.
// convex/events.ts import { z } from 'zod' import { zx } from 'zodvex' import { zq, zm } from './functions' import { EventModel } from './models' export const list = zq({ args: {}, returns: zx.docArray(EventModel), handler: async (ctx) => { // Dates come back as Date objects, not numbers return await ctx.db.query('events').collect() }, }) export const create = zm({ args: { title: z.string(), startDate: zx.date(), endDate: zx.date().optional(), location: z.string().optional(), }, returns: zx.id('events'), handler: async (ctx, args) => { // Dates are automatically encoded to timestamps on write return await ctx.db.insert('events', args) }, })
zx.id('events')is a typed ID validator — branding only, no wire transform.zx.date()andzx.codec()are codecs. Details in Features.
See the full quickstart example for a runnable project.
Four primary entry points:
zodvex — Client-safe full-Zod surface. Use in React components and shared code.zodvex/server — Server-only. Use in Convex functions and schema definitions.zodvex/mini — Client-safe zod/mini surface.zodvex/mini/server — Server-only zod/mini surface.Focused entrypoints — zodvex/react, zodvex/client, zodvex/codegen (and their zodvex/mini variants) — back the codegen workflow and are mostly consumed by generated code; see Codegen.
Two deprecated paths remain for migration only:
zodvex/legacy — Deprecated runtime APIs kept only for migration.zodvex/core — Deprecated compatibility alias for zodvex.initZodvex wraps ctx.db so modeled reads decode automatically and insert/replace values encode automatically. Codecs are opt-in per field — schemas without them get the same validation, typed IDs, and correct optional/nullable mapping through the exact same setup.
zx.date() — Date ↔ timestamp codec. Stored as v.float64(), used as Date in handlers.zx.codec(wire, runtime, transforms) — Custom codecs for complex transformations (encryption, serialization, etc.).zx.id('table') — Typed Convex ID validator with GenericId<T> branding — no wire transform (not a codec).Object patches encode supplied fields without validating the merged document or retaining outer object refinements; union patches use full encoding. Unmodeled tables and system access pass through. unwrap() or wrapDb: false bypass wrapping. See the boundary contract.
Guides: Custom Codecs, Date Handling
The same wrapped ctx.db carries per-row rules and audit hooks. Modeled reads and supplied write values use decoded runtime types:
.withRules(ruleCtx, rules) — gate and transform reads and writes per table (read, insert, patch, replace, delete), with an optional deny-by-default policy..audit({ afterRead, afterWrite }) — observe completed inner reads and writes. Composition order determines what audit sees; an audit layer outside .withRules() observes the rules-processed results.Guide: Rules & Audit
The codec wrapper can delegate to an underlying db you supply, so ctx.db-wrapping libraries like convex-helpers/server/triggers compose instead of colliding — codec on top, triggers underneath, real db at the bottom:
const triggers = new Triggers<DataModel>() export const { zq, zm, zim /* ... */ } = initZodvex(schema, server, { underlyingDb: { mutation: (ctx) => triggers.wrapDB(ctx).db }, })
Triggers fire inside zodvex mutations and observe native wire-format writes — which unlocks the aggregate component's table-trigger mode, model-bound cascades, and similar reactive patterns. ctx.db.unwrap() is the matching escape hatch for direct access to the underlying db.
Guide: Builders — Composing with triggers
zodvex includes an optional CLI that generates typed client code:
useZodQuery, useZodMutation, generated into convex/_zodvex/client — import them from there; args are encoded and results decoded automatically
Invalid query arguments throw during render and can be handled by a React error boundary. Pass 'skip' explicitly while required inputs are unavailable; encoding failures no longer silently look like a loading query.encodeArgs, decodeResult for custom client integrationsctx.runQuery / ctx.runMutation encode args + decode results, and ctx.scheduler.runAfter / ctx.scheduler.runAt encode args, via the registry. Pass natural decoded values; zodvex encodes them to wire at the call site.Registry-backed clients and outbound helpers warn and return raw wire data when result parsing fails by default; use onDecodeError: 'throw' to reject failed result parses. Missing registry schemas pass through unchanged.
zodvex generate # one-shot generation zodvex dev # watch mode
When you need codegen: Full-stack apps with React frontends wanting typed client hooks.
When you DON'T: Server-side codec-aware DB works with just initZodvex — no codegen needed.
Guides: Codegen | Example: examples/task-manager/
| Zod Type | Convex Validator |
|---|---|
z.string() |
v.string() |
z.number() |
v.float64() |
z.bigint() |
v.int64() |
z.boolean() |
v.boolean() |
z.null() |
v.null() |
z.array(T) |
v.array(T) |
z.object({...}) |
v.object({...}) |
z.record(T) |
v.record(v.string(), T) |
z.union([...]) |
v.union(...) |
z.literal(x) |
v.literal(x) |
z.enum(['a', 'b']) |
v.union(v.literal('a'), v.literal('b')) ¹ |
z.optional(T) |
v.optional(T) |
z.nullable(T) |
v.union(T, v.null()) |
Note: Native
z.date()is not supported — usezx.date()instead. See Date Handling for details.
¹ Enum unions carry a cosmetically different TypeScript signature than hand-written v.union(v.literal(...)) calls — identical values, runtime validation, and type safety. Details: Mapping Helpers.
For zodvex's own types — zx.id(), zx.date(), zx.codec() — and exactly how they map to Convex validators, see The zx Namespace.
Read the migration guide for what changed in each release and why. Automated renames are available:
npx zodvex migrate ./convex # apply renames npx zodvex migrate ./convex --dry-run # preview changes
zx.id(), zx.date(), zx.codec()initZodvex and legacy builders.withContext(), defineContext, onSuccess.withRules(), .audit() on ctx.dbzx.codec(), decodeDoc/encodeDoczx.date() deep dive.pick(), .fieldszodToConvex, zodToConvexFieldsreturnsAspickShape, safePick{ staged: true } on index, searchIndex, vectorIndexzodvexStream, zodvexMergedStream for fan-out paginationSee the roadmap for where zodvex is heading — deploy-scale performance, ecosystem interop, and the model/namespace evolution.
MIT
Built with ❤️ for the Convex ecosystem. Thanks to convex-helpers for the early foundations.