Skip to content

Conversation

@Nicolapps
Copy link
Member

@Nicolapps Nicolapps commented Nov 11, 2025

This pull request adds full support for Zod 4 in convex-helpers.

Migration

The methods in convex-helpers/server/zod have been moved to convex-helpers/server/zod3. The new Zod 4-compatible methods can be imported from convex-helpers/server/zod4.

For convenience, some methods in convex-helpers/server/zod (zodToConvex, zodOutputToConvex, zodToConvexFields, zodOutputToConvexFields, and withSystemFields) now accept both Zod 3 and Zod 4 schemas. If you’re using Zod 4 in your codebase, we strongly recommend that you import directly from convex-helpers/server/zod4, as it simplifies the work that the TypeScript compiler needs to do.

Codecs support

When using Zod 4.1, developers can benefit from Zod codecs to roundtrip values to/from functions and the database. As explained in https://www.notion.so/convex-dev/Convex-Zod-4-Codecs-integration-294b57ff32ab809fa067fe63c740ca74?source=copy_link, the code of convex-helpers doesn’t need to do anything related to codecs:

  • When using codecs to encode function arguments, developers can use codecs with Input = Convex value and Output = Developer-facing value
  • When using codecs to encode function return values, developers can use codecs with Input = Developer-facing value and Output = Convex value
  • When using codecs to store functions in the database, developers can use codecs with Input = Convex value and Output = Developer-facing value. (convex-helpers doesn’t provide a database wrapper that encodes values with Zod, but if you’re writing one, you would want to call the .encode() and .decode() functions of Zod schemas).

This strategy allows developers to keep using z.transform() in both args and returns, while keeping support for Zod 3 and 4.0.

Implementation details

  • zid is implemented as a z.custom call with a registry that stores the table name in a WeakMap. I considered extending z.ZodCustom or z.ZodType, but this isn’t officially supported by Zod (see https://discord.com/channels/893487829802418277/1359688107594748054/1367295316365152308).
  • zodToConvex and zodOutputToConvex support circular schemas (v.any() is returned when a type is used more than once).
  • z.templateLiteral() is now supported.
  • z.tuple() is now supported: it might return validators that are more complex than necessary (e.g. [number, number] will be converted to v.union(v.number(), v.number())).
  • z.literal() now supports literals with multiple values (e.g. z.literal([1, 2, 3])) correctly.
  • For some types, z.tuple() and z.literal(), the output type is loosened to not assume a specific order in the resulting VUnion. This is necessary since the type information in Zod only contains a union, which don’t have a guaranteed order in TypeScript. The Zod 3 implementation didn’t handle this correctly (but wasn’t updated).
  • z.record() now uses v.object() when the key is a literal (or union of literals), since v.union(v.literal(…), …) isn’t a valid Convex validator.
  • Methods that take a Zod schema support both Zod 4 and Zod 4 mini. Methods that return a Zod schema don’t support Zod Mini, but support could be added in the future if necesary.

Acknowledgments

This pull request is inspired by the work done by @ksinghal in #818 and by @natedunn in vendpark#1 and vendpark#2. Thanks to both of you!

Co-Authored-By: Karan Singhal [email protected]
Co-Authored-By: Nate Dunn [email protected]

@Nicolapps Nicolapps changed the title WIP: Zod 4 support Zod 4 support Nov 14, 2025
Copy link
Collaborator

@ianmacartney ianmacartney left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks great!

>;
};

function customFnBuilder(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function is almost identical to zod3 except for a few lines. Ok for now, but is the eventual plan to deprecate zod3 or to unify or keep as is? It's ok to keep it all separate. maybe a comment to keep them up to date is enough?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, I added a comment in fdf6e5b

Comment on lines +1439 to +1443
type IsUnion<T, U extends T = T> = T extends unknown
? [U] extends [T]
? false
: true
: false;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is some more magic I don't yet understand.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comes from https://stackoverflow.com/a/59687759/4652564. I don’t understand in detail how it works, but from my testing it looks like it works correctly.

Comment on lines 1467 to 1476
type IsUnknownOrAny<T> =
// any?
0 extends 1 & T
? true
: // unknown?
unknown extends T
? [T] extends [unknown]
? true
: false
: false;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are these standard type utilities, llm-produced, or your own invention?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For this one I used a LLM to solve the following problems:

  • knowing whether a generic type is any
  • knowing whether a generic type is unknown

I got consistent answers after asking multiple times and my type tests pass, so I think that the solution is correct

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

vRequired(valueValidator),
);

function extractStringLiterals(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

any reason these helper functions can't be outside of the function body? not sure if it has an impact/ the interpreter is smart enough, but I wouldn't want every invocation of this function to create these functions if not necessary

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, they were just here to define them in the only place where they are used. I moved them to the global scope in e43940d

>
: never;

function vRequired(validator: GenericValidator) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

one optimization would be to return the existing validator if it's already required

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, I did it in 54e0622725e77f7de84673b4bc07f7edc84f5e76


type NotUndefined<T> = Exclude<T, undefined>;

type VRequired<T extends Validator<any, OptionalProperty, any>> =
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do you think this would be useful in the core package? or just inline it here to start?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this is a good idea. I can do this as a follow-up task, I created an issue at #847

Copy link
Member Author

@Nicolapps Nicolapps left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @ianmacartney for your review!

Comment on lines +1439 to +1443
type IsUnion<T, U extends T = T> = T extends unknown
? [U] extends [T]
? false
: true
: false;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comes from https://stackoverflow.com/a/59687759/4652564. I don’t understand in detail how it works, but from my testing it looks like it works correctly.

>;
};

function customFnBuilder(
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, I added a comment in fdf6e5b

vRequired(valueValidator),
);

function extractStringLiterals(
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, they were just here to define them in the only place where they are used. I moved them to the global scope in e43940d

>
: never;

function vRequired(validator: GenericValidator) {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, I did it in 54e0622725e77f7de84673b4bc07f7edc84f5e76


type NotUndefined<T> = Exclude<T, undefined>;

type VRequired<T extends Validator<any, OptionalProperty, any>> =
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this is a good idea. I can do this as a follow-up task, I created an issue at #847

Comment on lines 1467 to 1476
type IsUnknownOrAny<T> =
// any?
0 extends 1 & T
? true
: // unknown?
unknown extends T
? [T] extends [unknown]
? true
: false
: false;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For this one I used a LLM to solve the following problems:

  • knowing whether a generic type is any
  • knowing whether a generic type is unknown

I got consistent answers after asking multiple times and my type tests pass, so I think that the solution is correct

Comment on lines 1467 to 1476
type IsUnknownOrAny<T> =
// any?
0 extends 1 & T
? true
: // unknown?
unknown extends T
? [T] extends [unknown]
? true
: false
: false;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Nicolapps Nicolapps linked an issue Nov 15, 2025 that may be closed by this pull request
@Nicolapps Nicolapps merged commit 6472eca into main Nov 15, 2025
3 checks passed
@Nicolapps Nicolapps deleted the nicolas/zod-4-support branch November 15, 2025 02:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Update zod validation to support Zod 4

3 participants