A deployment sets one logging field in its config file, and the service starts with no log format at all. Spreading the file over the defaults replaced the whole log object with the one key the file mentioned, and every sibling under it disappeared. The setting that broke was the one nobody touched.
What you get
You will end up with a config loader that layers sources so each one overrides only what it names, and leaves the objects it read unchanged. This is for you if config arrives from several places.
Short answer
Deep merge the sources in order of precedence, least specific first, so a later layer overrides only the keys it names and leaves the rest of a nested object alone. Object spread cannot do this: it replaces a nested object wholesale. Clone each source before merging, because the common merge functions write into the objects they are given.
You will need
Node 22 or later, and configuration from more than one source. The example layers defaults, a file, the environment and command-line flags, which is the usual order of precedence.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| deepmerge | You want array handling you choose rather than inherit | Another dependency, and its default array behavior is to concatenate rather than replace | Arrays never appear in your configuration |
| lodash.merge | Any project, and the behavior most reviewers will already recognize | It writes into its first argument, so a call over your defaults edits them in place | You need arrays replaced rather than merged by index |
| Object spread | Flat configuration with no nesting at all | A nested object is replaced whole, so one key in a layer discards its siblings | Configuration has any nesting |
| struct merge | You already use struct to read or transform the same data | It writes into the objects it is handed, including nested ones lifted from later sources | lodash is already a dependency |
Voxgig maintains struct. It is one of four options here, not the recommendation.
Three of the four do the same thing to nested objects and differ on arrays and on what they mutate. Spread is the one that behaves differently in kind, and it is also the one already in the language, which is why the bug in the opening paragraph is common.
Layer the sources in order
Precedence is argument order, and the clone is what keeps the sources reusable.
export function loadConfig({ defaults, file = {}, env = {}, flags = {} }) {
return merge([defaults, file, env, flags].map(clone))
}
Least specific first, most specific last. Flags beat the environment, which beats the file, which beats the defaults, and each layer only affects the keys it sets.
That ordering is a convention rather than a rule, and it is worth stating in your own documentation because callers will assume it either way. The twelve-factor guidance on configuration argues for the environment carrying deployment-specific values, which ranks it over a file checked into the repository and below a flag somebody typed deliberately.
The .map(clone) is not defensive style. Both struct’s merge and lodash’s write into the object they
are handed, so passing module-level defaults directly edits them, and the second call to loadConfig
returns something different from the first. That failure is invisible in a process that loads config
once and obvious in a test suite that loads it per test.
Watch spread lose the siblings
The same two layers through three functions, with each getting its own copies.
node compare.mjs
object spread
log.format kept: undefined
upstream.pool.min: undefined
admin_origins: ["https://ops.example.com"]
lodash.merge
log.format kept: "json"
upstream.pool.min: 2
admin_origins: ["https://ops.example.com","https://admin.example.com"]
struct merge
log.format kept: "json"
upstream.pool.min: 2
admin_origins: ["https://ops.example.com","https://admin.example.com"]
Spread lost log.format and upstream.pool.min, because the override named log and upstream and
replaced both objects entirely. The other two kept them.
The third line is where the deep merges get something wrong instead. The override set
admin_origins to a list of one, and both merged the arrays index by index, so the second origin
from the defaults survived into the result. For a list of allowed origins that is a security difference rather than a
stylistic one, and it argues for replacing arrays rather than merging them.
Array handling is where these libraries genuinely disagree, so it is the thing to check before choosing. deepmerge concatenates by default and takes a strategy function to change it. The other two merge by index. None of them replaces the array outright without being told to, which is the behavior configuration usually wants.
Check what each call mutates
The mutation behavior is the part that will not show up in a normal test, so assert on it directly.
node mutation.mjs
merge([base, over]) base changed: true
merge([{}, base, over]) base changed: true
merge([clone(base), over]) base changed: false
lodashMerge(base, over) base changed: true
lodashMerge({}, base, over) base changed: false
The second line is the surprise. Putting an empty object first protects lodash’s first argument and does not protect struct’s sources, because nested objects are lifted from a later source by reference and then written into. Cloning each source is what holds for both.
node --test config.test.mjs
1..5
# tests 5
# suites 0
# pass 5
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 125.447521
When it goes wrong
An array in configuration is merged rather than replaced. A caller narrowing a list to one entry gets their entry plus whatever the defaults held at the later indexes, which is almost never what they meant. Replace arrays explicitly, either with a merge function that takes an array strategy or by overwriting those keys after the merge.
The second failure is an environment layer of strings. Values from the environment arrive as text, so
PORT=8080 merges a string over a number and the type changes without anything complaining. Coerce
the environment layer to the types the defaults declare before merging, or validate the merged result
against a schema that catches it.
When not to do this
Do not deep merge configuration that is really a list of things. A set of named upstreams keyed by name merges member by member, and a deployment that means to define exactly two ends up with the defaults’ third still present.
Do not adopt struct for merging alone. lodash.merge is more widely recognized and does the same job. The reason to prefer struct is that the same package reads and transforms the same data elsewhere in your code.
Do not merge secrets into the same object as the rest of the configuration. A merged config gets logged, and the layer that came from a secret store is the one that should not appear in that log.
Do not let precedence be implicit. Write the order down in one function, as loadConfig does, so a question
about which source wins is answered by reading four words rather than by tracing calls.
Related how-tos
Last verified
Verified 2026-09-06 against Node 22.22.2, @voxgig/struct 0.3.5 and lodash.merge 4.6.2. All three output blocks are what the preceding command printed.