Skip to content

Update zod 3 to zod 4 support in p5.js dev-2.0 #7872

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: dev-2.0
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
"omggif": "^1.0.10",
"pako": "^2.1.0",
"pixelmatch": "^7.1.0",
"zod": "^3.23.8"
"zod": "^3.25.51"
},
"devDependencies": {
"@rollup/plugin-alias": "^5.1.1",
Expand Down
27 changes: 17 additions & 10 deletions src/core/friendly_errors/param_validator.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* @requires core
*/
import * as constants from '../constants.js';
import * as z from 'zod';
import { z } from 'zod/v4';
import dataDoc from '../../../docs/parameterData.json';

function validateParams(p5, fn, lifecycles) {
Expand Down Expand Up @@ -230,6 +230,10 @@ function validateParams(p5, fn, lifecycles) {
param = param?.replace(/^\.\.\.(.+)\[\]$/, '$1');

let schema = generateTypeSchema(param);
if (!schema || typeof schema.optional !== 'function') {
Copy link
Contributor

Choose a reason for hiding this comment

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

Just curious what test case was generating an undefined schema? This works as a fallback, but I wonder if there's potentially a deeper issue needing fixing that this hides.

Copy link
Author

@madhav2348 madhav2348 Jun 6, 2025

Choose a reason for hiding this comment

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

In Zod 3, an undefined somehow works fine schema.optional(), but in Zod 4 throws a TypeError , and 181 test cases failed in WebGL, Type and Visual . So using z.any() as safeguard

Internal clip operations do not enable stencil test for future draw cycles
TypeError: Cannot read properties of undefined (reading 'optin')
 - /node_modules/.vite/deps/zod_v4.js:2884:86
 - /node_modules/.vite/deps/zod_v4.js:2884:56
 - /node_modules/.vite/deps/zod_v4.js:464:5
 - /node_modules/.vite/deps/zod_v4.js:10325:13
 - /node_modules/.vite/deps/zod_v4.js:464:5
 - /node_modules/.vite/deps/zod_v4.js:479:5
 - /node_modules/.vite/deps/zod_v4.js:10336:10
 - /src/core/friendly_errors/param_validator.js:284:26
matches expected screenshots
TypeError: schema.optional is not a function
 - /src/core/friendly_errors/param_validator.js:238:25
 - /src/core/friendly_errors/param_validator.js:276:21
 - /src/core/friendly_errors/param_validator.js:276:12
 - /src/core/friendly_errors/param_validator.js:274:27
 - /src/core/friendly_errors/param_validator.js:271:39
 - /src/core/friendly_errors/param_validator.js:538:24
 - /src/core/friendly_errors/param_validator.js:569:16

This could work by throw an error here to catch real bugs early instead of hiding them with z.any().
Its just a thought, not sure if this would works,

Copy link
Contributor

Choose a reason for hiding this comment

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

I tried adding this:

let schema = generateTypeSchema(param);
if (typeof schema.optional !== 'function') {
  console.log(param, schema);
}

...and then running just that failing test by changing it to test.only(...), and I see this log:

image

So it looks like z.function() is responsible for this? I think we don't want to turn this into an any type, but rather, figure out how we can keep it a function, but make it possible to be optional. Maybe something is different about how functions are treated in zod 4?

Copy link
Author

Choose a reason for hiding this comment

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

My bad. In zod 4, the docs say:
'' The result of z.function() is no longer a Zod schema. Instead, it acts as a standalone "function factory" for defining Zod-validated functions. The API has also changed; you define an input and output schema upfront, instead of using args() and .returns() methods. ''

Do you have any suggestions on this? Because any() feels like a brute-force solution.
I was considering a custom wrapper using z.custom(). But would love to hear your thoughts on this

https://zod.dev/v4/changelog?id=zfunction
https://zod.dev/api?id=functions

schema = z.any();
}

if (isOptional) {
schema = schema.optional();
}
Expand Down Expand Up @@ -318,7 +322,7 @@ function validateParams(p5, fn, lifecycles) {
}

const numArgs = args.length;
const schemaItems = schema.items;
const schemaItems = schema.def.items;
const numSchemaItems = schemaItems.length;
const numRequiredSchemaItems = schemaItems.filter(item => !item.isOptional()).length;

Expand Down Expand Up @@ -353,11 +357,11 @@ function validateParams(p5, fn, lifecycles) {
};

// Default to the first schema, so that we are guaranteed to return a result.
let closestSchema = schema._def.options[0];
let closestSchema = schema.def.options[0];
// We want to return the schema with the lowest score.
let bestScore = Infinity;

const schemaUnion = schema._def.options;
const schemaUnion = schema.def.options;
schemaUnion.forEach(schema => {
const score = scoreSchema(schema);
if (score < bestScore) {
Expand Down Expand Up @@ -386,7 +390,7 @@ function validateParams(p5, fn, lifecycles) {
// (after scoring the schema closeness in `findClosestSchema`). Here, we
// always print the first error so that user can work through the errors
// one by one.
let currentError = zodErrorObj.errors[0];
let currentError = zodErrorObj.issues[0];

// Helper function to build a type mismatch message.
const buildTypeMismatchMessage = (actualType, expectedTypeStr, position) => {
Expand All @@ -403,24 +407,27 @@ function validateParams(p5, fn, lifecycles) {
const expectedTypes = new Set();
let actualType;

error.unionErrors.forEach(err => {
const issue = err.issues[0];
error.errors.forEach(err => {
const issue = err[0];
if (issue) {
if (!actualType) {
actualType = issue.received;
actualType = issue.message;
}

if (issue.code === 'invalid_type') {
actualType = issue.message.split(', received ')[1]
expectedTypes.add(issue.expected);
}
// The case for constants. Since we don't want to print out the actual
// constant values in the error message, the error message will
// direct users to the documentation.
else if (issue.code === 'invalid_literal') {
else if (issue.code === 'invalid_value') {
expectedTypes.add("constant (please refer to documentation for allowed values)");
actualType = args[error.path[0]];
} else if (issue.code === 'custom') {
const match = issue.message.match(/Input not instance of (\w+)/);
if (match) expectedTypes.add(match[1]);
actualType = undefined
}
}
});
Expand Down Expand Up @@ -452,7 +459,7 @@ function validateParams(p5, fn, lifecycles) {
break;
}
case 'invalid_type': {
message += buildTypeMismatchMessage(currentError.received, currentError.expected, currentError.path.join('.'));
message += buildTypeMismatchMessage(currentError.message.split(', received ')[1], currentError.expected, currentError.path.join('.'));
break;
}
case 'too_big': {
Expand Down
4 changes: 2 additions & 2 deletions test/unit/core/param_errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ suite('Validate Params', function () {
{ fn: 'rect', name: 'null, non-trailing, optional parameter', input: [0, 0, 0, 0, null, 0, 0, 0], msg: '🌸 p5.js says: Expected number at the fifth parameter, but received null in p5.rect().' },
{ fn: 'color', name: 'too many args + wrong types too', input: ['A', 'A', 0, 0, 0, 0, 0, 0, 0, 0], msg: '🌸 p5.js says: Expected at most 4 arguments, but received more in p5.color(). For more information, see https://p5js.org/reference/p5/color.' },
{ fn: 'line', name: 'null string given', input: [1, 2, 4, 'null'], msg: '🌸 p5.js says: Expected number at the fourth parameter, but received string in p5.line().' },
{ fn: 'line', name: 'NaN value given', input: [1, 2, 4, NaN], msg: '🌸 p5.js says: Expected number at the fourth parameter, but received nan in p5.line().' }
{ fn: 'line', name: 'NaN value given', input: [1, 2, 4, NaN], msg: '🌸 p5.js says: Expected number at the fourth parameter, but received NaN in p5.line().' }
];

invalidInputs.forEach(({ name, input, fn, msg }) => {
Expand Down Expand Up @@ -278,4 +278,4 @@ suite('Validate Params', function () {
assert.isFalse(result.success);
});
});
});
});