@libar-dev/zod-convex-tables
v0.1.0
Published
Table and schema utilities for Convex with Zod v4
Downloads
10
Maintainers
Readme
@libar-dev/zod-convex-tables
Table definition helpers and schema manipulation utilities for Convex with Zod v4.
Features
- Table Definition Helpers -
zodTablefor quick table setup with Convex validators - Document Type Schemas - Automatic
_idand_creationTimefield addition - Type-Safe Pick/Omit - Extract schema subsets with full type preservation
- Zero Type Depth Issues - Avoids Zod's recursive type limitations
Installation
npm install @libar-dev/zod-convex-tables zod@^4.1.0Peer Dependencies:
convex>= 1.30.0 < 2.0.0convex-helpers>= 0.1.111zod^4.1.0
Quick Start
Table Definition
import { zodTable } from '@libar-dev/zod-convex-tables';
import { z } from 'zod';
// Create table with raw shape
export const Users = zodTable('users', {
name: z.string(),
email: z.string().email(),
role: z.enum(['admin', 'user', 'guest']),
});
// Use in schema.ts:
export default defineSchema({
users: Users.table,
});
// Type-safe document:
type User = z.infer<typeof Users.zDoc>;
// { name: string; email: string; role: 'admin' | 'user' | 'guest'; _id: Id<'users'>; _creationTime: number }Schema Picking
Extract subsets of schemas with full type inference:
import { safePick, safeOmit } from '@libar-dev/zod-convex-tables';
const UserSchema = z.object({
id: z.string(),
email: z.string().email(),
firstName: z.string(),
lastName: z.string(),
password: z.string(),
});
// Pick specific fields - type-safe!
const PublicUserSchema = safePick(UserSchema, ['email', 'firstName', 'lastName']);
type PublicUser = z.infer<typeof PublicUserSchema>;
// { email: string; firstName: string; lastName: string }
// Omit sensitive fields - type-safe!
const SafeUserSchema = safeOmit(UserSchema, ['password']);
type SafeUser = z.infer<typeof SafeUserSchema>;
// { id: string; email: string; firstName: string; lastName: string }API Reference
zodTable(name, shape)
Creates a Convex table definition from a Zod schema shape.
import { zodTable } from '@libar-dev/zod-convex-tables';
import { zid } from '@libar-dev/zod-convex-ids';
export const Posts = zodTable('posts', {
title: z.string(),
content: z.string(),
authorId: zid('users'),
published: z.boolean(),
});
// Properties available:
Posts.table; // TableDefinition for schema.ts
Posts.shape; // Raw Zod shape { title: z.string(), ... }
Posts.zDoc; // Schema with _id and _creationTime
Posts.name; // 'posts'Query Return Types:
export const getPost = query({
args: { id: v.id('posts') },
handler: async (ctx, { id }) => ctx.db.get(id),
returns: Posts.zDoc.nullable(),
});
export const listPosts = query({
handler: async (ctx) => ctx.db.query('posts').collect(),
returns: z.array(Posts.zDoc),
});zodDoc(tableName, schema)
Adds document fields (_id, _creationTime) to any Zod object schema:
const UserSchema = z.object({
name: z.string(),
email: z.string().email(),
});
const UserDocSchema = zodDoc('users', UserSchema);
// Adds _id: zid('users') and _creationTime: z.number()
type UserDoc = z.infer<typeof UserDocSchema>;
// { name: string; email: string; _id: Id<'users'>; _creationTime: number }zodDocOrNull(tableName, schema)
Creates a nullable document schema (useful for query returns):
const MaybeUserSchema = zodDocOrNull('users', UserSchema);
// Same as zodDoc but with | nullpickShape(schemaOrShape, keys)
Extracts raw shape for function arguments:
import { pickShape } from '@libar-dev/zod-convex-tables';
import { zodToConvexFields } from '@libar-dev/zod-convex-core';
const loginShape = pickShape(UserSchema, ['email', 'password']);
export const login = mutation({
args: zodToConvexFields(loginShape),
handler: async (ctx, { email, password }) => {
// Type-safe: email and password are inferred
},
});safePick(schema, keys)
Creates new schema with selected fields (type-safe):
import { safePick } from '@libar-dev/zod-convex-tables';
const UpdateSchema = safePick(UserSchema, ['firstName', 'lastName', 'email']);
type UpdateData = z.infer<typeof UpdateSchema>;
// { firstName: string; lastName: string; email: string }safeOmit(schema, keys)
Creates schema without specified fields (type-safe):
import { safeOmit } from '@libar-dev/zod-convex-tables';
const PublicUserSchema = safeOmit(UserSchema, ['password', 'internalId']);
type PublicUser = z.infer<typeof PublicUserSchema>;
// All fields except password and internalIdWhy Safe Pick/Omit?
Zod's built-in pick() and omit() can trigger TypeScript's type depth limits with complex schemas. Our safe versions avoid this:
// ❌ Can cause: "Type instantiation is excessively deep"
const Picked = ComplexSchema.pick({ field1: true, field2: true });
// ✅ Works with any schema complexity
const Picked = safePick(ComplexSchema, ['field1', 'field2']);Usage Patterns
Table-Centric Development
// convex/schema/users.ts
export const Users = zodTable('users', {
email: z.string().email(),
name: z.string(),
bio: z.string().optional(),
verified: z.boolean(),
});
// convex/users.ts
import { Users } from './schema/users';
export const createUser = mutation({
args: zodToConvexFields(Users.shape),
handler: async (ctx, userData) => {
return ctx.db.insert('users', userData);
},
});
export const getUser = query({
args: { id: v.id('users') },
handler: async (ctx, { id }) => ctx.db.get(id),
returns: Users.zDoc.nullable(),
});Partial Updates
const Users = zodTable('users', {
email: z.string().email(),
name: z.string(),
bio: z.string().optional(),
});
// Partial update schema
const UpdateUserSchema = safePick(z.object(Users.shape), ['name', 'bio']);
export const updateProfile = mutation({
args: {
id: v.id('users'),
...zodToConvexFields(UpdateUserSchema.partial().shape),
},
handler: async (ctx, { id, ...updates }) => {
await ctx.db.patch(id, updates);
},
});With Authentication Context
const Users = zodTable('users', {
email: z.string().email(),
role: z.enum(['admin', 'user']),
profile: z.object({
name: z.string(),
bio: z.string().optional(),
}),
});
// Public info only
const PublicUserSchema = safePick(z.object(Users.shape), ['profile']);
export const getPublicProfile = query({
args: { id: v.id('users') },
handler: async (ctx, { id }) => {
const user = await ctx.db.get(id);
return user ? PublicUserSchema.parse(user) : null;
},
returns: PublicUserSchema.nullable(),
});Type Safety
All utilities maintain full TypeScript type inference:
const UserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string(),
password: z.string(),
});
// Full type inference
const PublicSchema = safeOmit(UserSchema, ['password']);
type PublicUser = z.infer<typeof PublicSchema>;
// { id: string; name: string; email: string }
// ^? TypeScript knows exactly which fields are present
// Compile-time error for invalid keys
const Invalid = safePick(UserSchema, ['nonexistent']);
// ^^^^^^^^^^^
// Error: Type '"nonexistent"' is not assignable to type 'keyof ...'Migration from convex-helpers
If you're migrating from convex-helpers table utilities, here's how to adapt your code.
Table Definitions
convex-helpers approach:
// With convex-helpers
import { zodToConvex } from 'convex-helpers/server/zod';
import { defineSchema, defineTable } from 'convex/server';
import { z } from 'zod/v3'; // Note: Zod v3
const UserSchema = z.object({
name: z.string(),
email: z.string().email(),
});
export default defineSchema({
users: defineTable(zodToConvex(UserSchema))
.index('by_email', ['email']),
});@libar-dev/zod-convex-tables approach:
// With zod-convex-tables
import { zodTable } from '@libar-dev/zod-convex-tables';
import { defineSchema } from 'convex/server';
import { z } from 'zod'; // Zod v4 native
export const Users = zodTable('users', {
name: z.string(),
email: z.string().email(),
});
export default defineSchema({
users: Users.table
.index('by_email', ['email']),
});
// Bonus: Get document type with _id and _creationTime
type UserDoc = z.infer<typeof Users.zDoc>;Key Differences
| Feature | convex-helpers | @libar-dev/zod-convex-tables |
|---------|---------------|------------------------------|
| Zod Version | v3 (via zod/v3) | v4 native |
| Table Definition | Manual defineTable() | zodTable() helper |
| Document Schema | Manual composition | Automatic zDoc property |
| Pick/Omit | Uses Zod's .pick() | Type-safe safePick() / safeOmit() |
| Type Depth Issues | Can hit TS2589 | Avoided by design |
Pick/Omit Migration
convex-helpers:
// Can cause TS2589 with complex schemas
const PublicUser = UserSchema.pick({ email: true, name: true });
const SafeUser = UserSchema.omit({ password: true });zod-convex-tables:
// Type-safe and TS2589-resistant
import { safePick, safeOmit } from '@libar-dev/zod-convex-tables';
const PublicUser = safePick(UserSchema, ['email', 'name']);
const SafeUser = safeOmit(UserSchema, ['password']);Step-by-Step Migration
Update dependencies:
npm install @libar-dev/zod-convex-tables zod@^4 # convex-helpers is still required (peer dependency used by zodTable)Update imports:
// Before import { zodToConvex } from 'convex-helpers/server/zod'; import { z } from 'zod/v3'; // After import { zodTable } from '@libar-dev/zod-convex-tables'; import { z } from 'zod';Replace table definitions:
// Before export default defineSchema({ users: defineTable(zodToConvex(UserSchema)), }); // After export const Users = zodTable('users', { name: z.string(), email: z.string().email(), }); export default defineSchema({ users: Users.table, });Replace pick/omit:
// Before const Partial = Schema.pick({ field1: true }); // After import { safePick } from '@libar-dev/zod-convex-tables'; const Partial = safePick(Schema, ['field1']);
Related Packages
@libar-dev/zod-convex-core- Core conversion engine@libar-dev/zod-convex-ids- Type-safe ID validation@libar-dev/zod-convex-codecs- Bidirectional codecs
License
MIT License - See LICENSE file for details.
Copyright (c) 2025 Libar AI
