Lodash _.defaults and Deep Destination Mutation
Lodash's _.defaults utility assigns own and inherited
enumerable string-keyed properties from source objects to a destination
object for any destination properties that resolve to
undefined. This article examines how
_.defaults interacts with nested structures, why
pre-existing deeply nested destination objects remain unmutated during
execution, the differences between shallow assignment and recursive
merging, and the security considerations surrounding object manipulation
in JavaScript.
Shallow Execution and Nested Object Integrity
The primary reason _.defaults does not alter
pre-existing deeply nested structures inside a destination object is its
strictly shallow execution model. Unlike recursive utilities such as
_.defaultsDeep or _.merge,
_.defaults operates exclusively on the top-level keys of
the provided objects.
When _.defaults processes a destination object:
- It iterates through the own and inherited enumerable keys of each source object.
- It performs a strict check against the destination object:
destination[key] === undefined. - If the property exists on the destination object—even if it is a
complex, deeply nested object—the check resolves to
false. - The key is skipped entirely, leaving the original nested object and its references untouched.
Because there is no recursive descent into properties that are already present on the destination, the inner properties of an existing nested structure are never evaluated, reassigned, or mutated.
Destination Mutation vs. Nested Non-Mutation
A critical distinction in Lodash is that _.defaults
does mutate the top-level destination object passed as
its first argument, but it does not mutate existing
nested objects within that destination.
Consider the following scenario:
const destination = {
config: {
timeout: 1000,
retries: 3
}
};
const defaults = {
config: {
timeout: 5000,
logging: true
},
env: 'production'
};
_.defaults(destination, defaults);In this execution:
- The root
destinationobject is mutated by havingenv: 'production'assigned to it becausedestination.envwasundefined. destination.configis already defined, so_.defaultsignoresdefaults.config.- The nested
destination.configretains{ timeout: 1000, retries: 3 }exactly as it was. It does not mergelogging: trueor overwritetimeout: 1000.
The deeply nested destination structure remains protected simply because shallow assignment never penetrates child nodes.
Security and Prototype Pollution Prevention
Deeply recursive object operations often introduce vulnerabilities,
most notably prototype pollution, where an attacker injects properties
into Object.prototype via keys like __proto__
or constructor.prototype.
_.defaults safeguards against this risk through multiple
architectural constraints:
- Absence of Recursive Traversal: Because
_.defaultsnever traverses nested paths dynamically based on input, it cannot be tricked into traversing down an arbitrary path provided by malicious user input. - Safe Property Assignment: Lodash checks for
reserved and potentially harmful keys. When assigning properties, it
avoids writing directly to
__proto__. - No In-Place Sub-Object Instantiation: In deep-merge
functions, missing intermediate paths are created on the fly (e.g.,
creating an empty object if
target.a.bdoes not exist)._.defaultssimply performs a top-level reference assignment for missing properties, eliminating logic branches that could be exploited to hijack prototype chains.
The Source Object Reference Caveat
While existing deeply nested properties on the destination object are safe from mutation, properties added from the source object are copied by reference, not cloned.
If a source property is an object:
const source = { settings: { debug: false } };
const destination = {};
_.defaults(destination, source);Here, destination.settings points directly to the memory
address of source.settings. Mutating
destination.settings.debug later in application code will
mutate source.settings.debug. To achieve total immutability
where neither destination nor source references can leak or mutate,
developers must combine _.defaults with cloning techniques,
or supply a fresh target:
const safeObject = _.defaults({}, _.cloneDeep(destination), defaults);Summary
_.defaults avoids mutating existing deeply nested
destination objects by enforcing a shallow evaluation strategy that
terminates whenever a property is already present on the destination.
This prevents unwanted property overrides, bypasses recursive traversal
overhead, and inherently mitigates the prototype pollution vectors
common to recursive deep-merging utilities.