Skip to content

fix(hir/codegen): new <imported-function>() runs the constructor body (zod v4 checks; #4698)#4769

Merged
proggeramlug merged 1 commit into
mainfrom
fix-4698-zod-checks
Jun 8, 2026
Merged

fix(hir/codegen): new <imported-function>() runs the constructor body (zod v4 checks; #4698)#4769
proggeramlug merged 1 commit into
mainfrom
fix-4698-zod-checks

Conversation

@proggeramlug

Copy link
Copy Markdown
Contributor

Summary

Fixes #4698. new F(args) where F is a function — or a const/let holding a closure value — imported from another module silently produced an empty object: the constructor body never ran, so this.x = … and Object.defineProperty(this, …) writes were lost.

This is the zod-v4 TypeError: Cannot read properties of undefined (reading 'onattach') crash. Every string/number schema with a check (.min / .max / .length / .regex / numeric .gt / .lt / …) builds its check via new checks.$ZodCheckMinLength(def) across the core/checks.tscore/api.ts module boundary (where checks is an import * as namespace), so the check instance came back with its non-enumerable _zod property (and everything else) gone — then crashed on ch._zod.onattach.

Note: the original issue analysis suspected a defineProperty attribute-retention bug. The real cause is the cross-module new path; a minimal new <imported closure>() loses all own properties because the constructor body is skipped entirely.

Root cause

An imported binding is neither a local, a func, nor a class in the importing module, so it fell through to Expr::New { class_name } — whose codegen finds no matching class and emits an empty placeholder — instead of js_new_function_construct, which binds this, runs the body, and returns the populated instance. Three gaps, all on the cross-module new path:

  • perry-hir lower/expr_new.rs — bare new <importedFn>() now routes to NewDynamic { callee: ExternFuncRef, … } when the name resolves via lookup_imported_func, mirroring the existing local / FuncRef branches.
  • perry-codegen expr/new_dynamic.rsExternFuncRef added to the routes_through_function_construct callee set so it reaches js_new_function_construct instead of the best-effort empty-object fallback.
  • perry-codegen expr/v8_interop.rs::try_static_class_namenew ns.Foo() over a namespace import only takes the class-construction path when Foo actually names a known class; a closure-valued const export (zod's $ZodCheckMinLength = $constructor(...)) now falls through to the function-construct route. Real namespace-imported classes (in ctx.classes) and re-exported builtins (intercepted by the helper's global thunks) are unaffected.

Verification

The exact repro now matches Node:

import { z } from "zod";
console.log(z.string().min(2).parse("hello")); // → hello

.max / .length / .regex / numeric .gt / .lt and nested object schemas with checks all match Node byte-for-byte. Minimal multi-module + namespace-import repros pass; real namespace-imported classes still construct correctly.

  • perry-hir + perry-codegen unit tests: pass.
  • Class parity filter: test_issue_836_zod_class_reexports (cross-module class re-export, directly in the change's blast radius) passes. The pre-existing class-suite failures (iterators, mixins, defineProperty-on-prototype, stream subclass) are single-file programs with zero imports — and all three changes here require a cross-module import to fire, so they are provably inert for those files.

Out of scope

The safeParse error-path / .email() error-formatting failures noted in #4698 are a separate, still-open issue.

Refs #793.

…zod v4 checks; #4698)

`new F(args)` where `F` is a function — or a `const`/`let` holding a closure
value — imported from another module silently produced an empty object: the
constructor body never ran, so `this.x = …` and `Object.defineProperty(this, …)`
writes were lost. This is the zod-v4 `TypeError: Cannot read properties of
undefined (reading 'onattach')` crash (#4698): every string/number schema with a
check (`.min`/`.max`/`.length`/`.regex`/`.gt`/`.lt`/…) builds its check via
`new checks.$ZodCheckMinLength(def)` across the core/checks.ts → core/api.ts
module boundary (where `checks` is `import * as`), and the check came back with
its non-enumerable `_zod` (and everything else) gone.

An imported binding that isn't a registered class fell through to an empty-object
placeholder instead of `js_new_function_construct` (binds `this`, runs the body,
returns the populated instance). The fix lives entirely in codegen: at
HIR-lowering time an imported class and an imported function are indistinguishable
(both unknown to `lookup_class`), and the cross-module class-inline machinery in
`collect_modules` relies on `new <ImportedClass>()` staying as
`Expr::New { class_name }` — so HIR must not reroute it.

- perry-codegen lower_call/new.rs: when `lower_new` finds no class for the name
  but the name resolves to an imported binding (`import_function_prefixes`,
  excluding V8-fallback specifiers), lower it as an `ExternFuncRef` value and
  construct via `js_new_function_construct` instead of the empty placeholder.
  Imported classes are in `ctx.classes` and take the construction path, so they
  never reach this fallback (guarded by the existing
  `dependency_is_transformed_before_importer_for_cross_module_inline` test).
- perry-codegen expr/v8_interop.rs (`try_static_class_name`) + expr/new_dynamic.rs:
  `new ns.Foo()` over a namespace import only takes the class path when `Foo`
  actually names a known class; a closure-valued `const` export (zod's
  `$ZodCheckMinLength = $constructor(...)`) falls through to the NewDynamic
  function-construct route (`ExternFuncRef` added to its callee set). Real
  namespace-imported classes and re-exported builtins are unaffected.

`z.string().min(2).parse("hello")` now prints `hello`; `.max`/`.length`/`.regex`/
`.gt`/`.lt` and nested object schemas with checks match Node byte-for-byte. Full
`perry` bin test suite (469) green. The `safeParse` error-path / `.email()`
error-formatting failures noted in #4698 are a separate, still-open issue. Refs
#793.
@proggeramlug proggeramlug force-pushed the fix-4698-zod-checks branch from 9a3f28e to cf736d2 Compare June 7, 2026 20:49
@proggeramlug proggeramlug merged commit f121ed4 into main Jun 8, 2026
13 checks passed
@proggeramlug proggeramlug deleted the fix-4698-zod-checks branch June 8, 2026 04:51
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.

zod v4: .string().min() (checks) crashes — ch._zod.onattach undefined under compilePackages

1 participant