Skip to content

Commit 21d8b89

Browse files
Zod 4 support: fix empty function args (#855)
Co-authored-by: Ian Macartney <ian@convex.dev>
1 parent 21ec6d6 commit 21d8b89

File tree

6 files changed

+113
-4
lines changed

6 files changed

+113
-4
lines changed

convex/_generated/api.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import type * as sessionsExample from "../sessionsExample.js";
1919
import type * as streamsExample from "../streamsExample.js";
2020
import type * as testingFunctions from "../testingFunctions.js";
2121
import type * as triggersExample from "../triggersExample.js";
22+
import type * as zodFunctionsExample from "../zodFunctionsExample.js";
2223

2324
import type {
2425
ApiFromModules,
@@ -38,6 +39,7 @@ declare const fullApi: ApiFromModules<{
3839
streamsExample: typeof streamsExample;
3940
testingFunctions: typeof testingFunctions;
4041
triggersExample: typeof triggersExample;
42+
zodFunctionsExample: typeof zodFunctionsExample;
4143
}>;
4244

4345
/**

convex/zodFunctionsExample.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { zCustomQuery } from "convex-helpers/server/zod4";
2+
import { query } from "./_generated/server";
3+
import { NoOp } from "convex-helpers/server/customFunctions";
4+
import { z } from "zod/v4";
5+
6+
export const zQuery = zCustomQuery(query, NoOp);
7+
8+
export const noArgs = zQuery({
9+
args: {},
10+
handler: async (_ctx) => {
11+
return "Hello world!";
12+
},
13+
});
14+
15+
const stringToDate = z.codec(z.iso.datetime(), z.date(), {
16+
decode: (isoString) => new Date(isoString),
17+
encode: (date) => date.toISOString(),
18+
});
19+
const dateToString = z.codec(z.date(), z.iso.datetime(), {
20+
decode: (date) => date.toISOString(),
21+
encode: (isoString) => new Date(isoString),
22+
});
23+
24+
export const withArgs = zQuery({
25+
args: {
26+
date: stringToDate,
27+
},
28+
returns: {
29+
oneDayAfter: dateToString,
30+
},
31+
handler: async (_ctx, args) => {
32+
return {
33+
oneDayAfter: new Date(args.date.getTime() + 24 * 60 * 60 * 1000),
34+
};
35+
},
36+
});

packages/convex-helpers/server/zod4.functions.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,13 @@ export const testQuery = zQuery({
6868
}),
6969
});
7070

71+
export const testQueryNoArgs = zQuery({
72+
args: {},
73+
handler: async (_ctx, args) => {
74+
assertType<Record<string, never>>(args);
75+
},
76+
});
77+
7178
/**
7279
* Test zCustomMutation with Zod schemas for args and return value
7380
*/
@@ -254,6 +261,7 @@ export const generateUserId = mutation({
254261
const testApi: ApiFromModules<{
255262
fns: {
256263
testQuery: typeof testQuery;
264+
testQueryNoArgs: typeof testQueryNoArgs;
257265
testMutation: typeof testMutation;
258266
testAction: typeof testAction;
259267
returnsNothing: typeof returnsNothing;
@@ -287,6 +295,22 @@ describe("zCustomQuery, zCustomMutation, zCustomAction", () => {
287295
>();
288296
});
289297

298+
describe("zCustomQuery with no args", () => {
299+
test("through t.query", async () => {
300+
const t = convexTest(schema, modules);
301+
const response = await t.query(testApi.testQueryNoArgs);
302+
expect(response).toBeNull();
303+
});
304+
305+
test("through t.run", async () => {
306+
const t = convexTest(schema, modules);
307+
const response = await t.run((ctx) =>
308+
ctx.runQuery(testApi.testQueryNoArgs),
309+
);
310+
expect(response).toBeNull();
311+
});
312+
});
313+
290314
test("zCustomMutation", async () => {
291315
const t = convexTest(schema, modules);
292316
const response = await t.mutation(testApi.testMutation, {

packages/convex-helpers/server/zod4.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -960,16 +960,19 @@ type ReturnValueOutput<
960960
> = [ReturnsValidator] extends [zCore.$ZodType]
961961
? Returns<zCore.output<ReturnsValidator>>
962962
: [ReturnsValidator] extends [ZodFields]
963-
? Returns<zCore.output<zCore.$ZodObject<ReturnsValidator>>>
963+
? Returns<zCore.output<zCore.$ZodObject<ReturnsValidator, zCore.$strict>>>
964964
: any;
965965

966966
// The args before they've been validated: passed from the client
967967
type ArgsInput<ArgsValidator extends ZodFields | zCore.$ZodObject<any> | void> =
968968
[ArgsValidator] extends [zCore.$ZodObject<any>]
969969
? [zCore.input<ArgsValidator>]
970-
: [ArgsValidator] extends [ZodFields]
971-
? [zCore.input<zCore.$ZodObject<ArgsValidator>>]
972-
: OneArgArray;
970+
: ArgsValidator extends Record<string, never>
971+
? // eslint-disable-next-line @typescript-eslint/no-empty-object-type
972+
[{}]
973+
: [ArgsValidator] extends [Record<string, z.ZodTypeAny>]
974+
? [zCore.input<zCore.$ZodObject<ArgsValidator, zCore.$strict>>]
975+
: OneArgArray;
973976

974977
// The args after they've been validated: passed to the handler
975978
type ArgsOutput<

src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import RelationshipExample from "./components/RelationshipExample";
33
import SessionsExample from "./components/SessionsExample";
44
import { HonoExample } from "./components/HonoExample";
55
import { StreamsExample } from "./components/StreamsExample";
6+
import { ZodFunctionsExample } from "./components/ZodFunctionsExample";
67
import { SessionProvider } from "convex-helpers/react/sessions";
78
import { CacheExample } from "./components/CacheExample";
89
import { ConvexQueryCacheProvider } from "convex-helpers/react/cache";
@@ -23,6 +24,7 @@ export default function App() {
2324
<HonoExample />
2425
<CacheExample />
2526
<StreamsExample />
27+
<ZodFunctionsExample />
2628
</ConvexQueryCacheProvider>
2729
</SessionProvider>
2830
</main>
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { api } from "../../convex/_generated/api";
2+
import { useQuery } from "convex/react";
3+
import { useMemo } from "react";
4+
import { z } from "zod/v4";
5+
6+
const stringToDate = z.codec(z.iso.datetime(), z.date(), {
7+
decode: (isoString) => new Date(isoString),
8+
encode: (date) => date.toISOString(),
9+
});
10+
11+
export function ZodFunctionsExample() {
12+
const now = useMemo(() => new Date(), []);
13+
14+
const noArgsResult = useQuery(api.zodFunctionsExample.noArgs);
15+
const noArgsResultEmptyArgs = useQuery(api.zodFunctionsExample.noArgs, {});
16+
const withArgsResult = useQuery(api.zodFunctionsExample.withArgs, {
17+
date: stringToDate.encode(now),
18+
});
19+
20+
return (
21+
<div>
22+
<h2>Zod Functions Example</h2>
23+
<section>
24+
<h3>No Args Query</h3>
25+
<p>Result: {noArgsResult ?? "Loading..."}</p>
26+
</section>
27+
<section>
28+
<h3>No Args Query (Empty Args)</h3>
29+
<p>Result: {noArgsResultEmptyArgs ?? "Loading..."}</p>
30+
</section>
31+
<section>
32+
<h3>With Args Query</h3>
33+
{withArgsResult && (
34+
<p>
35+
One day after:{" "}
36+
{stringToDate.decode(withArgsResult.oneDayAfter).toLocaleString()}
37+
</p>
38+
)}
39+
</section>
40+
</div>
41+
);
42+
}

0 commit comments

Comments
 (0)