Skip to content
2 changes: 2 additions & 0 deletions convex/_generated/api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -38,6 +39,7 @@ declare const fullApi: ApiFromModules<{
streamsExample: typeof streamsExample;
testingFunctions: typeof testingFunctions;
triggersExample: typeof triggersExample;
zodFunctionsExample: typeof zodFunctionsExample;
}>;

/**
Expand Down
36 changes: 36 additions & 0 deletions convex/zodFunctionsExample.ts
Original file line number Diff line number Diff line change
@@ -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),
};
},
});
24 changes: 24 additions & 0 deletions packages/convex-helpers/server/zod4.functions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,13 @@ export const testQuery = zQuery({
}),
});

export const testQueryNoArgs = zQuery({
args: {},
handler: async (_ctx, args) => {
assertType<Record<string, never>>(args);
},
});

/**
* Test zCustomMutation with Zod schemas for args and return value
*/
Expand Down Expand Up @@ -254,6 +261,7 @@ export const generateUserId = mutation({
const testApi: ApiFromModules<{
fns: {
testQuery: typeof testQuery;
testQueryNoArgs: typeof testQueryNoArgs;
testMutation: typeof testMutation;
testAction: typeof testAction;
returnsNothing: typeof returnsNothing;
Expand Down Expand Up @@ -287,6 +295,22 @@ describe("zCustomQuery, zCustomMutation, zCustomAction", () => {
>();
});

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 () => {
const t = convexTest(schema, modules);
const response = await t.mutation(testApi.testMutation, {
Expand Down
11 changes: 7 additions & 4 deletions packages/convex-helpers/server/zod4.ts
Original file line number Diff line number Diff line change
Expand Up @@ -960,16 +960,19 @@ type ReturnValueOutput<
> = [ReturnsValidator] extends [zCore.$ZodType]
? Returns<zCore.output<ReturnsValidator>>
: [ReturnsValidator] extends [ZodFields]
? Returns<zCore.output<zCore.$ZodObject<ReturnsValidator>>>
? Returns<zCore.output<zCore.$ZodObject<ReturnsValidator, zCore.$strict>>>
: any;

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

// The args after they've been validated: passed to the handler
type ArgsOutput<
Expand Down
2 changes: 2 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -23,6 +24,7 @@ export default function App() {
<HonoExample />
<CacheExample />
<StreamsExample />
<ZodFunctionsExample />
</ConvexQueryCacheProvider>
</SessionProvider>
</main>
Expand Down
42 changes: 42 additions & 0 deletions src/components/ZodFunctionsExample.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<h2>Zod Functions Example</h2>
<section>
<h3>No Args Query</h3>
<p>Result: {noArgsResult ?? "Loading..."}</p>
</section>
<section>
<h3>No Args Query (Empty Args)</h3>
<p>Result: {noArgsResultEmptyArgs ?? "Loading..."}</p>
</section>
<section>
<h3>With Args Query</h3>
{withArgsResult && (
<p>
One day after:{" "}
{stringToDate.decode(withArgsResult.oneDayAfter).toLocaleString()}
</p>
)}
</section>
</div>
);
}
Loading