Lodash _.defaultsDeep() method
What you’ll learn
- How
_.defaultsDeep(object, ...sources)recursively fills only theundefinedleaves of nested plain objects and arrays. - Why a defined value on the destination always wins—same precedence rule as _.defaults, just applied at every depth.
- How arrays are recursed by index (not replaced wholesale).
- Try each example in the editor (
?tryit=1,2,3) with Lodash from a CDN.
Prerequisites
Skim _.defaults() first—defaultsDeep is the recursive twin with the same “destination wins” precedence rule.
- Nested object trees: mental model of walking
obj.a.b.cstep by step, sincedefaultsDeeprecurses through plain objects. - undefined vs null: only
undefinedleaves are filled—nullcounts as already set. - Mutation awareness: the destination is written in place; pass
{}first when you don’t want callers to share the result.
Overview
_.defaultsDeep walks each source left to right and recurses into matching plain-object and array branches. At every leaf it writes the source value only when the destination’s value at that path is undefined. Everything else is left untouched.
Recursive, leaf by leaf
A defined nested object no longer blocks defaults below it—each undefined leaf inside can still be filled independently.
Existing values are sacred
Same precedence rule as _.defaults: destination wins, then the first source to define a leaf wins.
Arrays = recursed by index
Arrays aren’t opaque values: each index is compared and filled the same way as object keys.
Syntax
_.defaultsDeep(object, [...sources]) - object: destination; mutated in place.
- sources: zero or more source objects walked left to right; recursed into plain objects and arrays.
- Write rule: a leaf is written only when the destination’s value at that path is
undefined. - Returns: the (now-mutated) destination object.
Nested config: keep what’s defined, fill what’s missing
User-supplied theme and font.size survive; font.family is filled from the defaults because the user didn’t specify one.
import defaultsDeep from "lodash/defaultsDeep";
const userConfig = {
theme: "dark",
font: { size: 16 }
};
const defaults = {
theme: "light",
font: { size: 14, family: "Arial" }
};
defaultsDeep(userConfig, defaults);
// userConfig -> {
// theme: "dark", // user wins
// font: { size: 16, family: "Arial" } // size wins; family filled
// } defaultsDeep vs defaults on nested data
A defined nested object stops _.defaults in its tracks; _.defaultsDeep walks inside and fills the still-undefined leaves.
import defaults from "lodash/defaults";
import defaultsDeep from "lodash/defaultsDeep";
const baseShape = () => ({ user: { name: "Guest" } });
const fillFrom = { user: { name: "Admin", role: "viewer" } };
defaults(baseShape(), fillFrom);
// -> { user: { name: "Guest" } }
// ^ user already defined as an object, so SHALLOW
// defaults bails out and the whole branch is kept.
defaultsDeep(baseShape(), fillFrom);
// -> { user: { name: "Guest", role: "viewer" } }
// ^^^^^^^^^^^^ ^^^^^^^^^^^^^^
// kept filled (was undefined) Arrays are recursed by index (not replaced)
This catches many readers off guard. Arrays are treated like objects keyed by their numeric indexes: existing positions are protected and only sparse/longer source positions get filled in.
import defaultsDeep from "lodash/defaultsDeep";
const target = { tags: ["a", "b"] };
const source = { tags: ["x", "y", "z"] };
defaultsDeep(target, source);
// target -> { tags: ["a", "b", "z"] }
// ^^^ ^^^ ^^^
// kept kept index 2 was undefined -> filled
// To replace an array wholesale, treat it as a leaf you set yourself:
const target2 = { tags: undefined };
defaultsDeep(target2, source);
// target2 -> { tags: ["x", "y", "z"] } (entire array filled) 📋 _.defaultsDeep vs _.defaults vs _.merge
| Topic | _.defaultsDeep | _.defaults | _.merge |
|---|---|---|---|
| Depth | Recursive (plain objects + arrays) | Shallow | Recursive (plain objects + arrays) |
| Precedence on conflict | Destination wins; first source fills | Destination wins; first source fills | Source wins (last source overwrites) |
Replaces null? | No—only undefined | No—only undefined | Yes |
| Arrays | Walked by index | Replaced wholesale | Walked by index |
| Typical use | Layered nested config (user + defaults) | Flat option fallbacks | Combining nested data where source should win |
Pick _.defaultsDeep when you want “keep what the user gave me, fill the rest” recursively. Switch to _.merge the moment defaults should override.
Pitfalls to avoid
Array merge is positional
Old docs sometimes claim arrays are “treated as values.” They aren’t—each index is compared, so source values can sneak in past the end of a shorter destination array. Replace the array yourself if you need wholesale substitution.
null blocks the default
Only undefined counts as missing. A leaf set to null remains null. Sanitize upstream or pre-process with _.assignInWith.
Mutates and shares references
The destination is written in place, and any new nested objects/arrays it gains are references from the source. Mutating them later mutates the source too. Pass {} first or deep-clone afterwards if you need isolation.
Prototype-pollution surface
Like every deep merge, walking attacker-controlled JSON can write __proto__ or constructor.prototype keys. Strip or validate the source shape before merging untrusted input.
❓ FAQ
Summary
- Purpose: recursively fill
undefinedleaves on the destination using plain-object and array sources. - Remember: destination wins; first source fills; arrays merged by index;
nullis NOT replaced; mutates the destination. - Next: Lodash _.findKey(), _.defaults(), or the official Lodash docs for _.defaultsDeep.
_.defaultsDeep recurses into arrays by index—not as opaque values. defaultsDeep({ tags: ['a','b'] }, { tags: ['x','y','z'] }) yields { tags: ['a','b','z'] }: indexes 0 and 1 are defined on the destination, so only index 2 is filled.
6 people found this page helpful
