From 44311e145ef0031ada4f6c2a300401f9973f8769 Mon Sep 17 00:00:00 2001 From: Nicolas Ettlin Date: Thu, 20 Nov 2025 10:59:49 -0800 Subject: [PATCH 01/13] Add a reproduction of the issue --- convex/_generated/api.d.ts | 2 ++ convex/zodFunctionsExample.ts | 36 ++++++++++++++++++++++ src/App.tsx | 2 ++ src/components/ZodFunctionsExample.tsx | 42 ++++++++++++++++++++++++++ 4 files changed, 82 insertions(+) create mode 100644 convex/zodFunctionsExample.ts create mode 100644 src/components/ZodFunctionsExample.tsx diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 9b59dec8..c697261a 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -19,6 +19,7 @@ import type * as sessionsExample from "../sessionsExample.js"; import type * as streamsExample from "../streamsExample.js"; import type * as testingFunctions from "../testingFunctions.js"; import type * as triggersExample from "../triggersExample.js"; +import type * as zodFunctionsExample from "../zodFunctionsExample.js"; import type { ApiFromModules, @@ -38,6 +39,7 @@ declare const fullApi: ApiFromModules<{ streamsExample: typeof streamsExample; testingFunctions: typeof testingFunctions; triggersExample: typeof triggersExample; + zodFunctionsExample: typeof zodFunctionsExample; }>; /** diff --git a/convex/zodFunctionsExample.ts b/convex/zodFunctionsExample.ts new file mode 100644 index 00000000..479693d2 --- /dev/null +++ b/convex/zodFunctionsExample.ts @@ -0,0 +1,36 @@ +import { zCustomQuery } from "convex-helpers/server/zod4"; +import { query } from "./_generated/server"; +import { NoOp } from "convex-helpers/server/customFunctions"; +import { z } from "zod/v4"; + +export const zQuery = zCustomQuery(query, NoOp); + +export const noArgs = zQuery({ + args: {}, + handler: async (ctx) => { + return "Hello world!"; + }, +}); + +const stringToDate = z.codec(z.iso.datetime(), z.date(), { + decode: (isoString) => new Date(isoString), + encode: (date) => date.toISOString(), +}); +const dateToString = z.codec(z.date(), z.iso.datetime(), { + decode: (date) => date.toISOString(), + encode: (isoString) => new Date(isoString), +}); + +export const withArgs = zQuery({ + args: { + date: stringToDate, + }, + returns: { + oneDayAfter: dateToString, + }, + handler: async (_ctx, args) => { + return { + oneDayAfter: new Date(args.date.getTime() + 24 * 60 * 60 * 1000), + }; + }, +}); diff --git a/src/App.tsx b/src/App.tsx index 1ae0ef4b..9f8cf60d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,7 @@ import RelationshipExample from "./components/RelationshipExample"; import SessionsExample from "./components/SessionsExample"; import { HonoExample } from "./components/HonoExample"; import { StreamsExample } from "./components/StreamsExample"; +import { ZodFunctionsExample } from "./components/ZodFunctionsExample"; import { SessionProvider } from "convex-helpers/react/sessions"; import { CacheExample } from "./components/CacheExample"; import { ConvexQueryCacheProvider } from "convex-helpers/react/cache"; @@ -23,6 +24,7 @@ export default function App() { + diff --git a/src/components/ZodFunctionsExample.tsx b/src/components/ZodFunctionsExample.tsx new file mode 100644 index 00000000..86714800 --- /dev/null +++ b/src/components/ZodFunctionsExample.tsx @@ -0,0 +1,42 @@ +import { api } from "../../convex/_generated/api"; +import { useQuery } from "convex/react"; +import { useMemo } from "react"; +import { z } from "zod/v4"; + +const stringToDate = z.codec(z.iso.datetime(), z.date(), { + decode: (isoString) => new Date(isoString), + encode: (date) => date.toISOString(), +}); + +export function ZodFunctionsExample() { + const now = useMemo(() => new Date(), []); + + const noArgsResult = useQuery(api.zodFunctionsExample.noArgs); + const noArgsResultEmptyArgs = useQuery(api.zodFunctionsExample.noArgs, {}); + const withArgsResult = useQuery(api.zodFunctionsExample.withArgs, { + date: stringToDate.encode(now), + }); + + return ( +
+

Zod Functions Example

+
+

No Args Query

+

Result: {noArgsResult ?? "Loading..."}

+
+
+

No Args Query (Empty Args)

+

Result: {noArgsResultEmptyArgs ?? "Loading..."}

+
+
+

With Args Query

+ {withArgsResult && ( +

+ One day after:{" "} + {stringToDate.decode(withArgsResult.oneDayAfter).toLocaleString()} +

+ )} +
+
+ ); +} From 5dc03c62a0bed5cd4007252d6781c5522f191126 Mon Sep 17 00:00:00 2001 From: Nicolas Ettlin Date: Thu, 20 Nov 2025 11:29:05 -0800 Subject: [PATCH 02/13] Add test in test.ts --- .../convex-helpers/server/zod4.functions.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/convex-helpers/server/zod4.functions.test.ts b/packages/convex-helpers/server/zod4.functions.test.ts index 163197a9..4096ead6 100644 --- a/packages/convex-helpers/server/zod4.functions.test.ts +++ b/packages/convex-helpers/server/zod4.functions.test.ts @@ -68,6 +68,14 @@ export const testQuery = zQuery({ }), }); +export const testQueryNoArgs = zQuery({ + args: {}, + handler: async (_ctx, args) => { + assertType<{}>(args); + }, + returns: z.null(), +}); + /** * Test zCustomMutation with Zod schemas for args and return value */ @@ -227,6 +235,7 @@ export const generateUserId = mutation({ const testApi: ApiFromModules<{ fns: { testQuery: typeof testQuery; + testQueryNoArgs: typeof testQueryNoArgs; testMutation: typeof testMutation; testAction: typeof testAction; transform: typeof transform; @@ -258,6 +267,12 @@ describe("zCustomQuery, zCustomMutation, zCustomAction", () => { >(); }); + test("zCustomQuery with no args", async () => { + const t = convexTest(schema, modules); + const response = await t.query(testApi.testQueryNoArgs); + expect(response).toBeNull(); + }); + test("zCustomMutation", async () => { const t = convexTest(schema, modules); const response = await t.mutation(testApi.testMutation, { From 13ffa8a5c6791611408026b185aae3a3d8b6e4bb Mon Sep 17 00:00:00 2001 From: Nicolas Ettlin Date: Thu, 20 Nov 2025 11:29:09 -0800 Subject: [PATCH 03/13] Fix types --- packages/convex-helpers/server/zod4.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/convex-helpers/server/zod4.ts b/packages/convex-helpers/server/zod4.ts index 0bf128a3..908d5898 100644 --- a/packages/convex-helpers/server/zod4.ts +++ b/packages/convex-helpers/server/zod4.ts @@ -961,7 +961,7 @@ type ArgsInput | void> = [ArgsValidator] extends [zCore.$ZodObject] ? [zCore.input] : [ArgsValidator] extends [ZodFields] - ? [zCore.input>] + ? [zCore.input>] : OneArgArray; // The args after they've been validated: passed to the handler From 3a953e29a88cab1ae709b31ee68d97f70c56798c Mon Sep 17 00:00:00 2001 From: Nicolas Ettlin Date: Thu, 20 Nov 2025 11:42:01 -0800 Subject: [PATCH 04/13] Use strictObject everywhere --- packages/convex-helpers/server/zod4.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/convex-helpers/server/zod4.ts b/packages/convex-helpers/server/zod4.ts index 908d5898..ec426ea3 100644 --- a/packages/convex-helpers/server/zod4.ts +++ b/packages/convex-helpers/server/zod4.ts @@ -846,7 +846,7 @@ function customFnBuilder( const returns = maybeObject && !(maybeObject instanceof zCore.$ZodType) - ? z.object(maybeObject) + ? z.strictObject(maybeObject) : maybeObject; const returnValidator = @@ -877,7 +877,7 @@ function customFnBuilder( extra, ); const rawArgs = pick(allArgs, Object.keys(argsValidator)); - const parsed = z.object(argsValidator).safeParse(rawArgs); + const parsed = z.strictObject(argsValidator).safeParse(rawArgs); if (!parsed.success) { throw new ConvexError({ ZodError: JSON.parse( From 8c45beb5980198cc8607bfbe623350e40c125a05 Mon Sep 17 00:00:00 2001 From: Nicolas Ettlin Date: Thu, 20 Nov 2025 11:42:29 -0800 Subject: [PATCH 05/13] Fix empty types --- packages/convex-helpers/server/zod4.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/convex-helpers/server/zod4.ts b/packages/convex-helpers/server/zod4.ts index ec426ea3..d44437cc 100644 --- a/packages/convex-helpers/server/zod4.ts +++ b/packages/convex-helpers/server/zod4.ts @@ -961,7 +961,9 @@ type ArgsInput | void> = [ArgsValidator] extends [zCore.$ZodObject] ? [zCore.input] : [ArgsValidator] extends [ZodFields] - ? [zCore.input>] + ? keyof ArgsValidator extends never + ? [{}] + : [zCore.input>] : OneArgArray; // The args after they've been validated: passed to the handler @@ -970,7 +972,9 @@ type ArgsOutput< > = [ArgsValidator] extends [zCore.$ZodObject] ? [zCore.output] : [ArgsValidator] extends [ZodFields] - ? [zCore.output>] + ? keyof ArgsValidator extends never + ? [{}] + : [zCore.output>] : OneArgArray; type Overwrite = Omit & U; From 4413829575f7fa04281767467f662fa64d3c739c Mon Sep 17 00:00:00 2001 From: Nicolas Ettlin Date: Thu, 20 Nov 2025 14:39:20 -0800 Subject: [PATCH 06/13] Fix empty args Co-authored-by: Ian Macartney --- convex/zodFunctionsExample.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/convex/zodFunctionsExample.ts b/convex/zodFunctionsExample.ts index 479693d2..959cb37c 100644 --- a/convex/zodFunctionsExample.ts +++ b/convex/zodFunctionsExample.ts @@ -7,7 +7,7 @@ export const zQuery = zCustomQuery(query, NoOp); export const noArgs = zQuery({ args: {}, - handler: async (ctx) => { + handler: async (_ctx) => { return "Hello world!"; }, }); From c76ca213e2b22e4437e0672403da7da354325b27 Mon Sep 17 00:00:00 2001 From: Nicolas Ettlin Date: Thu, 20 Nov 2025 15:14:12 -0800 Subject: [PATCH 07/13] Fix tests --- .../server/zod4.functions.test.ts | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/convex-helpers/server/zod4.functions.test.ts b/packages/convex-helpers/server/zod4.functions.test.ts index 4096ead6..b8d860b8 100644 --- a/packages/convex-helpers/server/zod4.functions.test.ts +++ b/packages/convex-helpers/server/zod4.functions.test.ts @@ -71,9 +71,8 @@ export const testQuery = zQuery({ export const testQueryNoArgs = zQuery({ args: {}, handler: async (_ctx, args) => { - assertType<{}>(args); + assertType>(args); }, - returns: z.null(), }); /** @@ -267,10 +266,20 @@ describe("zCustomQuery, zCustomMutation, zCustomAction", () => { >(); }); - test("zCustomQuery with no args", async () => { - const t = convexTest(schema, modules); - const response = await t.query(testApi.testQueryNoArgs); - expect(response).toBeNull(); + describe("zCustomQuery with no args", () => { + test("through t.query", async () => { + const t = convexTest(schema, modules); + const response = await t.query(testApi.testQueryNoArgs); + expect(response).toBeNull(); + }); + + test("through t.run", async () => { + const t = convexTest(schema, modules); + const response = await t.run((ctx) => + ctx.runQuery(testApi.testQueryNoArgs), + ); + expect(response).toBeNull(); + }); }); test("zCustomMutation", async () => { From 52566b7af58344d3f863b89f73a25b4c2c720c78 Mon Sep 17 00:00:00 2001 From: Nicolas Ettlin Date: Thu, 20 Nov 2025 15:31:20 -0800 Subject: [PATCH 08/13] Fix style --- packages/convex-helpers/server/zod4.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/convex-helpers/server/zod4.ts b/packages/convex-helpers/server/zod4.ts index d44437cc..fac309b0 100644 --- a/packages/convex-helpers/server/zod4.ts +++ b/packages/convex-helpers/server/zod4.ts @@ -962,7 +962,7 @@ type ArgsInput | void> = ? [zCore.input] : [ArgsValidator] extends [ZodFields] ? keyof ArgsValidator extends never - ? [{}] + ? OneArgArray : [zCore.input>] : OneArgArray; @@ -973,7 +973,7 @@ type ArgsOutput< ? [zCore.output] : [ArgsValidator] extends [ZodFields] ? keyof ArgsValidator extends never - ? [{}] + ? OneArgArray : [zCore.output>] : OneArgArray; From f4da61e885f36cbf1d1a6e5e865af74a567b7dc1 Mon Sep 17 00:00:00 2001 From: Nicolas Ettlin Date: Thu, 20 Nov 2025 20:51:16 -0800 Subject: [PATCH 09/13] Revert "Fix style" This reverts commit 52566b7af58344d3f863b89f73a25b4c2c720c78. --- packages/convex-helpers/server/zod4.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/convex-helpers/server/zod4.ts b/packages/convex-helpers/server/zod4.ts index fac309b0..d44437cc 100644 --- a/packages/convex-helpers/server/zod4.ts +++ b/packages/convex-helpers/server/zod4.ts @@ -962,7 +962,7 @@ type ArgsInput | void> = ? [zCore.input] : [ArgsValidator] extends [ZodFields] ? keyof ArgsValidator extends never - ? OneArgArray + ? [{}] : [zCore.input>] : OneArgArray; @@ -973,7 +973,7 @@ type ArgsOutput< ? [zCore.output] : [ArgsValidator] extends [ZodFields] ? keyof ArgsValidator extends never - ? OneArgArray + ? [{}] : [zCore.output>] : OneArgArray; From 23a8f3e5cbbc4264c4973f4e78edc7591be28a5c Mon Sep 17 00:00:00 2001 From: Nicolas Ettlin Date: Thu, 20 Nov 2025 21:08:16 -0800 Subject: [PATCH 10/13] Remove ineffective fix --- packages/convex-helpers/server/zod4.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/convex-helpers/server/zod4.ts b/packages/convex-helpers/server/zod4.ts index d44437cc..ec426ea3 100644 --- a/packages/convex-helpers/server/zod4.ts +++ b/packages/convex-helpers/server/zod4.ts @@ -961,9 +961,7 @@ type ArgsInput | void> = [ArgsValidator] extends [zCore.$ZodObject] ? [zCore.input] : [ArgsValidator] extends [ZodFields] - ? keyof ArgsValidator extends never - ? [{}] - : [zCore.input>] + ? [zCore.input>] : OneArgArray; // The args after they've been validated: passed to the handler @@ -972,9 +970,7 @@ type ArgsOutput< > = [ArgsValidator] extends [zCore.$ZodObject] ? [zCore.output] : [ArgsValidator] extends [ZodFields] - ? keyof ArgsValidator extends never - ? [{}] - : [zCore.output>] + ? [zCore.output>] : OneArgArray; type Overwrite = Omit & U; From b8d31a7f7bc3562a040d4be01b574a2222d448c3 Mon Sep 17 00:00:00 2001 From: Nicolas Ettlin Date: Mon, 24 Nov 2025 14:10:29 -0800 Subject: [PATCH 11/13] Revert incorrect solution --- packages/convex-helpers/server/zod4.ts | 63 ++++++++++++++------------ 1 file changed, 35 insertions(+), 28 deletions(-) diff --git a/packages/convex-helpers/server/zod4.ts b/packages/convex-helpers/server/zod4.ts index ec426ea3..4a5f3c4b 100644 --- a/packages/convex-helpers/server/zod4.ts +++ b/packages/convex-helpers/server/zod4.ts @@ -430,15 +430,18 @@ export function zodToConvex( } visited.add(validator); - if (validator instanceof zCore.$ZodDefault) { - return v.optional(zodToConvexInner(validator._zod.def.innerType)); - } - - if (validator instanceof zCore.$ZodPipe) { - return zodToConvexInner(validator._zod.def.in); - } - - return zodToConvexCommon(validator, zodToConvexInner); + const result = + validator instanceof zCore.$ZodDefault + ? v.optional(zodToConvexInner(validator._zod.def.innerType)) + : validator instanceof zCore.$ZodPipe + ? zodToConvexInner(validator._zod.def.in) + : zodToConvexCommon(validator, zodToConvexInner); + + // After returning, we remove the validator from the visited set because + // we only want to detect circular types, not cases where part of a type + // is reused (e.g. `v.object({ field1: mySchema, field2: mySchema })`). + visited.delete(validator); + return result; } // `as any` because ConvexValidatorFromZod is defined from the behavior of zodToConvex. @@ -517,20 +520,20 @@ export function zodOutputToConvex( } visited.add(validator); - if (validator instanceof zCore.$ZodDefault) { - // Output: always there - return zodOutputToConvexInner(validator._zod.def.innerType); - } - - if (validator instanceof zCore.$ZodPipe) { - return zodOutputToConvexInner(validator._zod.def.out); - } - - if (validator instanceof zCore.$ZodTransform) { - return v.any(); - } - - return zodToConvexCommon(validator, zodOutputToConvexInner); + const result = + validator instanceof zCore.$ZodDefault + ? zodOutputToConvexInner(validator._zod.def.innerType) + : validator instanceof zCore.$ZodPipe + ? zodOutputToConvexInner(validator._zod.def.out) + : validator instanceof zCore.$ZodTransform + ? v.any() + : zodToConvexCommon(validator, zodOutputToConvexInner); + + // After returning, we remove the validator from the visited set because + // we only want to detect circular types, not cases where part of a type + // is reused (e.g. `v.object({ field1: mySchema, field2: mySchema })`). + visited.delete(validator); + return result; } // `as any` because ConvexValidatorFromZodOutput is defined from the behavior of zodOutputToConvex. @@ -846,7 +849,7 @@ function customFnBuilder( const returns = maybeObject && !(maybeObject instanceof zCore.$ZodType) - ? z.strictObject(maybeObject) + ? z.object(maybeObject) : maybeObject; const returnValidator = @@ -877,7 +880,7 @@ function customFnBuilder( extra, ); const rawArgs = pick(allArgs, Object.keys(argsValidator)); - const parsed = z.strictObject(argsValidator).safeParse(rawArgs); + const parsed = await z.object(argsValidator).safeParseAsync(rawArgs); if (!parsed.success) { throw new ConvexError({ ZodError: JSON.parse( @@ -891,7 +894,9 @@ function customFnBuilder( const ret = await handler(finalCtx, finalArgs); // We don't catch the error here. It's a developer error and we // don't want to risk exposing the unexpected value to the client. - const result = returns ? returns.parse(ret) : ret; + const result = returns + ? await returns.parseAsync(ret === undefined ? null : ret) + : ret; if (added.onSuccess) { await added.onSuccess({ ctx, args, result }); } @@ -914,7 +919,9 @@ function customFnBuilder( const ret = await handler(finalCtx, finalArgs); // We don't catch the error here. It's a developer error and we // don't want to risk exposing the unexpected value to the client. - const result = returns ? returns.parse(ret) : ret; + const result = returns + ? await returns.parseAsync(ret === undefined ? null : ret) + : ret; if (added.onSuccess) { await added.onSuccess({ ctx, args, result }); } @@ -961,7 +968,7 @@ type ArgsInput | void> = [ArgsValidator] extends [zCore.$ZodObject] ? [zCore.input] : [ArgsValidator] extends [ZodFields] - ? [zCore.input>] + ? [zCore.input>] : OneArgArray; // The args after they've been validated: passed to the handler From 6c9a5f7e5c872844cfb0b6be0192532591a4bb53 Mon Sep 17 00:00:00 2001 From: Nicolas Ettlin Date: Mon, 24 Nov 2025 16:02:37 -0800 Subject: [PATCH 12/13] Apply fix --- packages/convex-helpers/server/zod4.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/convex-helpers/server/zod4.ts b/packages/convex-helpers/server/zod4.ts index 4a5f3c4b..6062e12a 100644 --- a/packages/convex-helpers/server/zod4.ts +++ b/packages/convex-helpers/server/zod4.ts @@ -960,15 +960,17 @@ type ReturnValueOutput< > = [ReturnsValidator] extends [zCore.$ZodType] ? Returns> : [ReturnsValidator] extends [ZodFields] - ? Returns>> + ? Returns>> : any; // The args before they've been validated: passed from the client type ArgsInput | void> = [ArgsValidator] extends [zCore.$ZodObject] ? [zCore.input] - : [ArgsValidator] extends [ZodFields] - ? [zCore.input>] + : ArgsValidator extends Record + ? [{}] + : [ArgsValidator] extends [Record] + ? [zCore.input>] : OneArgArray; // The args after they've been validated: passed to the handler From 90490ae5f1cb55ae1a3b69bf954e99fe882195c5 Mon Sep 17 00:00:00 2001 From: Nicolas Ettlin Date: Mon, 24 Nov 2025 17:46:13 -0800 Subject: [PATCH 13/13] Mute lint warning --- packages/convex-helpers/server/zod4.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/convex-helpers/server/zod4.ts b/packages/convex-helpers/server/zod4.ts index 6062e12a..f0cd41c3 100644 --- a/packages/convex-helpers/server/zod4.ts +++ b/packages/convex-helpers/server/zod4.ts @@ -968,10 +968,11 @@ type ArgsInput | void> = [ArgsValidator] extends [zCore.$ZodObject] ? [zCore.input] : ArgsValidator extends Record - ? [{}] + ? // eslint-disable-next-line @typescript-eslint/no-empty-object-type + [{}] : [ArgsValidator] extends [Record] ? [zCore.input>] - : OneArgArray; + : OneArgArray; // The args after they've been validated: passed to the handler type ArgsOutput<