# defu: Recursive Default Merging With a Priority Order

> defu assigns default properties recursively, with the leftmost argument winning and later arguments filling in what is missing. It is a few hundred bytes of merging logic with three escape hatches, a custom merger, a function merger and an array aware variant, plus a TypeScript type utility that mirrors the runtime behaviour.

**unjs/defu** — 🌊 Assign default properties recursively

- Repository: https://github.com/unjs/defu
- Stars: 1,367 · Forks: 54
- Language: TypeScript
- License: MIT
- Published: 2026-10-16 · Updated: 2026-10-16 · Language: en
- Canonical page: https://hysenlabs.com/projects/unjs-defu

## One Function and a Priority Order

defu assigns default properties, recursively, and the description in the package metadata is short: recursively assign default properties, lightweight and fast.

The usage is a single call with a variadic list:

```js
import { defu } from "defu";

const options = defu(object, ...defaults);
```

The rule that makes it predictable is that leftmost arguments have more priority when assigning defaults. The first argument is the destination object, the source objects are the defaults, and the result is a new object where the leftmost defined value wins and anything missing is filled in from further to the right.

The documented example shows the recursion:

```js
console.log(defu({ a: { b: 2 } }, { a: { b: 1, c: 3 } }));
// => { a: { b: 2, c: 3 } }
```

The nested object is merged key by key rather than replaced wholesale, so b keeps the value 2 from the left and c is taken from the right. That is the difference from a spread or from Object.assign, and it is the reason to use a dedicated function rather than a language feature.

The package is published with both module formats, so a CommonJS project can require it directly:

```js
const { defu } = require("defu");
```

## The Merge Rules, Including the Array Concatenation

The remarks section lists the behaviours that are easy to get wrong when you assume the merge is a plain deep assign, and the array rule is the first surprise.

Will concat array values, if the default property is defined. So the following produces a longer array rather than a replaced one:

```js
console.log(defu({ array: ["b", "c"] }, { array: ["a"] }));
// => { array: ['b', 'c', 'a'] }
```

The user's values come first and the default values are appended. That is convenient for a list of ignore patterns, where a user adds an entry and the defaults stay. It is surprising for a list where the default is a complete set and the user is meant to replace it, and there is no option to turn the behaviour off in the documented API.

The second remark is that the object and defaults are not modified. The merge produces a new object rather than filling in the input in place, which means you can safely pass a frozen configuration object, and it also means the arrays you get back are new arrays.

Together those two rules define the contract: pure, recursive, leftmost first, with arrays concatenated.

## Nullish Values Are Skipped and Prototype Keys Are Blocked

Two remarks decide what happens at the edges of the input, and both are the kind of detail that changes how you use the function.

Nullish values, meaning null and undefined, are skipped. A key present in the input with the value null does not override the default, the default wins. The README points at defaults-deep, omit-deep or lodash.defaultsdeep if you need to preserve or want different behaviour, which is a direct admission that this is a choice and not the only reasonable one.

The other one is a security decision. Assignment of __proto__ and constructor keys will be skipped to prevent security issues with object pollution.

That is worth understanding rather than just accepting, because it means a configuration object coming from an untrusted source cannot set the prototype of the merged result. It also means that if you were relying on __proto__ as an ordinary key, which is unusual but not impossible, that key is simply dropped.

Together with the mutation remark, the picture is a function designed to be safe to call on partially trusted input, which is a good default for a library that sits in configuration paths.

## createDefu Changes the Merge Strategy

Sometimes the default merging strategy is not desirable, and createDefu creates a custom instance with a different merging strategy.

The merger function accepts obj, the source object, key and value, the current value, and it should return true if the custom merging was applied. The example in the README sums numbers instead of overriding them:

```js
import { createDefu } from "defu";

const ext = createDefu((obj, key, value) => {
  if (typeof obj[key] === "number" && typeof value === "number") {
    obj[key] += value;
    return true;
  }
});

ext({ cost: 15 }, { cost: 10 }); // { cost: 25 }
```

The contract has two halves, and both matter. Returning true tells defu that you have handled the key, and returning false lets the normal merge run, which means a custom merger can handle one type of key and leave everything else alone. The merger receives the object being built, so it can read the value that is already there, which is what makes accumulation possible.

The same pattern holds for the other two functions, and the three together cover most of what a configuration system needs: the plain merge, a strategy override, and a per-key function.

## defuFn and defuArrayFn Treat Functions as Values

defuFn is the second escape hatch, and it changes the meaning of a function in the input object.

If the user provided a function, it will be called with the default value instead of merging. The README gives the example, filtering items out of an array default and adding 20 to a count:

```js
import { defuFn } from "defu";

defuFn(
  {
    ignore: (val) => val.filter((item) => item !== "dist"),
    count: (count) => count + 20,
  },
  {
    ignore: ["node_modules", "dist"],
    count: 10,
  },
);
```

The result is the filtered ignore list and a count of 30, with the function called with the default as its argument and the function's return value taking the place of the merged value.

There is one caveat stated in the README: if the default value is not defined, the function defined will not be called and kept as value. So a function in the input only fires when there is a default for it to act on, and otherwise it is passed through untouched.

defuArrayFn is similar to defuFn but only applies to array values defined in defaults, so the function is called only if the value defined in defaults is an array. That restriction is the point of the variant, since it lets a function handle list defaults without accidentally firing on scalar defaults.

## The Defu Type Utility Mirrors the Runtime

Because defu is a TypeScript package, the interesting question is what happens to the type of the result, and the answer is a type utility.

The README exposes Defu as a type utility to return a merged type that follows the rules that defu follows:

```js
import type { Defu } from 'defu'

type Options = Defu<{ foo: 'bar' }, [{}, { bar: 'baz' }, { something: 42 }]>
// returns { foo: 'bar', bar: 'baz', 'something': 42 }
```

The signature takes the destination type first and then a tuple of the source types, and the result is the type you would get if you called defu with those arguments. The leftmost member of the tuple wins for any key that appears in it, and the rest are merged in, so the result type has the same priority order as the runtime behaviour.

That is more useful than it sounds. A configuration object in a TypeScript codebase usually has a type, and the defaults usually have a looser type, and without a utility the merged type is just the destination type with the defaults invisible. With Defu, the type of the merged object says what the defaults actually contribute, which is what lets a caller see the full set of options the library will honour.

## A Small Package With a Modern Toolchain

The repository is small, with src/ for the source, lib/ and dist/ for the built output, test/ for the suite, and the usual configuration files including a .oxfmtrc.json and an .oxlintrc.json.

The package.json shows the shape of the distribution. The version is 6.1.7, and there are three entry points, main at ./lib/defu.cjs for CommonJS, module at ./dist/defu.mjs for the module build, and types at ./dist/defu.d.ts. The exports map is more careful than that, with separate types files for the import and require conditions, .d.mts and .d.cts, because the two module systems need different declaration formats.

The scripts are worth reading because the toolchain has moved on from the usual suspects. Linting is oxlint and formatting is oxfmt rather than eslint and prettier, and the type check is tsgo, the native preview of the TypeScript compiler, rather than tsc. The test script runs the lint, the type check and then vitest run, and the release script runs the tests and the build, then changelogen for the changelog and publish.

The development dependencies match, with vitest 4, typescript 6, unbuild for the build, expect-type for the type level tests, and the native preview compiler pinned to a specific dev version. The package manager is pnpm at 10.33.4, and the releases are frequent, with 6.1.7 on 2026-04-07 and 6.1.6 and 6.1.5 on 2026-04-01.

## Conclusion

defu fits a codebase that spreads configuration across a user object, an environment file and a set of built-in defaults, since one call flattens them into a single options object with a predictable priority order. It is a poor fit if you need the nullish values in your input to win, because those are skipped, and a poor fit if you need the defaults to override the input, since the order is the other way round. Before adopting it, read the remarks list, because the array concatenation and the skipped __proto__ and constructor keys are behaviours that differ from lodash.defaultsdeep and from a naive Object.assign, and both are decisions you may need to work around rather than accept. The package is MIT licensed at version 6.1.7, and the last push was on 2026-10-18.

## FAQ

### What does defu do and what is the argument order?

It assigns default properties recursively, with leftmost arguments having more priority. The first argument is the destination object and the rest are the defaults, so defu({ a: { b: 2 } }, { a: { b: 1, c: 3 } }) returns { a: { b: 2, c: 3 } }.

### How does defu treat arrays and nullish values?

Array values are concatenated when the default property is defined, so the user's values come first, and nullish values, meaning null and undefined, are skipped rather than overriding the default. The README points at defaults-deep, omit-deep or lodash.defaultsdeep if you want different behaviour.

### What is the difference between defu, defuFn and defuArrayFn?

defu merges normally, defuFn calls a function found in the input with the default value instead of merging, and defuArrayFn is the same but only applies to array values defined in the defaults. If no default value is defined, the function is not called and is kept as the value.

### How do I change the merge strategy in defu?

Use createDefu, which takes a merger function receiving obj, key and value and returning true if the custom merging was applied. The README shows one that sums numbers instead of overriding them, giving { cost: 25 } from a cost of 15 and a default of 10.

### Does defu protect against prototype pollution?

Yes. Assignment of __proto__ and constructor keys is skipped to prevent security issues with object pollution, and the remarks also state that the object and the defaults passed in are not modified.

## Sources

- [Issues](https://github.com/unjs/defu/issues)
- [License: MIT](https://github.com/unjs/defu/blob/main/LICENSE)
- [README](https://github.com/unjs/defu/blob/main/README.md)
- [Releases](https://github.com/unjs/defu/releases)
- [unjs/defu on GitHub](https://github.com/unjs/defu)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/unjs-defu
