Skip to content

feat: handle edge cases in plainObject, improve performance#5972

Draft
belgattitude wants to merge 3 commits into
colinhacks:mainfrom
belgattitude:improve-is-plain-object
Draft

feat: handle edge cases in plainObject, improve performance#5972
belgattitude wants to merge 3 commits into
colinhacks:mainfrom
belgattitude:improve-is-plain-object

Conversation

@belgattitude
Copy link
Copy Markdown

@belgattitude belgattitude commented May 8, 2026

Hello here's a proposal to fix some edge cases and improve isPlainObject performance

Tests for cases that weren't (imho) properly checked by the current implementation, notably:

  • Object.create({}) shouldn't be a plain object (open to discuss, see risk below)
  • If using Proxy, they should behave like their targets
  • Instanciating a function (new fct) shouldn't be a plain object

Risk:

const defaults = { host: "localhost" };
const cfg = Object.create(defaults); // This usage will break
cfg.port = 3000;
z.record(z.string(), z.any()).parse(cfg); // was: ok — becomes: invalid_type

Other info

The implementation is taken from https://github.com/belgattitude/httpx/tree/main/packages/plain-object#readme (I'm the author). Note that the tests are passing CI node 20, 22, 24, 26, bun, cloudflare, edge, playwright chromium and deno.

I've tested the performance against the zod implementation to see if there won't be regression. On the specific benchmarks, node 24 -> 1.22x faster, bun 1.3.13 -> 2.76 faster.

Note that benchs might not be realistic from the zod perspective.

Open to discuss

Node 24.15.0

 ✓ bench/comparative.bench.ts > Compare calling isPlainObject with 110x mixed types values 7424ms
     name                                                           hz     min     max    mean     p75     p99    p995    p999     rme  samples
   · "@httpx/plain-object": `isPlainObject(v)`            1,267,258.73  0.0007  0.8912  0.0008  0.0007  0.0015  0.0018  0.0102  ±0.67%   633631
   · "is-plain-obj":"4.1.0": 'isPlainObj(v)'              1,167,969.53  0.0007  2.5366  0.0009  0.0007  0.0015  0.0018  0.0104  ±1.27%   583985
   · "@sindresorhus/is":"8.0.0": 'is.plainObject(v)'      1,176,458.30  0.0007  0.7647  0.0009  0.0007  0.0014  0.0018  0.0130  ±0.77%   588230
   · "zod":"4.4.3": 'zod.util.isPlainObject(v)'             997,663.61  0.0008  1.8923  0.0010  0.0009  0.0017  0.0022  0.0143  ±1.08%   498832
   · "es-toolkit":"1.46.1": 'isPlainObject(v)'            1,040,376.28  0.0008  0.5971  0.0010  0.0008  0.0020  0.0022  0.0155  ±0.67%   520189
   · "redux":"5.0.1": 'isPlainObject(v)'                    406,199.96  0.0020  1.0312  0.0025  0.0021  0.0052  0.0061  0.0251  ±0.82%   203116
   · "is-plain-object":"5.0.0": 'isPlainObject(v)'          583,263.88  0.0014  4.1774  0.0017  0.0015  0.0035  0.0040  0.0207  ±1.81%   291632
   · "immer/is-plain-object":"4.2.0": 'isPlainObject(v)'    477,915.74  0.0019  0.6726  0.0021  0.0019  0.0038  0.0050  0.0208  ±0.62%   238958
   · lodash-es:"4.18.1": '_.isPlainObject(v)'                16,747.84  0.0467  1.8359  0.0597  0.0646  0.1725  0.2622  0.5797  ±1.46%     8375

 BENCH  Summary
                                                                                                                                                                                    
  "@httpx/plain-object": `isPlainObject(v)` - bench/comparative.bench.ts > Compare calling isPlainObject with 110x mixed types values
    1.08x faster than "@sindresorhus/is":"8.0.0": 'is.plainObject(v)'
    1.09x faster than "is-plain-obj":"4.1.0": 'isPlainObj(v)'
    1.22x faster than "es-toolkit":"1.46.1": 'isPlainObject(v)'
    1.27x faster than "zod":"4.4.3": 'zod.util.isPlainObject(v)'
    2.17x faster than "is-plain-object":"5.0.0": 'isPlainObject(v)'
    2.65x faster than "immer/is-plain-object":"4.2.0": 'isPlainObject(v)'
    3.12x faster than "redux":"5.0.1": 'isPlainObject(v)'
    75.67x faster than lodash-es:"4.18.1": '_.isPlainObject(v)'

Bun 1.3.13

✓ bench/comparative.bench.ts > Compare calling isPlainObject with 110x mixed types values 8270ms
     name                                                           hz     min     max    mean     p75     p99    p995    p999     rme  samples
   · "@httpx/plain-object": `isPlainObject(v)`            3,035,494.74  0.0002  2.0300  0.0003  0.0003  0.0006  0.0007  0.0018  ±0.98%  1517748
   · "is-plain-obj":"4.1.0": 'isPlainObj(v)'              2,910,611.34  0.0003  0.9960  0.0003  0.0003  0.0006  0.0007  0.0019  ±0.86%  1455306
   · "@sindresorhus/is":"8.0.0": 'is.plainObject(v)'      3,195,461.39  0.0003  1.8672  0.0003  0.0003  0.0006  0.0007  0.0017  ±1.15%  1597731
   · "zod":"4.4.3": 'zod.util.isPlainObject(v)'           1,158,960.66  0.0008  0.5257  0.0009  0.0008  0.0016  0.0020  0.0052  ±0.46%   579481
   · "es-toolkit":"1.46.1": 'isPlainObject(v)'            1,096,506.16  0.0008  1.5147  0.0009  0.0008  0.0016  0.0020  0.0102  ±0.80%   548254
   · "redux":"5.0.1": 'isPlainObject(v)'                  1,357,176.37  0.0005  2.1069  0.0007  0.0006  0.0013  0.0015  0.0067  ±1.31%   678589
   · "is-plain-object":"5.0.0": 'isPlainObject(v)'          567,347.42  0.0014  4.8731  0.0018  0.0015  0.0036  0.0043  0.0184  ±3.94%   283674
   · "immer/is-plain-object":"4.2.0": 'isPlainObject(v)'  1,607,338.90  0.0005  1.3589  0.0006  0.0006  0.0011  0.0014  0.0058  ±0.92%   803670
   · lodash-es:"4.18.1": '_.isPlainObject(v)'                40,471.25  0.0073  2.2837  0.0247  0.0289  0.1214  0.1528  0.3628  ±1.82%    20236

 BENCH  Summary
                                                                                                                                                                                    
  "@sindresorhus/is":"8.0.0": 'is.plainObject(v)' - bench/comparative.bench.ts > Compare calling isPlainObject with 110x mixed types values
    1.05x faster than "@httpx/plain-object": `isPlainObject(v)`
    1.10x faster than "is-plain-obj":"4.1.0": 'isPlainObj(v)'
    1.99x faster than "immer/is-plain-object":"4.2.0": 'isPlainObject(v)'
    2.35x faster than "redux":"5.0.1": 'isPlainObject(v)'
    2.76x faster than "zod":"4.4.3": 'zod.util.isPlainObject(v)'
    2.91x faster than "es-toolkit":"1.46.1": 'isPlainObject(v)'
    5.63x faster than "is-plain-object":"5.0.0": 'isPlainObject(v)'
    78.96x faster than lodash-es:"4.18.1": '_.isPlainObject(v)'

@pullfrog
Copy link
Copy Markdown
Contributor

pullfrog Bot commented May 8, 2026

TL;DR — Replaces the constructor/isPrototypeOf heuristic in util.isPlainObject with a direct prototype-chain check, fixing false positives on Object.create({}) and custom constructors while running faster on the author's benchmark (1.22x on Node 24, 2.76x on Bun 1.3.13 vs the 4.4.3 baseline).

Key changes

  • Rewrite isPlainObject around Object.getPrototypeOf — accepts only values whose prototype is null, Object.prototype, or a prototype whose own prototype is null (the node:vm.runInNewContext case).
  • Expand isPlainObject test coverage — adds cases for nested Object.create chains, Proxy-wrapped plain and non-plain targets, and instances of a non-Object constructor.

Summary | 2 files | 3 commits | base: mainimprove-is-plain-object


Tighter plain-object detection

Before: Inspected o.constructor.prototype and checked for an own isPrototypeOf property, which treated Object.create({}) and new CustomCtor() (where the ctor's prototype still carries an inherited isPrototypeOf) as plain objects.
After: Walks the prototype chain directly, accepting only null, Object.prototype, or a prototype whose parent is null (cross-realm objects from node:vm).

The new check is both stricter about what counts as plain and simpler — four lines of prototype comparisons replace the five-step constructor dance. Proxy targets still report correctly because Object.getPrototypeOf traverses the proxy's getPrototypeOf trap.

packages/zod/src/v4/core/util.ts


Edge cases locked in by tests

Before: Object.create({}), nested Object.create chains, non-Object constructor instances, and Proxy wrappers around non-plain targets had no dedicated assertions.
After: Each case is pinned with an expect(...).toEqual(...), so regressions in either direction surface immediately.

Notably, Object.create(Object.create(null)) is asserted as true (two-hop chain ending in null) while Object.create(Object.create({})) is false, and new Proxy(new Date(), { get: (t) => t }) is false — the proxy's prototype chain leads back to Date.prototype, which the new implementation correctly rejects.

packages/zod/src/v4/classic/tests/index.test.ts

Pullfrog  | View workflow run | via Pullfrog | Using Claude Opus𝕏

@belgattitude
Copy link
Copy Markdown
Author

Some bench from the zod benchmark suite:

cpu: AMD Ryzen 7 8845HS w/ Radeon 780M Graphics
runtime: node v24.15.0 (x64-linux)

Current implementation

benchmark      time (avg)             (min … max)       p75       p99      p999
------------------------------------------------- -----------------------------
• z.object().parse
------------------------------------------------- -----------------------------
zod3          369 µs/iter     (286 µs … 1'722 µs)    350 µs    975 µs  1'608 µs
zod4       38'113 ns/iter  (22'226 ns … 2'710 µs) 42'203 ns    116 µs    445 µs
valibot       254 µs/iter     (186 µs … 1'640 µs)    331 µs    577 µs  1'247 µs

benchmark      time (avg)             (min … max)       p75       p99      p999
------------------------------------------------- -----------------------------
• safeparse vs parse — fail
------------------------------------------------- -----------------------------
parse      26'849 µs/iter (18'677 µs … 44'032 µs) 28'072 µs 44'032 µs 44'032 µs
safeparse  16'603 µs/iter (12'234 µs … 32'008 µs) 18'549 µs 32'008 µs 32'008 µs

New Implementation

benchmark      time (avg)             (min … max)       p75       p99      p999
------------------------------------------------- -----------------------------
• z.object().parse
------------------------------------------------- -----------------------------
zod3          379 µs/iter     (283 µs … 4'860 µs)    383 µs   1000 µs  4'233 µs
zod4       34'283 ns/iter  (21'430 ns … 1'055 µs) 35'239 ns    131 µs    469 µs
valibot       251 µs/iter     (180 µs … 1'866 µs)    334 µs    670 µs  1'312 µs

benchmark      time (avg)             (min … max)       p75       p99      p999
------------------------------------------------- -----------------------------
• safeparse vs parse — fail
------------------------------------------------- -----------------------------
parse      22'330 µs/iter (16'774 µs … 36'468 µs) 24'843 µs 36'468 µs 36'468 µs
safeparse  16'345 µs/iter (11'504 µs … 35'814 µs) 19'047 µs 35'814 µs 35'814 µs

Copy link
Copy Markdown
Contributor

@pullfrog pullfrog Bot left a comment

Choose a reason for hiding this comment

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

Important

The new isPlainObject is cleaner and faster, but it silently changes observable parse behavior for z.record() (and intersection merging) on inputs like Object.create({ ... }), which used to be accepted and now produce invalid_type. That's worth surfacing in the PR description, and the cross-realm branch is loose enough to be worth tightening.

A couple of specific things:

  • Object.create(someNonNull) now fails z.record(). The old impl treated any object whose prototype chain reached Object.prototype as plain (because it ultimately checked for an own isPrototypeOf on ctor.prototype). Under the new impl, only direct {} / Object.create(null) / cross-realm objects are plain, so z.record(...).parse(Object.create({ foo: 1 })) now fails where it previously succeeded. The new test at index.test.ts:846 locks this in without calling it out. I'd mention it explicitly in the PR body — it's the kind of thing users hit via inherited-config patterns.

  • The cross-realm branch is too permissive. Object.getPrototypeOf(proto) === null isn't specific to vm.runInNewContext({}); it also matches Object.create(Object.create(null)), Object.setPrototypeOf({}, Object.create(null)), and any other two-deep chain ending in null. If the goal is genuinely cross-realm {} and nothing else, Object.prototype.toString.call(o) === "[object Object]" is a sharper test, or compare proto.constructor?.name === "Object".

  • Proxy coverage is weaker than the tests imply. new Proxy(new Date(), { getPrototypeOf: () => Object.prototype }) passes the new check as a plain object, because the getPrototypeOf trap is authoritative. The existing Proxy assertions only work because no trap is installed and the chain falls through to the target. Worth either adding a trap-based counterexample to document the limit, or dropping the Proxy-guarantee framing from the PR description.

The perf improvement and the Object.create(null) / vm-context handling are genuine wins. Thanks for the benchmarks.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

proto === null ||
proto === Object.prototype ||
// Required to support node:vm.runInNewContext({})
Object.getPrototypeOf(proto) === null
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Object.getPrototypeOf(proto) === null also returns true for Object.create(Object.create(null)) and Object.setPrototypeOf(new Date(), Object.create(null)) — any prototype chain that's exactly two deep with a null top. If the intent is strictly cross-realm {}, consider Object.prototype.toString.call(o) === "[object Object]" or proto.constructor?.name === "Object" for a tighter check.

test("isPlainObject", () => {
expect(z.core.util.isPlainObject({})).toEqual(true);
expect(z.core.util.isPlainObject(Object.create(null))).toEqual(true);
expect(z.core.util.isPlainObject(Object.create({}))).toEqual(false);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Worth calling out in the PR description that this is a user-visible behavior change: z.record(...).parse(Object.create({ foo: 1 })) now rejects with invalid_type where it previously succeeded (the old impl accepted anything whose chain reached Object.prototype). Not necessarily wrong — it matches other libs — but it's a breaking parse-behavior change worth surfacing.

get(target, _prop, _receiver) {
return target;
},
}))).toEqual(false)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

These Proxy assertions only hold because no getPrototypeOf trap is installed. new Proxy(new Date(), { getPrototypeOf: () => Object.prototype }) passes as plain under the new impl. Consider adding that as a counterexample, or phrasing the PR goal as "transparent proxies behave like their targets" rather than "Proxy is properly detected".

Copy link
Copy Markdown
Contributor

@pullfrog pullfrog Bot left a comment

Choose a reason for hiding this comment

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

Important

The new implementation matches @httpx/plain-object exactly, which is what the PR claims. Nice perf win. Two asks before merging: (1) call out the user-visible behavior change for Object.create(plainObj) in the PR description, since it flips a parsing result for z.record(...) and z.intersection(...) on inputs that previously round-tripped, and (2) add a tripwire test for the vm.runInNewContext case that motivates the 2-level prototype walk.

isPlainObject is part of the public surface via z.core.util.isPlainObject and is consumed internally by shallowClone, mergeValues (intersections) at core/schemas.ts:2508, record parsing at core/schemas.ts:2881, and normalizeObjectLikeDef at core/util.ts:653/681, so any behavioral flip ripples into those code paths.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

// Required to support node:vm.runInNewContext({})
Object.getPrototypeOf(proto) === null
);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is a user-visible behavior change worth documenting in the PR description: Object.create(plainObj) flips from true to false. The diff's test additions at index.test.ts:847-848 encode the flip but don't frame it as breaking.

Concrete impact:

const defaults = { host: "localhost" };
const cfg = Object.create(defaults);
cfg.port = 3000;
z.record(z.string(), z.any()).parse(cfg); // was: ok — becomes: invalid_type

Same story for z.intersection(...)mergeValues at core/schemas.ts:2508 gates on isPlainObject(a) && isPlainObject(b), so transformed outputs that previously merged will now throw Unmergable intersection. The direction of the flip matches lodash / is-plain-obj / @sindresorhus/is consensus (the old zod behavior was the outlier), so the change is defensible — just worth a line in the PR body so Colin can decide whether to call it out in release notes.

proto === null ||
proto === Object.prototype ||
// Required to support node:vm.runInNewContext({})
Object.getPrototypeOf(proto) === null
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The comment names node:vm.runInNewContext({}) as the reason for the 2-level walk, but no test exercises that path — the only case that lands on this branch in the added tests is the synthetic Object.create(Object.create(null)). Worth a tripwire so a future simplification to a single-level check doesn't silently regress cross-realm support:

import vm from "node:vm";
expect(z.core.util.isPlainObject(vm.runInNewContext("({ a: 1 })"))).toEqual(true);
expect(z.core.util.isPlainObject(vm.runInNewContext("[]"))).toEqual(false);
expect(z.core.util.isPlainObject(vm.runInNewContext("new Date()"))).toEqual(false);

@belgattitude belgattitude marked this pull request as draft May 8, 2026 13:44
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.

1 participant