zodvex 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,271 +4,316 @@
4
4
 
5
5
  Type-safe Convex functions with Zod schemas. Preserve Convex's optional/nullable semantics while leveraging Zod's powerful validation.
6
6
 
7
- > Heavily inspired by [convex-helpers](https://github.com/get-convex/convex-helpers). Built on top of their excellent utilities.
7
+ > Built on top of [convex-helpers](https://github.com/get-convex/convex-helpers)
8
8
 
9
- ## 📦 Installation
9
+ ## Table of Contents
10
10
 
11
- ```bash
12
- npm install zodvex zod convex convex-helpers
13
- ```
14
-
15
- ```bash
16
- pnpm add zodvex zod convex convex-helpers
17
- ```
11
+ - [Installation](#installation)
12
+ - [Quick Start](#quick-start)
13
+ - [Defining Schemas](#defining-schemas)
14
+ - [Table Definitions](#table-definitions)
15
+ - [Building Your Schema](#building-your-schema)
16
+ - [Defining Functions](#defining-functions)
17
+ - [Working with Subsets](#working-with-subsets)
18
+ - [Form Validation](#form-validation)
19
+ - [API Reference](#api-reference)
20
+ - [Advanced Usage](#advanced-usage)
18
21
 
19
- ```bash
20
- yarn add zodvex zod convex convex-helpers
21
- ```
22
+ ## Installation
22
23
 
23
24
  ```bash
24
- bun add zodvex zod convex convex-helpers
25
+ npm install zodvex zod@^4.1.0 convex convex-helpers
25
26
  ```
26
27
 
27
28
  **Peer dependencies:**
28
29
 
29
- - `zod` (v4)
30
- - `convex` (>= 1.27)
31
- - `convex-helpers` (>= 0.1.101-alpha.1)
30
+ - `zod` (^4.1.0 or later)
31
+ - `convex` (>= 1.27.0)
32
+ - `convex-helpers` (>= 0.1.104)
33
+
34
+ ## Quick Start
32
35
 
33
- ## 🚀 Quick Start
36
+ ### 1. Set up your builders
34
37
 
35
- ### 1. Define a Zod schema and create type-safe Convex functions
38
+ Create a `convex/util.ts` file with reusable builders ([copy full example](./examples/queries.ts)):
39
+
40
+ ```ts
41
+ // convex/util.ts
42
+ import { query, mutation, action } from './_generated/server'
43
+ import { zQueryBuilder, zMutationBuilder, zActionBuilder } from 'zodvex'
44
+
45
+ export const zq = zQueryBuilder(query)
46
+ export const zm = zMutationBuilder(mutation)
47
+ export const za = zActionBuilder(action)
48
+ ```
49
+
50
+ ### 2. Use builders to create type-safe functions
36
51
 
37
52
  ```ts
38
53
  // convex/users.ts
39
- import { z } from "zod";
40
- import { query, mutation } from "./_generated/server";
41
- import { zQuery, zMutation } from "zodvex";
54
+ import { z } from 'zod'
55
+ import { zid } from 'zodvex'
56
+ import { zq, zm } from './util'
57
+ import { Users } from './schemas/users'
58
+
59
+ export const getUser = zq({
60
+ args: { id: zid('users') },
61
+ returns: Users.zDoc.nullable(),
62
+ handler: async (ctx, { id }) => {
63
+ return await ctx.db.get(id)
64
+ }
65
+ })
66
+
67
+ export const createUser = zm({
68
+ args: Users.shape,
69
+ returns: zid('users'),
70
+ handler: async (ctx, user) => {
71
+ // user is fully typed and validated
72
+ return await ctx.db.insert('users', user)
73
+ }
74
+ })
75
+ ```
76
+
77
+ ## Defining Schemas
42
78
 
43
- // Define your schema
44
- const UserInput = z.object({
45
- name: z.string().min(1),
79
+ Define your Zod schemas as plain objects for best type inference:
80
+
81
+ ```ts
82
+ import { z } from 'zod'
83
+ import { zid } from 'zodvex'
84
+
85
+ // Plain object shape - recommended
86
+ export const userShape = {
87
+ name: z.string(),
46
88
  email: z.string().email(),
47
89
  age: z.number().optional(),
48
- });
90
+ avatarUrl: z.string().url().nullable(),
91
+ teamId: zid('teams').optional() // Convex ID reference
92
+ }
49
93
 
50
- // Create type-safe queries
51
- export const getUser = zQuery(
52
- query,
53
- { id: z.string() },
54
- async (ctx, { id }) => {
55
- return await ctx.db.get(id);
56
- },
57
- );
58
-
59
- // Create type-safe mutations
60
- export const createUser = zMutation(mutation, UserInput, async (ctx, user) => {
61
- // `user` is fully typed and validated!
62
- return await ctx.db.insert("users", user);
63
- });
94
+ // Can also use z.object() if preferred
95
+ export const User = z.object(userShape)
64
96
  ```
65
97
 
66
- ### 2. Use with Convex schemas
98
+ ## Table Definitions
99
+
100
+ Use `zodTable` as a drop-in replacement for Convex's `Table`:
67
101
 
68
102
  ```ts
69
103
  // convex/schema.ts
70
- import { defineSchema } from "convex/server";
71
- import { z } from "zod";
72
- import { zodTable } from "zodvex";
104
+ import { z } from 'zod'
105
+ import { zodTable, zid } from 'zodvex'
73
106
 
74
- // Define a table from a plain object shape
75
- const usersShape = {
107
+ export const Users = zodTable('users', {
76
108
  name: z.string(),
77
109
  email: z.string().email(),
78
110
  age: z.number().optional(), // → v.optional(v.float64())
79
111
  deletedAt: z.date().nullable(), // → v.union(v.float64(), v.null())
80
- };
81
-
82
- export const Users = zodTable("users", usersShape);
83
-
84
- // Use in your Convex schema
85
- export default defineSchema({
86
- users: Users.table.index("by_email", ["email"]).searchIndex("search_name", {
87
- searchField: "name",
88
- }),
89
- });
112
+ teamId: zid('teams').optional()
113
+ })
114
+
115
+ // Access the underlying table
116
+ Users.table // Convex table definition
117
+ Users.shape // Original Zod shape
118
+ Users.zDoc // Zod schema with _id and _creationTime
119
+ Users.docArray // z.array(zDoc) for return types
90
120
  ```
91
121
 
92
- ## Features
122
+ ## Building Your Schema
93
123
 
94
- ### Why zodvex?
124
+ Use `zodTable().table` in your Convex schema:
95
125
 
96
- - **Correct optional/nullable semantics**: Preserves Convex's distinction between optional and nullable fields
97
- - `.optional()` → `v.optional(T)` (field can be omitted)
98
- - `.nullable()` `v.union(T, v.null())` (field required but can be null)
99
- - `.optional().nullable()` `v.optional(v.union(T, v.null()))` (can be omitted or null)
100
- - **Type-safe function wrappers**: Full TypeScript inference for inputs and outputs
101
- - **Date handling**: Automatic conversion between JavaScript `Date` objects and Convex timestamps
102
- - **CRUD scaffolding**: Generate complete CRUD operations from a single schema
126
+ ```ts
127
+ // convex/schema.ts
128
+ import { defineSchema } from 'convex/server'
129
+ import { Users } from './tables/users'
130
+ import { Teams } from './tables/teams'
103
131
 
104
- ### Supported Types
132
+ export default defineSchema({
133
+ users: Users.table
134
+ .index('by_email', ['email'])
135
+ .index('by_team', ['teamId'])
136
+ .searchIndex('search_name', { searchField: 'name' }),
105
137
 
106
- This library intentionally supports only Zod types that map cleanly to Convex validators. Anything outside this list is unsupported (or best-effort with caveats).
138
+ teams: Teams.table.index('by_created', ['_creationTime'])
139
+ })
140
+ ```
107
141
 
108
- - Primitives: `z.string()`, `z.number()` → `v.float64()`, `z.boolean()`, `z.null()`
109
- - Date: `z.date()` → `v.float64()` (timestamp), encoded/decoded automatically
110
- - Literals: `z.literal(x)` → `v.literal(x)`
111
- - Enums: `z.enum([...])` → `v.union(v.literal(...))`
112
- - Arrays: `z.array(T)` → `v.array(T')`
113
- - Objects: `z.object({...})` → `v.object({...})`
114
- - Records: `z.record(T)` or `z.record(z.string(), T)` → `v.record(v.string(), T')` (string keys only)
115
- - Unions: `z.union([...])` (members must be supported types)
116
- - Optional/nullable wrappers: `.optional()` → `v.optional(T')`, `.nullable()` → `v.union(T', v.null())`
117
- - Convex IDs: `zid('table')` → `v.id('table')`
142
+ ## Defining Functions
118
143
 
119
- Unsupported or partial (explicitly out-of-scope):
144
+ Use your builders from `util.ts` to create type-safe functions:
120
145
 
121
- - Tuples (fixed-length) — Convex has no fixed-length tuple validator; mapping would be lossy
122
- - Intersections combining object shapes widens overlapping fields; not equivalent to true intersection
123
- - Transforms/effects/pipelines not used for validator mapping; if you use them, conversions happen at runtime only
124
- - Lazy, function, promise, set, map, symbol, branded/readonly, NaN/catch — unsupported
146
+ ```ts
147
+ import { z } from 'zod'
148
+ import { zid } from 'zodvex'
149
+ import { zq, zm } from './util'
150
+ import { Users } from './tables/users'
125
151
 
126
- Note: `z.bigint()` `v.int64()` is recognized for validator mapping but currently has no special runtime encode/decode; prefer numbers where possible.
152
+ // Query with return type validation
153
+ export const listUsers = zq({
154
+ args: {},
155
+ returns: Users.docArray,
156
+ handler: async (ctx) => {
157
+ return await ctx.db.query('users').collect()
158
+ }
159
+ })
127
160
 
128
- ## 📚 API Reference
161
+ // Mutation with Convex ID
162
+ export const deleteUser = zm({
163
+ args: { id: zid('users') },
164
+ returns: z.null(),
165
+ handler: async (ctx, { id }) => {
166
+ await ctx.db.delete(id)
167
+ return null
168
+ }
169
+ })
170
+
171
+ // Using the full schema
172
+ export const createUser = zm({
173
+ args: Users.shape,
174
+ returns: zid('users'),
175
+ handler: async (ctx, user) => {
176
+ return await ctx.db.insert('users', user)
177
+ }
178
+ })
179
+ ```
129
180
 
130
- ### Mapping Helpers
181
+ ## Working with Subsets
131
182
 
132
- Convert Zod schemas to Convex validators:
183
+ Pick a subset of fields for focused operations:
133
184
 
134
185
  ```ts
135
- import { z } from "zod";
136
- import { zodToConvex, zodToConvexFields, pickShape, safePick } from "zodvex";
186
+ import { z } from 'zod'
187
+ import { zid } from 'zodvex'
188
+ import { zm } from './util'
189
+ import { Users, zUsers } from './tables/users'
190
+
191
+ // Use Zod's .pick() to select fields
192
+ const UpdateFields = zUsers.pick({
193
+ firstName: true,
194
+ lastName: true,
195
+ email: true
196
+ })
197
+
198
+ export const updateUserProfile = zm({
199
+ args: {
200
+ id: zid('users'),
201
+ ...UpdateFields.shape
202
+ },
203
+ handler: async (ctx, { id, ...fields }) => {
204
+ await ctx.db.patch(id, fields)
205
+ }
206
+ })
137
207
 
138
- // Convert a single Zod type to a Convex validator
139
- const validator = zodToConvex(z.string().optional());
140
- // → v.optional(v.string())
208
+ // Or inline for simple cases
209
+ export const updateUserName = zm({
210
+ args: {
211
+ id: zid('users'),
212
+ name: z.string()
213
+ },
214
+ handler: async (ctx, { id, name }) => {
215
+ await ctx.db.patch(id, { name })
216
+ }
217
+ })
218
+ ```
141
219
 
142
- // Convert a Zod object shape to Convex field validators
143
- const fields = zodToConvexFields({
144
- name: z.string(),
145
- age: z.number().nullable(),
146
- });
147
- // → { name: v.string(), age: v.union(v.float64(), v.null()) }
220
+ ## Form Validation
148
221
 
149
- // Safe picking from large schemas
150
- // IMPORTANT: Avoid Zod's .pick() on large schemas (30+ fields) as it can cause
151
- // TypeScript instantiation depth errors. Use pickShape/safePick instead.
222
+ Use your schemas with form libraries like react-hook-form:
152
223
 
153
- const User = z.object({
154
- id: z.string(),
155
- email: z.string().email(),
156
- firstName: z.string().optional(),
157
- lastName: z.string().optional(),
158
- createdAt: z.date().nullable(),
159
- });
160
-
161
- // ❌ DON'T use Zod's .pick() on large schemas (type instantiation issues)
162
- // const Clerk = User.pick({ email: true, firstName: true, lastName: true });
163
-
164
- // ✅ DO use pickShape to extract a plain object shape
165
- const clerkShape = pickShape(User, ["email", "firstName", "lastName"]);
166
- // Use directly in wrappers or build a new z.object()
167
- const Clerk = z.object(clerkShape);
168
-
169
- // Alternative: safePick builds the z.object() for you
170
- const ClerkAlt = safePick(User, { email: true, firstName: true, lastName: true });
171
- // Both Clerk and ClerkAlt are equivalent - use whichever is more readable
172
- ```
224
+ ```tsx
225
+ import { useForm } from 'react-hook-form'
226
+ import { zodResolver } from '@hookform/resolvers/zod'
227
+ import { z } from 'zod'
228
+ import { useMutation } from 'convex/react'
229
+ import { api } from '../convex/_generated/api'
230
+ import { Users } from '../convex/tables/users'
173
231
 
174
- ### Function Wrappers
232
+ // Create form schema from your table schema
233
+ const CreateUserForm = z.object(Users.shape)
234
+ type CreateUserForm = z.infer<typeof CreateUserForm>
175
235
 
176
- Type-safe wrappers for Convex functions:
236
+ function UserForm() {
237
+ const createUser = useMutation(api.users.createUser)
177
238
 
178
- ```ts
179
- import { z } from "zod";
180
- import { query, mutation, action } from "./_generated/server";
181
- import { zQuery, zMutation, zAction } from "zodvex";
239
+ const {
240
+ register,
241
+ handleSubmit,
242
+ formState: { errors }
243
+ } = useForm<CreateUserForm>({
244
+ resolver: zodResolver(CreateUserForm)
245
+ })
182
246
 
183
- // Query with validated input and optional return validation
184
- export const getById = zQuery(
185
- query,
186
- { id: z.string() },
187
- async (ctx, { id }) => ctx.db.get(id),
188
- {
189
- returns: z.object({
190
- name: z.string(),
191
- createdAt: z.date(),
192
- }),
193
- },
194
- );
247
+ const onSubmit = async (data: CreateUserForm) => {
248
+ await createUser(data)
249
+ }
195
250
 
196
- // Mutation with Zod object
197
- export const updateUser = zMutation(
198
- mutation,
199
- z.object({
200
- id: z.string(),
201
- name: z.string().min(1),
202
- }),
203
- async (ctx, { id, name }) => ctx.db.patch(id, { name }),
204
- );
205
-
206
- // Action with single value (normalizes to { value })
207
- export const sendEmail = zAction(
208
- action,
209
- z.string().email(),
210
- async (ctx, { value: email }) => {
211
- // Send email to the address
212
- },
213
- );
251
+ return (
252
+ <form onSubmit={handleSubmit(onSubmit)}>
253
+ <input {...register('name')} />
254
+ {errors.name && <span>{errors.name.message}</span>}
214
255
 
215
- // Internal functions also supported
216
- import { zInternalQuery, zInternalMutation, zInternalAction } from "zodvex";
256
+ <input {...register('email')} />
257
+ {errors.email && <span>{errors.email.message}</span>}
217
258
 
218
- // Handler return typing
219
- // When `returns` is provided, your handler must return z.input<typeof returns>
220
- // (domain values like Date). zodvex validates and encodes to Convex Values for you.
259
+ <button type="submit">Create User</button>
260
+ </form>
261
+ )
262
+ }
221
263
  ```
222
264
 
223
- #### Return Typing + Helpers
265
+ ## API Reference
266
+
267
+ ### Builders
224
268
 
225
- Handlers should return domain values shaped by your `returns` schema. zodvex validates and encodes them to Convex Values for you. For common patterns:
269
+ **Basic builders** - Create type-safe functions without auth:
226
270
 
227
271
  ```ts
228
- import { z } from "zod";
229
- import { zQuery, zodTable, returnsAs } from "zodvex";
230
- import { query } from "./_generated/server";
272
+ zQueryBuilder(query) // Creates query builder
273
+ zMutationBuilder(mutation) // Creates mutation builder
274
+ zActionBuilder(action) // Creates action builder
275
+ ```
231
276
 
232
- // Table with doc helpers
233
- export const Users = zodTable("users", {
234
- name: z.string(),
235
- createdAt: z.date()
236
- });
277
+ **Custom builders** - Add auth or custom context:
237
278
 
238
- // 1) Return full docs, strongly typed
239
- export const listUsers = zQuery(
240
- query,
241
- {},
242
- async (ctx) => {
243
- const rows = await ctx.db.query("users").collect();
244
- // No cast needed — handler returns domain values; wrapper encodes to Convex Values
245
- return rows;
246
- },
247
- { returns: Users.docArray }
248
- );
279
+ ```ts
280
+ import { type QueryCtx } from './_generated/server'
281
+ import { customCtx } from 'zodvex'
249
282
 
250
- // 2) Return a custom shape (with Dates)
251
- export const createdBounds = zQuery(
283
+ const authQuery = zCustomQueryBuilder(
252
284
  query,
253
- {},
254
- async (ctx) => {
255
- const first = await ctx.db.query("users").order("asc").first();
256
- const last = await ctx.db.query("users").order("desc").first();
257
- return { first: first?.createdAt ?? null, last: last?.createdAt ?? null };
258
- },
259
- { returns: z.object({ first: z.date().nullable(), last: z.date().nullable() }) }
260
- );
285
+ customCtx(async (ctx: QueryCtx) => {
286
+ const user = await getUserOrThrow(ctx)
287
+ return { user }
288
+ })
289
+ )
261
290
 
262
- // 3) In tricky inference spots, use a typed identity
263
- export const listUsersOk = zQuery(
264
- query,
265
- {},
266
- async (ctx) => {
267
- const rows = await ctx.db.query("users").collect();
268
- return returnsAs<typeof Users.docArray>()(rows);
269
- },
270
- { returns: Users.docArray }
271
- );
291
+ // Use with automatic context injection
292
+ export const getMyProfile = authQuery({
293
+ args: {},
294
+ returns: Users.zDoc.nullable(),
295
+ handler: async (ctx) => {
296
+ if (!ctx.user) return null
297
+ return ctx.db.get(ctx.user._id)
298
+ }
299
+ })
300
+ ```
301
+
302
+ ### Mapping Helpers
303
+
304
+ ```ts
305
+ import { zodToConvex, zodToConvexFields } from 'zodvex'
306
+
307
+ // Convert single Zod type to Convex validator
308
+ const validator = zodToConvex(z.string().optional())
309
+ // → v.optional(v.string())
310
+
311
+ // Convert object shape to Convex field validators
312
+ const fields = zodToConvexFields({
313
+ name: z.string(),
314
+ age: z.number().nullable()
315
+ })
316
+ // → { name: v.string(), age: v.union(v.float64(), v.null()) }
272
317
  ```
273
318
 
274
319
  ### Codecs
@@ -276,272 +321,204 @@ export const listUsersOk = zQuery(
276
321
  Convert between Zod-shaped data and Convex-safe JSON:
277
322
 
278
323
  ```ts
279
- import { convexCodec } from "zodvex";
280
- import { z } from "zod";
324
+ import { convexCodec } from 'zodvex'
281
325
 
282
326
  const UserSchema = z.object({
283
327
  name: z.string(),
284
- birthday: z.date().optional(),
285
- metadata: z.record(z.string()),
286
- });
287
-
288
- const codec = convexCodec(UserSchema);
328
+ birthday: z.date().optional()
329
+ })
289
330
 
290
- // Get Convex validators for table definition
291
- const validators = codec.toConvexSchema();
331
+ const codec = convexCodec(UserSchema)
292
332
 
293
- // Encode: Zod data → Convex JSON (Date → timestamp, omit undefined)
333
+ // Encode: Date → timestamp, omit undefined
294
334
  const encoded = codec.encode({
295
- name: "Alice",
296
- birthday: new Date("1990-01-01"),
297
- metadata: { role: "admin" },
298
- });
299
- // → { name: 'Alice', birthday: 631152000000, metadata: { role: 'admin' } }
300
-
301
- // Decode: Convex JSON → Zod data (timestamp → Date)
302
- const decoded = codec.decode(encoded);
303
- // → { name: 'Alice', birthday: Date('1990-01-01'), metadata: { role: 'admin' } }
304
-
305
- // Create sub-codecs (ZodObject only)
306
- const nameCodec = codec.pick({ name: true });
335
+ name: 'Alice',
336
+ birthday: new Date('1990-01-01')
337
+ })
338
+ // → { name: 'Alice', birthday: 631152000000 }
339
+
340
+ // Decode: timestamp → Date
341
+ const decoded = codec.decode(encoded)
342
+ // { name: 'Alice', birthday: Date('1990-01-01') }
307
343
  ```
308
344
 
309
- ### Table Helpers
310
-
311
- Define Convex tables from Zod schemas. `zodTable` accepts a **plain object shape** for best type inference:
312
-
313
- ```ts
314
- import { z } from "zod";
315
- import { zodTable, zid } from "zodvex";
316
- import { defineSchema } from "convex/server";
317
-
318
- // Define your schema as a plain object
319
- const postShape = {
320
- title: z.string(),
321
- content: z.string(),
322
- authorId: zid("users"), // Convex ID reference
323
- published: z.boolean().default(false),
324
- tags: z.array(z.string()).optional(),
325
- };
326
-
327
- // Create table helper - pass the plain object shape
328
- export const Posts = zodTable("posts", postShape);
329
-
330
- // Access properties
331
- Posts.table; // → Table definition for defineSchema
332
- Posts.shape; // → Original plain object shape
333
- Posts.zDoc; // → ZodObject with _id and _creationTime system fields
334
- Posts.docArray; // → z.array(zDoc) for return type validation
335
-
336
- // Use in schema.ts
337
- export default defineSchema({
338
- posts: Posts.table
339
- .index("by_author", ["authorId"])
340
- .index("by_published", ["published"]),
341
- });
345
+ ### Supported Types
342
346
 
343
- // Use docArray for return types
344
- export const listPosts = zQuery(
345
- query,
346
- {},
347
- async (ctx) => ctx.db.query("posts").collect(),
348
- { returns: Posts.docArray }
349
- );
347
+ | Zod Type | Convex Validator |
348
+ | -------------------- | ------------------------------------------- |
349
+ | `z.string()` | `v.string()` |
350
+ | `z.number()` | `v.float64()` |
351
+ | `z.bigint()` | `v.int64()` |
352
+ | `z.boolean()` | `v.boolean()` |
353
+ | `z.date()` | `v.float64()` (timestamp) |
354
+ | `z.null()` | `v.null()` |
355
+ | `z.array(T)` | `v.array(T)` |
356
+ | `z.object({...})` | `v.object({...})` |
357
+ | `z.record(T)` | `v.record(v.string(), T)` |
358
+ | `z.union([...])` | `v.union(...)` |
359
+ | `z.literal(x)` | `v.literal(x)` |
360
+ | `z.enum(['a', 'b'])` | `v.union(v.literal('a'), v.literal('b'))` ¹ |
361
+ | `z.optional(T)` | `v.optional(T)` |
362
+ | `z.nullable(T)` | `v.union(T, v.null())` |
363
+
364
+ **Zod v4 Enum Type Note:**
365
+
366
+ ¹ Enum types in Zod v4 produce a slightly different TypeScript signature than manually created unions:
367
+
368
+ ```typescript
369
+ // Manual union (precise tuple type)
370
+ const manual = v.union(v.literal('a'), v.literal('b'))
371
+ // Type: VUnion<"a" | "b", [VLiteral<"a", "required">, VLiteral<"b", "required">], "required", never>
372
+
373
+ // From Zod enum (array type)
374
+ const fromZod = zodToConvex(z.enum(['a', 'b']))
375
+ // Type: VUnion<"a" | "b", Array<VLiteral<"a" | "b", "required">>, "required", never>
350
376
  ```
351
377
 
352
- #### Working with Large Schemas
353
-
354
- For large schemas (30+ fields), the plain object pattern is recommended:
355
-
356
- ```ts
357
- // Define as plain object for better maintainability
358
- export const users = {
359
- // Meta
360
- tokenId: z.string(),
361
- type: z.literal("member"),
362
- isAdmin: z.boolean(),
363
-
364
- // User info
365
- email: z.string(),
366
- firstName: z.string().optional(),
367
- lastName: z.string().optional(),
368
- phone: z.string().optional(),
369
-
370
- // References
371
- favoriteUsers: z.array(zid("users")).optional(),
372
- activeDancerId: zid("dancers").optional(),
373
- // ... 40+ more fields
374
- };
375
-
376
- // Create table
377
- export const Users = zodTable("users", users);
378
-
379
- // Extract subsets without Zod's .pick() to avoid type complexity
380
- const zClerkFields = z.object(pickShape(users, ["email", "firstName", "lastName", "tokenId"]));
381
- // Use zClerkFields in function wrappers or validators
382
- ```
378
+ **This difference is purely cosmetic with no functional impact:**
383
379
 
384
- ## 🔧 Advanced Usage
380
+ - Value types are identical (`"a" | "b"`)
381
+ - ✅ Runtime validation is identical
382
+ - ✅ Type safety for function arguments/returns is preserved
383
+ - ✅ Convex uses `T[number]` which works identically for both array and tuple types
385
384
 
386
- ### Builder Pattern for Reusable Function Creators
385
+ This limitation exists because Zod v4 changed enum types from tuple-based to Record-based ([`ToEnum<T>`](https://github.com/colinhacks/zod/blob/v4/src/v4/core/util.ts#L83-L85)). TypeScript cannot convert a Record type to a specific tuple without knowing the keys at compile time. See [Zod v4 changelog](https://zod.dev/v4/changelog) and [enum evolution discussion](https://github.com/colinhacks/zod/discussions/2125) for more details.
387
386
 
388
- For projects with many functions, create reusable builders instead of repeating the base builder.
389
- Builders use Convex's native `{ args, handler, returns }` object syntax:
387
+ **Convex IDs:**
390
388
 
391
389
  ```ts
392
- import { query, mutation } from "./_generated/server";
393
- import { zQueryBuilder, zMutationBuilder } from "zodvex";
390
+ import { zid } from 'zodvex'
394
391
 
395
- // Create reusable builders
396
- export const zq = zQueryBuilder(query);
397
- export const zm = zMutationBuilder(mutation);
392
+ zid('tableName') // v.id('tableName')
393
+ zid('tableName').optional() // v.optional(v.id('tableName'))
394
+ ```
398
395
 
399
- // Use them with Convex-style object syntax
400
- export const getUser = zq({
401
- args: { id: z.string() },
402
- handler: async (ctx, { id }) => {
403
- return ctx.db.get(id);
404
- }
405
- });
396
+ ## Advanced Usage
406
397
 
407
- export const updateUser = zm({
408
- args: { id: z.string(), name: z.string() },
409
- handler: async (ctx, { id, name }) => {
410
- return ctx.db.patch(id, { name });
411
- }
412
- });
413
- ```
398
+ ### Custom Context Builders
414
399
 
415
- ### Custom Context with Middleware
400
+ Create builders with injected auth, permissions, or other context:
416
401
 
417
- Use custom builders to inject auth, permissions, or other context into your functions.
418
- zodvex provides `customCtx` (re-exported from convex-helpers) for convenience:
402
+ > **Best Practice:** Always add explicit type annotations to the `ctx` parameter in your `customCtx` functions. This improves TypeScript performance and prevents `ctx` from falling back to `any` in complex type scenarios. Import context types from `./_generated/server` (e.g., `QueryCtx`, `MutationCtx`, `ActionCtx`).
419
403
 
420
404
  ```ts
421
- import { query, mutation } from "./_generated/server";
422
- import { zCustomQueryBuilder, zCustomMutationBuilder, customCtx } from "zodvex";
405
+ import { zCustomQueryBuilder, zCustomMutationBuilder, customCtx } from 'zodvex'
406
+ import { type QueryCtx, type MutationCtx, query, mutation } from './_generated/server'
423
407
 
424
- // Create authenticated query builder
408
+ // Add user to all queries
425
409
  export const authQuery = zCustomQueryBuilder(
426
410
  query,
427
- customCtx(async (ctx) => {
428
- const user = await getUserOrThrow(ctx);
429
- return { user };
411
+ customCtx(async (ctx: QueryCtx) => {
412
+ const user = await getUserOrThrow(ctx)
413
+ return { user }
430
414
  })
431
- );
415
+ )
432
416
 
433
- // Create authenticated mutation builder
417
+ // Add user + permissions to mutations
434
418
  export const authMutation = zCustomMutationBuilder(
435
419
  mutation,
436
- customCtx(async (ctx) => {
437
- const user = await getUserOrThrow(ctx);
438
- return { user };
420
+ customCtx(async (ctx: MutationCtx) => {
421
+ const user = await getUserOrThrow(ctx)
422
+ const permissions = await getPermissions(ctx, user)
423
+ return { user, permissions }
439
424
  })
440
- );
441
-
442
- // Use them with automatic type-safe context
443
- export const getProfile = authQuery({
444
- args: {},
445
- handler: async (ctx) => {
446
- // ctx.user is automatically available and typed!
447
- return ctx.db.query("users").filter(q => q.eq(q.field("_id"), ctx.user._id)).first();
448
- }
449
- });
425
+ )
450
426
 
427
+ // Use them
451
428
  export const updateProfile = authMutation({
452
429
  args: { name: z.string() },
430
+ returns: z.null(),
453
431
  handler: async (ctx, { name }) => {
454
- // ctx.user is automatically available and typed!
455
- return ctx.db.patch(ctx.user._id, { name });
432
+ // ctx.user and ctx.permissions are available
433
+ if (!ctx.permissions.canEdit) {
434
+ throw new Error('No permission')
435
+ }
436
+ await ctx.db.patch(ctx.user._id, { name })
437
+ return null
456
438
  }
457
- });
439
+ })
458
440
  ```
459
441
 
460
- **Available custom builders:**
461
- - `zCustomQueryBuilder` - for queries with custom context
462
- - `zCustomMutationBuilder` - for mutations with custom context
463
- - `zCustomActionBuilder` - for actions with custom context
442
+ ### Date Handling
464
443
 
465
- ### Working with Dates
444
+ Dates are automatically converted to timestamps:
466
445
 
467
446
  ```ts
468
- // Dates are automatically converted to timestamps
469
447
  const eventShape = {
470
448
  title: z.string(),
471
449
  startDate: z.date(),
472
- endDate: z.date().nullable(),
473
- };
450
+ endDate: z.date().nullable()
451
+ }
474
452
 
475
- const Events = zodTable("events", eventShape);
453
+ export const Events = zodTable('events', eventShape)
476
454
 
477
- // In mutations - Date objects work seamlessly
478
- export const createEvent = zMutation(
479
- mutation,
480
- z.object(eventShape),
481
- async (ctx, event) => {
455
+ export const createEvent = zm({
456
+ args: eventShape,
457
+ handler: async (ctx, event) => {
482
458
  // event.startDate is a Date object
483
- // It's automatically converted to timestamp for storage
484
- return ctx.db.insert("events", event);
485
- },
486
- );
459
+ // Automatically converted to timestamp for storage
460
+ return await ctx.db.insert('events', event)
461
+ }
462
+ })
487
463
  ```
488
464
 
489
- ### Using Convex IDs
465
+ ### Return Type Helpers
490
466
 
491
467
  ```ts
492
- import { zid } from "zodvex";
493
-
494
- const CommentSchema = z.object({
495
- text: z.string(),
496
- postId: zid("posts"), // Reference to posts table
497
- authorId: zid("users"), // Reference to users table
498
- parentId: zid("comments").optional(), // Self-reference
499
- });
500
- ```
501
-
502
- ## ⚙️ Behavior & Semantics
468
+ import { returnsAs } from 'zodvex'
503
469
 
504
- ### Type Mappings
470
+ export const listUsers = zq({
471
+ args: {},
472
+ handler: async (ctx) => {
473
+ const rows = await ctx.db.query('users').collect()
474
+ // Use returnsAs for type hint in tricky inference spots
475
+ return returnsAs<typeof Users.docArray>()(rows)
476
+ },
477
+ returns: Users.docArray
478
+ })
479
+ ```
505
480
 
506
- | Zod Type | Convex Validator |
507
- | ----------------- | ------------------------- |
508
- | `z.string()` | `v.string()` |
509
- | `z.number()` | `v.float64()` |
510
- | `z.bigint()` | `v.int64()` |
511
- | `z.boolean()` | `v.boolean()` |
512
- | `z.date()` | `v.float64()` (timestamp) |
513
- | `z.null()` | `v.null()` |
514
- | `z.array(T)` | `v.array(T)` |
515
- | `z.object({...})` | `v.object({...})` |
516
- | `z.record(T)` | `v.record(v.string(), T)` |
517
- | `z.union([...])` | `v.union(...)` |
518
- | `z.literal(x)` | `v.literal(x)` |
519
- | `z.enum([...])` | `v.union(literals...)` |
520
- | `z.optional(T)` | `v.optional(T)` |
521
- | `z.nullable(T)` | `v.union(T, v.null())` |
481
+ ### Working with Large Schemas
522
482
 
523
- ### Important Notes
483
+ zodvex provides `pickShape` and `safePick` helpers as alternatives to Zod's `.pick()`:
524
484
 
525
- - **Defaults**: Zod defaults imply optional at the Convex schema level. Apply defaults in your application code.
526
- - **Numbers**: `z.number()` maps to `v.float64()`. For integers, use `z.bigint()` `v.int64()`.
527
- - **Transforms**: Zod transforms (`.transform()`, `.refine()`) are not supported in schema mapping and fall back to `v.any()`.
528
- - **Return encoding**: Return values are always encoded to Convex Values. When `returns` is specified, values are validated and then encoded according to the schema; without `returns`, values are still encoded (e.g., Date timestamp) for runtime safety.
485
+ ```ts
486
+ import { pickShape, safePick } from 'zodvex'
487
+
488
+ // Standard Zod .pick() works great for most schemas
489
+ const UserUpdate = User.pick({ email: true, firstName: true, lastName: true })
490
+
491
+ // If you hit TypeScript instantiation depth limits (rare, 100+ fields),
492
+ // use pickShape or safePick:
493
+ const userShape = pickShape(User, ['email', 'firstName', 'lastName'])
494
+ const UserUpdate = z.object(userShape)
495
+
496
+ // Or use safePick (convenience wrapper that does the same thing)
497
+ const UserUpdate = safePick(User, {
498
+ email: true,
499
+ firstName: true,
500
+ lastName: true
501
+ })
502
+ ```
529
503
 
530
- ### Runtime Conversion Consistency
504
+ ## Why zodvex?
531
505
 
532
- zodvex uses an internal base-type codec registry to keep validator mapping and runtime value conversion aligned (e.g., `Date` ↔ timestamp). Composite types (arrays, objects, records, unions, optional/nullable) are composed from these base entries.
506
+ - **Correct optional/nullable semantics** - Preserves Convex's distinction
507
+ - `.optional()` → `v.optional(T)` (field can be omitted)
508
+ - `.nullable()` → `v.union(T, v.null())` (required but can be null)
509
+ - Both → `v.optional(v.union(T, v.null()))`
510
+ - **Superior type safety** - Builders provide better type inference than wrapper functions
511
+ - **Date handling** - Automatic `Date` ↔ timestamp conversion
512
+ - **End-to-end validation** - Same schema from database to frontend forms
533
513
 
534
- ## 📝 Compatibility
514
+ ## Compatibility
535
515
 
536
- - **Zod**: v4 only (uses public v4 APIs)
537
- - **Convex**: >= 1.27
516
+ - **Zod**: ^4.1.0 or later
517
+ - **Convex**: >= 1.27.0
518
+ - **convex-helpers**: >= 0.1.104
538
519
  - **TypeScript**: Full type inference support
539
520
 
540
- ## 🤝 Contributing
541
-
542
- Contributions are welcome! Please feel free to submit a Pull Request.
543
-
544
- ## 📄 License
521
+ ## License
545
522
 
546
523
  MIT
547
524