Lodash isPlainObject Behavior with ES6 Proxy
Passing an instantiated ES6 Proxy to Lodash’s
_.isPlainObject method can produce unexpected evaluations,
silent side effects, and structural bugs in data pipelines. Because an
ES6 Proxy transparently forwards internal operations to its target by
default, Lodash evaluates the proxy based on the underlying target's
prototype chain rather than the proxy wrapper itself. This article
breaks down the internal mechanism of _.isPlainObject, how
Proxies interact with its checks, and the practical implications for
deep cloning, state management, and debugging.
How Lodash Evaluates Plain Objects
Lodash defines a plain object as an object created by the
Object constructor or one with a [[Prototype]]
of null. To determine this, _.isPlainObject
performs two primary checks:
- Tag Verification: It checks
Object.prototype.toString.call(value) === '[object Object]'to rule out primitives, functions, arrays, and standard built-ins (likeMaporDate). - Prototype Chain Traversal: It walks up the
prototype chain using
Object.getPrototypeOf(value)until it reaches the root prototype, verifying that the prototype matchesObject.prototypeornull.
Proxy Evaluation Mechanics
An ES6 Proxy does not have its own distinct
[[Prototype]] or type tag; it delegates internal calls
([[GetPrototypeOf]], [[Get]]) to the target
object unless a handler trap intercepts them.
- Plain Target Without Traps: If the proxy wraps a
standard object literal (
{}) and defines nogetPrototypeOftrap,_.isPlainObject(proxy)returnstrue. - Non-Plain Target: If the proxy wraps an instance of
a class, function, or non-plain object,
_.isPlainObject(proxy)returnsfalse. - Trapped Prototypes: If the handler defines a
getPrototypeOftrap, Lodash executes that trap during its prototype traversal. ReturningObject.prototypefrom the trap causes Lodash to evaluate the proxy as a plain object, even if the underlying target is not.
const target = {};
const proxy = new Proxy(target, {});
console.log(_.isPlainObject(proxy)); // true
const classInstance = new (class MyClass {})();
const classProxy = new Proxy(classInstance, {});
console.log(_.isPlainObject(classProxy)); // falseMajor Implications
1. Invalidation of Protective Boundaries
Proxies are commonly used to implement encapsulation, access control,
or reactive wrappers (e.g., Vue Reactivity, MobX). When
_.isPlainObject(proxy) returns true, external
systems assume the object is dumb, inert data. This can bypass developer
expectations that the object is a managed or controlled structure.
2. Trap Execution and Unintended Side Effects
During prototype inspection, Lodash calls
Object.getPrototypeOf. If your proxy defines a
getPrototypeOf trap with logging, analytics, or
lazy-initialization logic, simply running
_.isPlainObject(proxy) executes that code. Similarly, if
the object uses a Symbol.toStringTag getter, the initial
tag validation step triggers property access traps.
3. Destruction of Dynamic Behavior in Merges and Clones
Utilities like _.cloneDeep and _.merge rely
heavily on _.isPlainObject to decide whether to create a
new object and recurse or copy the reference directly.
If a Proxy evaluates as a plain object:
- Lodash creates a new, unproxied plain object literal.
- It iterates over the proxy's enumerable own properties and copies them to the new literal.
- The Proxy wrapper, along with all traps, membrane guarantees, and dynamic getters, is stripped away from the resulting clone.
4. False Negatives via Custom String Tags
If a proxy's target uses Symbol.toStringTag (or
intercepts it via a get trap) to assign a custom type
label, Object.prototype.toString.call(proxy) returns
[object CustomName]. In this scenario,
_.isPlainObject returns false immediately,
even if the object's prototype chain is entirely plain.
Summary of Best Practices
When handling objects that may be Proxies:
- Avoid using
_.isPlainObjectas an assertion that an object is free of side effects; a plain proxy can still intercept reads and writes. - Do not pass state-tracking or reactive proxies to structural Lodash
utilities like
_.cloneDeepor_.mergeunless the stripping of proxy traps is desired behavior. - If you must differentiate between a raw plain object and a proxy,
maintain a
WeakSetof generated proxies within your architecture, as native JavaScript provides no direct mechanism to detect an untrapped Proxy instance.