Skip to content

A small library for interacting with Zod and Convex together, with Zod Codecs and Zod v4 in mind.

License

Notifications You must be signed in to change notification settings

panzacoder/zodvex

Repository files navigation

zodvex

Type-safe Convex functions with Zod schemas. Preserve Convex's optional/nullable semantics while leveraging Zod's powerful validation.

Built on top of convex-helpers

Table of Contents

Installation

npm install zodvex zod@^4.1.0 convex convex-helpers

Peer dependencies:

  • zod (^4.1.0 or later)
  • convex (>= 1.27.0)
  • convex-helpers (>= 0.1.104)

Quick Start

1. Set up your builders

Create a convex/util.ts file with reusable builders (copy full example):

// convex/util.ts
import { query, mutation, action } from './_generated/server'
import { zQueryBuilder, zMutationBuilder, zActionBuilder } from 'zodvex'

export const zq = zQueryBuilder(query)
export const zm = zMutationBuilder(mutation)
export const za = zActionBuilder(action)

2. Use builders to create type-safe functions

// convex/users.ts
import { z } from 'zod'
import { zid } from 'zodvex'
import { zq, zm } from './util'
import { Users } from './schemas/users'

export const getUser = zq({
  args: { id: zid('users') },
  returns: Users.zDoc.nullable(),
  handler: async (ctx, { id }) => {
    return await ctx.db.get(id)
  }
})

export const createUser = zm({
  args: Users.shape,
  returns: zid('users'),
  handler: async (ctx, user) => {
    // user is fully typed and validated
    return await ctx.db.insert('users', user)
  }
})

Defining Schemas

Define your Zod schemas as plain objects for best type inference:

import { z } from 'zod'
import { zid } from 'zodvex'

// Plain object shape - recommended
export const userShape = {
  name: z.string(),
  email: z.string().email(),
  age: z.number().optional(),
  avatarUrl: z.string().url().nullable(),
  teamId: zid('teams').optional() // Convex ID reference
}

// Can also use z.object() if preferred
export const User = z.object(userShape)

Table Definitions

Use zodTable as a drop-in replacement for Convex's Table:

// convex/schema.ts
import { z } from 'zod'
import { zodTable, zid } from 'zodvex'

export const Users = zodTable('users', {
  name: z.string(),
  email: z.string().email(),
  age: z.number().optional(),        // → v.optional(v.float64())
  deletedAt: z.date().nullable(),    // → v.union(v.float64(), v.null())
  teamId: zid('teams').optional()
})

// Access the underlying table
Users.table      // Convex table definition
Users.shape      // Original Zod shape
Users.zDoc       // Zod schema with _id and _creationTime
Users.docArray   // z.array(zDoc) for return types

Building Your Schema

Use zodTable().table in your Convex schema:

// convex/schema.ts
import { defineSchema } from 'convex/server'
import { Users } from './tables/users'
import { Teams } from './tables/teams'

export default defineSchema({
  users: Users.table
    .index('by_email', ['email'])
    .index('by_team', ['teamId'])
    .searchIndex('search_name', { searchField: 'name' }),

  teams: Teams.table
    .index('by_created', ['_creationTime'])
})

Defining Functions

Use your builders from util.ts to create type-safe functions:

import { z } from 'zod'
import { zid } from 'zodvex'
import { zq, zm } from './util'
import { Users } from './tables/users'

// Query with return type validation
export const listUsers = zq({
  args: {},
  returns: Users.docArray,
  handler: async (ctx) => {
    return await ctx.db.query('users').collect()
  }
})

// Mutation with Convex ID
export const deleteUser = zm({
  args: { id: zid('users') },
  returns: z.null(),
  handler: async (ctx, { id }) => {
    await ctx.db.delete(id)
    return null
  }
})

// Using the full schema
export const createUser = zm({
  args: Users.shape,
  returns: zid('users'),
  handler: async (ctx, user) => {
    return await ctx.db.insert('users', user)
  }
})

Working with Subsets

Pick a subset of fields for focused operations:

import { z } from 'zod'
import { zid } from 'zodvex'
import { zm } from './util'
import { Users, zUsers } from './tables/users'

// Use Zod's .pick() to select fields
const UpdateFields = zUsers.pick({
  firstName: true,
  lastName: true,
  email: true
})

export const updateUserProfile = zm({
  args: {
    id: zid('users'),
    ...UpdateFields.shape
  },
  handler: async (ctx, { id, ...fields }) => {
    await ctx.db.patch(id, fields)
  }
})

// Or inline for simple cases
export const updateUserName = zm({
  args: {
    id: zid('users'),
    name: z.string()
  },
  handler: async (ctx, { id, name }) => {
    await ctx.db.patch(id, { name })
  }
})

Form Validation

Use your schemas with form libraries like react-hook-form:

import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { useMutation } from 'convex/react'
import { api } from '../convex/_generated/api'
import { Users } from '../convex/tables/users'

// Create form schema from your table schema
const CreateUserForm = z.object(Users.shape)
type CreateUserForm = z.infer<typeof CreateUserForm>

function UserForm() {
  const createUser = useMutation(api.users.createUser)

  const { register, handleSubmit, formState: { errors } } = useForm<CreateUserForm>({
    resolver: zodResolver(CreateUserForm)
  })

  const onSubmit = async (data: CreateUserForm) => {
    await createUser(data)
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('name')} />
      {errors.name && <span>{errors.name.message}</span>}

      <input {...register('email')} />
      {errors.email && <span>{errors.email.message}</span>}

      <button type="submit">Create User</button>
    </form>
  )
}

API Reference

Builders

Basic builders - Create type-safe functions without auth:

zQueryBuilder(query)      // Creates query builder
zMutationBuilder(mutation) // Creates mutation builder
zActionBuilder(action)     // Creates action builder

Custom builders - Add auth or custom context:

import { customCtx } from 'zodvex'

const authQuery = zCustomQueryBuilder(
  query,
  customCtx(async (ctx) => {
    const user = await getUserOrThrow(ctx)
    return { user }
  })
)

// Use with automatic context injection
export const getMyProfile = authQuery({
  args: {},
  returns: Users.zDoc.nullable(),
  handler: async (ctx) => {
    if (!ctx.user) return null
    return ctx.db.get(ctx.user._id)
  }
})

Mapping Helpers

import { zodToConvex, zodToConvexFields } from 'zodvex'

// Convert single Zod type to Convex validator
const validator = zodToConvex(z.string().optional())
// → v.optional(v.string())

// Convert object shape to Convex field validators
const fields = zodToConvexFields({
  name: z.string(),
  age: z.number().nullable()
})
// → { name: v.string(), age: v.union(v.float64(), v.null()) }

Codecs

Convert between Zod-shaped data and Convex-safe JSON:

import { convexCodec } from 'zodvex'

const UserSchema = z.object({
  name: z.string(),
  birthday: z.date().optional()
})

const codec = convexCodec(UserSchema)

// Encode: Date → timestamp, omit undefined
const encoded = codec.encode({
  name: 'Alice',
  birthday: new Date('1990-01-01')
})
// → { name: 'Alice', birthday: 631152000000 }

// Decode: timestamp → Date
const decoded = codec.decode(encoded)
// → { name: 'Alice', birthday: Date('1990-01-01') }

Supported Types

Zod Type Convex Validator
z.string() v.string()
z.number() v.float64()
z.bigint() v.int64()
z.boolean() v.boolean()
z.date() v.float64() (timestamp)
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([...]) v.union(literals...)
z.optional(T) v.optional(T)
z.nullable(T) v.union(T, v.null())

Convex IDs:

import { zid } from 'zodvex'

zid('tableName')           // → v.id('tableName')
zid('tableName').optional() // → v.optional(v.id('tableName'))

Advanced Usage

Custom Context Builders

Create builders with injected auth, permissions, or other context:

import { zCustomQueryBuilder, zCustomMutationBuilder, customCtx } from 'zodvex'
import { query, mutation } from './_generated/server'

// Add user to all queries
export const authQuery = zCustomQueryBuilder(
  query,
  customCtx(async (ctx) => {
    const user = await getUserOrThrow(ctx)
    return { user }
  })
)

// Add user + permissions to mutations
export const authMutation = zCustomMutationBuilder(
  mutation,
  customCtx(async (ctx) => {
    const user = await getUserOrThrow(ctx)
    const permissions = await getPermissions(ctx, user)
    return { user, permissions }
  })
)

// Use them
export const updateProfile = authMutation({
  args: { name: z.string() },
  returns: z.null(),
  handler: async (ctx, { name }) => {
    // ctx.user and ctx.permissions are available
    if (!ctx.permissions.canEdit) {
      throw new Error('No permission')
    }
    await ctx.db.patch(ctx.user._id, { name })
    return null
  }
})

Date Handling

Dates are automatically converted to timestamps:

const eventShape = {
  title: z.string(),
  startDate: z.date(),
  endDate: z.date().nullable()
}

export const Events = zodTable('events', eventShape)

export const createEvent = zm({
  args: eventShape,
  handler: async (ctx, event) => {
    // event.startDate is a Date object
    // Automatically converted to timestamp for storage
    return await ctx.db.insert('events', event)
  }
})

Return Type Helpers

import { returnsAs } from 'zodvex'

export const listUsers = zq({
  args: {},
  handler: async (ctx) => {
    const rows = await ctx.db.query('users').collect()
    // Use returnsAs for type hint in tricky inference spots
    return returnsAs<typeof Users.docArray>()(rows)
  },
  returns: Users.docArray
})

Working with Large Schemas

zodvex provides pickShape and safePick helpers as alternatives to Zod's .pick():

import { pickShape, safePick } from 'zodvex'

// Standard Zod .pick() works great for most schemas
const UserUpdate = User.pick({ email: true, firstName: true, lastName: true })

// If you hit TypeScript instantiation depth limits (rare, 100+ fields),
// use pickShape or safePick:
const userShape = pickShape(User, ['email', 'firstName', 'lastName'])
const UserUpdate = z.object(userShape)

// Or use safePick (convenience wrapper that does the same thing)
const UserUpdate = safePick(User, { email: true, firstName: true, lastName: true })

Why zodvex?

  • Correct optional/nullable semantics - Preserves Convex's distinction
    • .optional()v.optional(T) (field can be omitted)
    • .nullable()v.union(T, v.null()) (required but can be null)
    • Both → v.optional(v.union(T, v.null()))
  • Superior type safety - Builders provide better type inference than wrapper functions
  • Date handling - Automatic Date ↔ timestamp conversion
  • End-to-end validation - Same schema from database to frontend forms

Compatibility

  • Zod: ^4.1.0 or later
  • Convex: >= 1.27.0
  • convex-helpers: >= 0.1.104
  • TypeScript: Full type inference support

License

MIT


Built with ❤️ on top of convex-helpers

About

A small library for interacting with Zod and Convex together, with Zod Codecs and Zod v4 in mind.

Resources

License

Contributing

Stars

Watchers

Forks

Sponsor this project

 

Packages

No packages published

Contributors 2

  •  
  •