# How to merge nested config objects with predictable precedence

> Layer defaults, file, environment and flags with a deep merge so a later source overrides only the keys it sets, and clone first because merge mutates.

Source: https://voxgig.com/howto/merge-nested-config-objects-with-precedence

- Audience: platform-team
- Level: beginner
- Languages: typescript, javascript
- Verified: 2026-09-06
- Published: 2026-09-06

## 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](https://github.com/TehShrike/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](https://lodash.com/docs/#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](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax) | 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](https://github.com/voxgig/struct) | 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.

```ts title="config.mjs"
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](https://12factor.net/config) 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.

```bash
node compare.mjs
```

```text output
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](https://github.com/TehShrike/deepmerge#arraymerge) 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.

```bash
node mutation.mjs
```

```text output
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.

```bash
node --test config.test.mjs
```

```text output
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

- [Read a value at a nested path without null checks](/howto/read-a-nested-value-by-path)

- [Accept comments and unquoted keys in a JSON config file](/howto/accept-comments-and-unquoted-keys-in-json)

## 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.