zod icon indicating copy to clipboard operation
zod copied to clipboard

v4: mocking Math.random breaks schema parsing

Open cpetrov opened this issue 10 months ago • 2 comments

When testing code that involves randomness, it's common to mock Math.random to return deterministic values.

However, this breaks zod/v4 when parsing object schemas with more than one key.

To reproduce:

import { z } from 'zod/v4';

Math.random = () => 0.1;

const tenantConfigSchema = z.object({
  foo: z.string().optional(),
  bar: z.string().optional(),
});

const result = tenantConfigSchema.safeParse({ foo: 'baz' }).success;
console.log('Validation result:', result);

Error:

  const ccccccccccccccc = shape["bar"]._zod.run({ value: input["bar"], issues: [] }, ctx);
        ^

SyntaxError: Identifier 'ccccccccccccccc' has already been declared
    at new Function (<anonymous>)
    at Doc.compile (file:///Users/cpetrov/project/node_modules/zod/dist/esm/v4/core/doc.js:33:16)
    at generateFastpass (file:///Users/cpetrov/project/node_modules/zod/dist/esm/v4/core/schemas.js:753:24)
    at inst._zod.parse (file:///Users/cpetrov/project/node_modules/zod/dist/esm/v4/core/schemas.js:779:28)
    at Module.<anonymous> (file:///Users/cpetrov/project/node_modules/zod/dist/esm/v4/core/parse.js:6:32)
    at inst.parse (file:///Users/cpetrov/project/node_modules/zod/dist/esm/v4/classic/schemas.js:29:42)
    at <anonymous> (/Users/cpetrov/project/test.ts:10:35)
    at ModuleJob.run (node:internal/modules/esm/module_job:262:25)
    at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:482:26)
    at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:117:5)

It appears that Zod generates internal variable names for each object key using Math.random. If Math.random is mocked to return a constant number, all generated names collide, causing such an error.

Maybe zod/v4 can be made robust against monkey patching Math.random, for example by using a copy of Math.random stored at module load time?

cpetrov avatar Jun 17 '25 16:06 cpetrov

Hi @cpetrov! I'm Dosu and I’m helping the zod team.

You're correct—Zod v4 uses Math.random to generate internal variable names for object schema validation, which can cause name collisions and syntax errors if Math.random is mocked to always return the same value. This behavior is confirmed in the codebase and matches the error you're seeing "Zod v4 uses Math.random to generate internal variable names during schema parsing".

Currently, Zod does not protect against monkey patching of Math.random (for example, by storing a reference at module load time), so mocking it globally will break schema parsing. As a workaround, you can avoid mocking Math.random globally during tests that involve Zod schema parsing, or restore the original Math.random before running those tests.

Your suggestion to make Zod robust by storing a copy of Math.random at module load time would indeed prevent this issue and could be a worthwhile improvement. If you have further questions or want to discuss possible workarounds, let me know! If this answers your question, please feel free to close the issue.

To reply, just mention @dosu.


How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other  Join Discord Share on X

dosubot[bot] avatar Jun 17 '25 16:06 dosubot[bot]

Perhaps consider a slightly more clever monkey patch. There are much better ways to achieve deterministic pseudorandomness than ()=>0.1.

// choose any non-zero seed
let _seed = 123456789;

// LCG params from Numerical Recipes
const _a = 1664525;
const _c = 1013904223;
const _m = 0x100000000;

// override
Math.random = () => {
  _seed = (_a * _seed + _c) % _m;
  // normalize to [0,1)
  return _seed / _m;
};

// example
console.log(Math.random()); // always same first number
console.log(Math.random()); // and next

In the same vein, I could easily avoid Math.random() entirely with one of these seed-based approaches and will certainly look into this. 👍

colinhacks avatar Jun 17 '25 18:06 colinhacks

I just encountered the same issue. In my case, I used a simple constant value for the return, which was sufficient enough for my test:

jest.spyOn(global.Math, "random").mockReturnValue(0.5);

Additionally, I was able to return the value only once using:

jest.spyOn(global.Math, "random").mockReturnValueOnce(0.5);

hrstkatomas avatar Jun 24 '25 07:06 hrstkatomas

Fixed in https://github.com/colinhacks/zod/pull/4822 and landed in latest

colinhacks avatar Jul 04 '25 08:07 colinhacks