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 +383 -406
- package/dist/index.d.mts +22 -28
- package/dist/index.d.ts +22 -28
- package/dist/index.js +5 -16
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +6 -11
- package/dist/index.mjs.map +1 -1
- package/package.json +12 -12
- package/src/builders.ts +6 -6
- package/src/custom.ts +5 -1
- package/src/index.ts +1 -1
- package/src/mapping/types.ts +65 -10
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
|
-
>
|
|
7
|
+
> Built on top of [convex-helpers](https://github.com/get-convex/convex-helpers)
|
|
8
8
|
|
|
9
|
-
##
|
|
9
|
+
## Table of Contents
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
20
|
-
yarn add zodvex zod convex convex-helpers
|
|
21
|
-
```
|
|
22
|
+
## Installation
|
|
22
23
|
|
|
23
24
|
```bash
|
|
24
|
-
|
|
25
|
+
npm install zodvex zod@^4.1.0 convex convex-helpers
|
|
25
26
|
```
|
|
26
27
|
|
|
27
28
|
**Peer dependencies:**
|
|
28
29
|
|
|
29
|
-
- `zod` (
|
|
30
|
-
- `convex` (>= 1.27)
|
|
31
|
-
- `convex-helpers` (>= 0.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
|
-
|
|
36
|
+
### 1. Set up your builders
|
|
34
37
|
|
|
35
|
-
|
|
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
|
|
40
|
-
import {
|
|
41
|
-
import {
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
//
|
|
51
|
-
export const
|
|
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
|
-
|
|
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 {
|
|
71
|
-
import {
|
|
72
|
-
import { zodTable } from "zodvex";
|
|
104
|
+
import { z } from 'zod'
|
|
105
|
+
import { zodTable, zid } from 'zodvex'
|
|
73
106
|
|
|
74
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
//
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
##
|
|
122
|
+
## Building Your Schema
|
|
93
123
|
|
|
94
|
-
|
|
124
|
+
Use `zodTable().table` in your Convex schema:
|
|
95
125
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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
|
-
|
|
138
|
+
teams: Teams.table.index('by_created', ['_creationTime'])
|
|
139
|
+
})
|
|
140
|
+
```
|
|
107
141
|
|
|
108
|
-
|
|
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
|
-
|
|
144
|
+
Use your builders from `util.ts` to create type-safe functions:
|
|
120
145
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
181
|
+
## Working with Subsets
|
|
131
182
|
|
|
132
|
-
|
|
183
|
+
Pick a subset of fields for focused operations:
|
|
133
184
|
|
|
134
185
|
```ts
|
|
135
|
-
import { z } from
|
|
136
|
-
import {
|
|
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
|
-
//
|
|
139
|
-
const
|
|
140
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
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
|
-
|
|
236
|
+
function UserForm() {
|
|
237
|
+
const createUser = useMutation(api.users.createUser)
|
|
177
238
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
239
|
+
const {
|
|
240
|
+
register,
|
|
241
|
+
handleSubmit,
|
|
242
|
+
formState: { errors }
|
|
243
|
+
} = useForm<CreateUserForm>({
|
|
244
|
+
resolver: zodResolver(CreateUserForm)
|
|
245
|
+
})
|
|
182
246
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
216
|
-
|
|
256
|
+
<input {...register('email')} />
|
|
257
|
+
{errors.email && <span>{errors.email.message}</span>}
|
|
217
258
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
259
|
+
<button type="submit">Create User</button>
|
|
260
|
+
</form>
|
|
261
|
+
)
|
|
262
|
+
}
|
|
221
263
|
```
|
|
222
264
|
|
|
223
|
-
|
|
265
|
+
## API Reference
|
|
266
|
+
|
|
267
|
+
### Builders
|
|
224
268
|
|
|
225
|
-
|
|
269
|
+
**Basic builders** - Create type-safe functions without auth:
|
|
226
270
|
|
|
227
271
|
```ts
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
272
|
+
zQueryBuilder(query) // Creates query builder
|
|
273
|
+
zMutationBuilder(mutation) // Creates mutation builder
|
|
274
|
+
zActionBuilder(action) // Creates action builder
|
|
275
|
+
```
|
|
231
276
|
|
|
232
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
251
|
-
export const createdBounds = zQuery(
|
|
283
|
+
const authQuery = zCustomQueryBuilder(
|
|
252
284
|
query,
|
|
253
|
-
{
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
//
|
|
263
|
-
export const
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
async (ctx) => {
|
|
267
|
-
|
|
268
|
-
return
|
|
269
|
-
}
|
|
270
|
-
|
|
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
|
|
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
|
-
|
|
286
|
-
});
|
|
287
|
-
|
|
288
|
-
const codec = convexCodec(UserSchema);
|
|
328
|
+
birthday: z.date().optional()
|
|
329
|
+
})
|
|
289
330
|
|
|
290
|
-
|
|
291
|
-
const validators = codec.toConvexSchema();
|
|
331
|
+
const codec = convexCodec(UserSchema)
|
|
292
332
|
|
|
293
|
-
// Encode:
|
|
333
|
+
// Encode: Date → timestamp, omit undefined
|
|
294
334
|
const encoded = codec.encode({
|
|
295
|
-
name:
|
|
296
|
-
birthday: new Date(
|
|
297
|
-
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
###
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
389
|
-
Builders use Convex's native `{ args, handler, returns }` object syntax:
|
|
387
|
+
**Convex IDs:**
|
|
390
388
|
|
|
391
389
|
```ts
|
|
392
|
-
import {
|
|
393
|
-
import { zQueryBuilder, zMutationBuilder } from "zodvex";
|
|
390
|
+
import { zid } from 'zodvex'
|
|
394
391
|
|
|
395
|
-
//
|
|
396
|
-
|
|
397
|
-
|
|
392
|
+
zid('tableName') // → v.id('tableName')
|
|
393
|
+
zid('tableName').optional() // → v.optional(v.id('tableName'))
|
|
394
|
+
```
|
|
398
395
|
|
|
399
|
-
|
|
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
|
-
|
|
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
|
-
|
|
400
|
+
Create builders with injected auth, permissions, or other context:
|
|
416
401
|
|
|
417
|
-
|
|
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 {
|
|
422
|
-
import {
|
|
405
|
+
import { zCustomQueryBuilder, zCustomMutationBuilder, customCtx } from 'zodvex'
|
|
406
|
+
import { type QueryCtx, type MutationCtx, query, mutation } from './_generated/server'
|
|
423
407
|
|
|
424
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
455
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
453
|
+
export const Events = zodTable('events', eventShape)
|
|
476
454
|
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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
|
-
//
|
|
484
|
-
return ctx.db.insert(
|
|
485
|
-
}
|
|
486
|
-
)
|
|
459
|
+
// Automatically converted to timestamp for storage
|
|
460
|
+
return await ctx.db.insert('events', event)
|
|
461
|
+
}
|
|
462
|
+
})
|
|
487
463
|
```
|
|
488
464
|
|
|
489
|
-
###
|
|
465
|
+
### Return Type Helpers
|
|
490
466
|
|
|
491
467
|
```ts
|
|
492
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
483
|
+
zodvex provides `pickShape` and `safePick` helpers as alternatives to Zod's `.pick()`:
|
|
524
484
|
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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
|
-
|
|
504
|
+
## Why zodvex?
|
|
531
505
|
|
|
532
|
-
|
|
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
|
-
##
|
|
514
|
+
## Compatibility
|
|
535
515
|
|
|
536
|
-
- **Zod**:
|
|
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
|
-
##
|
|
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
|
|