Repository: remeda/remeda
Pull request #317 (2023-05-13 13:55:55)

feat(merge): add mergeDeep
This pull request continues the work initiated in PR #69. It includes the implementation of a 'mergedeep' feature, which is designed to deeply merge JavaScript objects.

Key updates in this pull request include:

1.    Rebased the branch on the current master to ensure compatibility and ease of merge.
2.   Fixed all lint errors to maintain code quality and readability.
3.   Added mergedeep into index.ts and mapping.md

Please review these changes and provide your valuable feedback.

solves: #41

---

Make sure that you:

- [x] Typedoc added for new methods and updated for changed
- [x] Tests added for new methods and updated for changed
- [x] New methods added to `src/index.ts`
- [x] New methods added to `mapping.md`


codesandbox-ci[bot]: This pull request is automatically built and testable in [CodeSandbox](https://codesandbox.io).

  To see build info of the built libraries, click [here](https://ci.codesandbox.io/status/remeda/remeda/pr/317/builds/377397) or the icon next to each commit SHA.

Latest deployment of this branch, based on commit 440539b09c03c7750b3585b3426238bebd340186:

|Sandbox| Source |
|--|--|
|[remeda/remeda](https://codesandbox.io/s/remeda-remeda-fbf887)| Configuration |

davydkov: Any ideas when this gonna be merged?
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/remeda/remeda/pull/317?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=remeda) Report
Patch coverage: **`100.00`**% and no project coverage change.
> Comparison is base [(`bc77dc6`)](https://app.codecov.io/gh/remeda/remeda/commit/bc77dc63b1d4bfd41cdabecd221d2bec41db4749?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=remeda) 99.86% compared to head [(`c6a0018`)](https://app.codecov.io/gh/remeda/remeda/pull/317?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=remeda) 99.86%.

<details><summary>Additional details and impacted files</summary>


```diff
@@           Coverage Diff            @@
##           master     #317    +/-   ##
========================================
  Coverage   99.86%   99.86%            
========================================
  Files          99      100     +1     
  Lines        5804     5953   +149     
  Branches      562      587    +25     
========================================
+ Hits         5796     5945   +149     
  Misses          8        8            
```


| [Impacted Files](https://app.codecov.io/gh/remeda/remeda/pull/317?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=remeda) | Coverage Δ | |
|---|---|---|
| [src/deepMege.ts](https://app.codecov.io/gh/remeda/remeda/pull/317?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=remeda#diff-c3JjL2RlZXBNZWdlLnRz) | `100.00% <100.00%> (ø)` | |


</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/remeda/remeda/pull/317?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=remeda).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=remeda).

TkDodo: @eranhirsch can you also take a look please?
sairus2k: @eranhirsch Thank you for your review and constructive feedback.

I take your feedback to heart and will work on improving the readability of the function and ensuring that it handles all edge cases correctly.


dartess: @sairus2k Hi! How are you doing with that? Do you need any help? I am looking forward to this PR to complete the migration and can help if needed.
RedHatter: @sairus2k Are you still working on this? Would really like to see this merged.
sairus2k: @RedHatter I am no longer actively working on this feature.
Since you're interested in seeing this get merged, feel free to fork the branch and continue development on your own fork if you'd like.
I'm closing this pull request for now since it seems I won't be able to prioritize finishing it myself in the near future.
eranhirsch: hey @sairus2k, I noticed I didn't go over your most recent changes yet, I'm sorry it slipped under my radar. I'll reopen the PR and have a look soon. If I see there are still open issues I might just push fixes myself. Sorry for this taking so long, I can imagine how frustrating it probably is to work on something for so long and not see it get shipped.
sairus2k: Hey @eranhirsch , no worries at all. I understand that things can get busy.

I wanted to bring to your attention that this pull request introduces the very first regular dependency, which is an important consideration. Also, the TypeScript config change could potentially have wider impacts that we should evaluate.

Unfortunately, due to time constraints, I won't be able to continue working on this pull request. Thank you for your understanding.
cjquines: heyy @eranhirsch , what's the status on this?
eranhirsch: Sorry for the delays, I'm just catching up this week on a backlog of 2 months. I hope to get to his in the coming couple of weeks. 

dartess: @eranhirsch hey. The code has changed so many times that I'm a little lost. But if you see certain problems that need to be resolved, I'm still can help.
eranhirsch: I'll send out fixes for this and would appreciate help with a review. Thanks @dartess 
eranhirsch: Oh boy!

Type-fest is both esm-only, requiring us to change our build slightly, but more importantly, it is also not backward compatible and defines its supported typescript version to 5.3 and its node version to 20 (21?). This makes it hard to import type-fest into our project. I couldn't get it to work, so I eventually copy-pasted the required parts of type-fest directly into the project.

I'd be happy to change things if someone has a better idea.

Patch:
diff --git a/.eslintrc.js b/.eslintrc.js
index 278c74d..dee5faf 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -64,5 +64,12 @@ module.exports = {
         node: true,
       },
     },
+    {
+      files: ['src/type-fest/**.ts'],
+      rules: {
+        // Type-fest uses a lot of "banned" types...
+        '@typescript-eslint/ban-types': 'off',
+      },
+    },
   ],
 };
diff --git a/mapping.md b/mapping.md
index 2fc78ee..9f6b3b1 100644
--- a/mapping.md
+++ b/mapping.md
@@ -48,6 +48,7 @@ documentation when migrating._
 | `meanBy`            | `meanBy`            | `-`                 |
 | `merge`             | `assign`            | `merge`             |
 | `mergeAll`          | `assign`            | `mergeAll`          |
+| `mergeDeep`         |                     | `mergeDeepRight`    |
 | `noop`              | `noop`              | `-`                 |
 | `objOf`             | `-`                 | `objOf`             |
 | `omit`              | `omit`              | `omit`              |
@@ -64,7 +65,6 @@ documentation when migrating._
 | `range`             | `range`             | `range`             |
 | `reduce`            | `reduce`            | `reduce`            |
 | `reject`            | `reject`            | `reject`            |
-| `splice`            | `-`                 | `remove`            |
 | `reverse`           | `reverse`           | `reverse`           |
 | `sample`            | `sampleSize`\*      |                     |
 | `set`               | `set`               | `assoc`             |
@@ -77,6 +77,7 @@ documentation when migrating._
 | `sortedIndexBy`     | `sortedIndexBy`     |                     |
 | `sortedLastIndex`   | `sortedLastIndex`   |                     |
 | `sortedLastIndexBy` | `sortedLastIndexBy` |                     |
+| `splice`            | `-`                 | `remove`            |
 | `splitAt`           | `-`                 | `splitAt`           |
 | `splitWhen`         | `-`                 | `splitWhen`         |
 | `stringToPath`      | `toPath`            | `-`                 |
diff --git a/src/index.ts b/src/index.ts
index 767b592..c50395c 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -60,6 +60,7 @@ export * from './maxBy';
 export * from './meanBy';
 export * from './merge';
 export * from './mergeAll';
+export * from './mergeDeep';
 export * from './minBy';
 export * from './noop';
 export * from './objOf';
diff --git a/src/mergeDeep.ts b/src/mergeDeep.ts
new file mode 100644
index 0000000..e22b69c
--- /dev/null
+++ b/src/mergeDeep.ts
@@ -0,0 +1,95 @@
+import { purry } from './purry';
+import { MergeDeep } from './type-fest/merge-deep';
+
+/**
+ * Merges the `source` object into the `destination` object. The merge is similar to performing `{ ...destination, ... source }` (where disjoint values from each object would be copied as-is, and for any overlapping props the value from `source` would be used); But for *each prop* (`p`), if **both** `destination` and `source` have a **plain-object** as a value, the value would be taken as the result of recursively deepMerging them (`result.p === deepMerge(destination.p, source.p)`).
+ *
+ * @param destination - The object to merge into. In general, this object would have it's values overridden.
+ * @param source - The object to merge from. In general, shared keys would be taken from this object.
+ * @returns - The merged object.
+ * @signature
+ *    R.mergeDeep(destination, source)
+ * @example
+ *    R.mergeDeep({ foo: 'bar', x: 1 }, { foo: 'baz', y: 2 }) // => { foo: 'baz', x: 1, y: 2 }
+ * @dataFirst
+ * @category Object
+ */
+export function mergeDeep<
+  Destination extends Record<string, unknown>,
+  Source extends Record<string, unknown>,
+>(destination: Destination, source: Source): MergeDeep<Destination, Source>;
+
+/**
+ * Merges the `source` object into the `destination` object. The merge is similar to performing `{ ...destination, ... source }` (where disjoint values from each object would be copied as-is, and for any overlapping props the value from `source` would be used); But for *each prop* (`p`), if **both** `destination` and `source` have a **plain-object** as a value, the value would be taken as the result of recursively deepMerging them (`result.p === deepMerge(destination.p, source.p)`).
+ *
+ * @param destination - The object to merge into. In general, this object would have it's values overridden.
+ * @param source - The object to merge from. In general, shared keys would be taken from this object.
+ * @returns - The merged object.
+ * @signature
+ *    R.mergeDeep(source)(destination)
+ * @example
+ *    R.pipe(
+ *      { foo: 'bar', x: 1 },
+ *      R.mergeDeep({ foo: 'baz', y: 2 }),
+ *    );  // => { foo: 'baz', x: 1, y: 2 }
+ * @dataLast
+ * @category Object
+ */
+export function mergeDeep<
+  Destination extends Record<string, unknown>,
+  Source extends Record<string, unknown>,
+>(source: Source): (target: Destination) => MergeDeep<Destination, Source>;
+
+export function mergeDeep() {
+  return purry(mergeDeepImplementation, arguments);
+}
+
+function mergeDeepImplementation<
+  Destination extends Record<string, unknown>,
+  Source extends Record<string, unknown>,
+>(destination: Destination, source: Source) {
+  // At this point the output is already merged, simply not deeply merged.
+  const output = { ...destination, ...source } as Record<
+    keyof Destination | keyof Source,
+    unknown
+  >;
+
+  // now just scan the output and look for values that should have been deep-
+  // merged
+  for (const key in source) {
+    if (!(key in destination)) {
+      // They don't share this key.
+      continue;
+    }
+
+    const destinationValue = destination[key];
+    if (!isRecord(destinationValue)) {
+      // The value in destination is not a mergable object so the value from
+      // source (which was already copied in the shallow merge) would be used
+      // as-is.
+      continue;
+    }
+
+    const sourceValue = source[key];
+    if (!isRecord(sourceValue)) {
+      // The value in source is not a mergable object either, so it will
+      // override the object in destination.
+      continue;
+    }
+
+    // Both destination and source have a mergable object for this key, so we
+    // recursively merge them.
+    output[key] = mergeDeepImplementation(destinationValue, sourceValue);
+  }
+
+  return output;
+}
+
+// TODO: Replace this with a call to `isPlainObject` once PR #436 ships.
+function isRecord(object: unknown): object is Record<string, unknown> {
+  return (
+    typeof object === 'object' &&
+    object !== null &&
+    Object.getPrototypeOf(object) === Object.prototype
+  );
+}
diff --git a/src/type-fest/README.md b/src/type-fest/README.md
new file mode 100644
index 0000000..7e1a488
--- /dev/null
+++ b/src/type-fest/README.md
@@ -0,0 +1,6 @@
+ALL FILES IN THIS FOLDER WERE ORIGINALLY COPIED FROM TYPE-FEST
+https://github.com/sindresorhus/type-fest
+
+We use a lot of types from type-fest but we can't include it in our project because it is an ESM only module, which also uses modern (ESNext) APIs.
+
+The files have been modified to remove stuff we don't need, remove documentation, and include changes to styling and formatting.
diff --git a/src/type-fest/conditional-simplify.ts b/src/type-fest/conditional-simplify.ts
new file mode 100644
index 0000000..5efcc04
--- /dev/null
+++ b/src/type-fest/conditional-simplify.ts
@@ -0,0 +1,15 @@
+export type ConditionalSimplifyDeep<
+  Type,
+  ExcludeType = never,
+  IncludeType = unknown,
+> = Type extends ExcludeType
+  ? Type
+  : Type extends IncludeType
+    ? {
+        [TypeKey in keyof Type]: ConditionalSimplifyDeep<
+          Type[TypeKey],
+          ExcludeType,
+          IncludeType
+        >;
+      }
+    : Type;
diff --git a/src/type-fest/enforce-optional.ts b/src/type-fest/enforce-optional.ts
new file mode 100644
index 0000000..cc1ac82
--- /dev/null
+++ b/src/type-fest/enforce-optional.ts
@@ -0,0 +1,29 @@
+import type { Simplify } from './simplify';
+
+// Returns `never` if the key is optional otherwise return the key type.
+type RequiredFilter<Type, Key extends keyof Type> = undefined extends Type[Key]
+  ? Type[Key] extends undefined
+    ? Key
+    : never
+  : Key;
+
+// Returns `never` if the key is required otherwise return the key type.
+type OptionalFilter<Type, Key extends keyof Type> = undefined extends Type[Key]
+  ? Type[Key] extends undefined
+    ? never
+    : Key
+  : never;
+
+export type EnforceOptional<ObjectType> = Simplify<
+  {
+    [Key in keyof ObjectType as RequiredFilter<
+      ObjectType,
+      Key
+    >]: ObjectType[Key];
+  } & {
+    [Key in keyof ObjectType as OptionalFilter<ObjectType, Key>]?: Exclude<
+      ObjectType[Key],
+      undefined
+    >;
+  }
+>;
diff --git a/src/type-fest/merge-deep.ts b/src/type-fest/merge-deep.ts
new file mode 100644
index 0000000..32fef65
--- /dev/null
+++ b/src/type-fest/merge-deep.ts
@@ -0,0 +1,90 @@
+import type { ConditionalSimplifyDeep } from './conditional-simplify';
+import type { Merge } from './merge';
+import type { OmitIndexSignature } from './omit-index-signature';
+import type { PickIndexSignature } from './pick-index-signature';
+
+type UnknownRecord = Record<string, unknown>;
+
+/**
+Deeply simplifies an object excluding iterables and functions. Used internally to improve the UX and accept both interfaces and type aliases as inputs.
+*/
+type SimplifyDeep<Type> = ConditionalSimplifyDeep<
+  Type,
+  Function | Iterable<unknown>,
+  object
+>;
+
+/**
+Try to merge two record properties or return the source property value, preserving `undefined` properties values in both cases.
+*/
+type MergeDeepRecordProperty<Destination, Source> = undefined extends Source
+  ?
+      | MergeDeepOrReturn<
+          Source,
+          Exclude<Destination, undefined>,
+          Exclude<Source, undefined>
+        >
+      | undefined
+  : MergeDeepOrReturn<Source, Destination, Source>;
+
+/**
+Walk through the union of the keys of the two objects and test in which object the properties are defined.
+Rules:
+1. If the source does not contain the key, the value of the destination is returned.
+2. If the source contains the key and the destination does not contain the key, the value of the source is returned.
+3. If both contain the key, try to merge according to the chosen {@link MergeDeepOptions options} or return the source if unable to merge.
+*/
+type DoMergeDeepRecord<
+  Destination extends UnknownRecord,
+  Source extends UnknownRecord,
+> = {
+  // Case in rule 1: The destination contains the key but the source doesn't.
+  [Key in keyof Destination as Key extends keyof Source
+    ? never
+    : Key]: Destination[Key];
+} & {
+  // Case in rule 2: The source contains the key but the destination doesn't.
+  [Key in keyof Source as Key extends keyof Destination
+    ? never
+    : Key]: Source[Key];
+} & {
+  // Case in rule 3: Both the source and the destination contain the key.
+  [Key in keyof Source as Key extends keyof Destination
+    ? Key
+    : never]: MergeDeepRecordProperty<
+    Key extends keyof Destination ? Destination[Key] : never,
+    Source[Key]
+  >;
+};
+
+/**
+Wrapper around {@link DoMergeDeepRecord} which preserves index signatures.
+*/
+type MergeDeepRecord<
+  Destination extends UnknownRecord,
+  Source extends UnknownRecord,
+> = DoMergeDeepRecord<
+  OmitIndexSignature<Destination>,
+  OmitIndexSignature<Source>
+> &
+  Merge<PickIndexSignature<Destination>, PickIndexSignature<Source>>;
+
+/**
+Try to merge two objects or two arrays/tuples recursively into a new type or return the default value.
+*/
+type MergeDeepOrReturn<DefaultType, Destination, Source> = SimplifyDeep<
+  [undefined] extends [Destination | Source]
+    ? DefaultType
+    : Destination extends UnknownRecord
+      ? Source extends UnknownRecord
+        ? MergeDeepRecord<Destination, Source>
+        : DefaultType
+      : DefaultType
+>;
+
+export type MergeDeep<
+  Destination extends UnknownRecord,
+  Source extends UnknownRecord,
+> = SimplifyDeep<
+  MergeDeepRecord<SimplifyDeep<Destination>, SimplifyDeep<Source>>
+>;
diff --git a/src/type-fest/merge.ts b/src/type-fest/merge.ts
new file mode 100644
index 0000000..50c0ddd
--- /dev/null
+++ b/src/type-fest/merge.ts
@@ -0,0 +1,15 @@
+import type { EnforceOptional } from './enforce-optional';
+import type { OmitIndexSignature } from './omit-index-signature';
+import type { PickIndexSignature } from './pick-index-signature';
+
+// Merges two objects without worrying about index signatures.
+type SimpleMerge<Destination, Source> = {
+  [Key in keyof Destination as Key extends keyof Source
+    ? never
+    : Key]: Destination[Key];
+} & Source;
+
+export type Merge<Destination, Source> = EnforceOptional<
+  SimpleMerge<PickIndexSignature<Destination>, PickIndexSignature<Source>> &
+    SimpleMerge<OmitIndexSignature<Destination>, OmitIndexSignature<Source>>
+>;
diff --git a/src/type-fest/omit-index-signature.ts b/src/type-fest/omit-index-signature.ts
new file mode 100644
index 0000000..d81d386
--- /dev/null
+++ b/src/type-fest/omit-index-signature.ts
@@ -0,0 +1,5 @@
+export type OmitIndexSignature<ObjectType> = {
+  [KeyType in keyof ObjectType as {} extends Record<KeyType, unknown>
+    ? never
+    : KeyType]: ObjectType[KeyType];
+};
diff --git a/src/type-fest/pick-index-signature.ts b/src/type-fest/pick-index-signature.ts
new file mode 100644
index 0000000..c5fc69f
--- /dev/null
+++ b/src/type-fest/pick-index-signature.ts
@@ -0,0 +1,5 @@
+export type PickIndexSignature<ObjectType> = {
+  [KeyType in keyof ObjectType as {} extends Record<KeyType, unknown>
+    ? KeyType
+    : never]: ObjectType[KeyType];
+};
diff --git a/src/type-fest/simplify.ts b/src/type-fest/simplify.ts
new file mode 100644
index 0000000..fd1a5e3
--- /dev/null
+++ b/src/type-fest/simplify.ts
@@ -0,0 +1,1 @@
+export type Simplify<T> = { [KeyType in keyof T]: T[KeyType] } & {};


Test patch:
diff --git a/src/mergeDeep.test.ts b/src/mergeDeep.test.ts
new file mode 100644
index 0000000..c3291a3
--- /dev/null
+++ b/src/mergeDeep.test.ts
@@ -0,0 +1,134 @@
+import { mergeDeep } from './mergeDeep';
+
+const NOOP = () => {
+  /* do nothing */
+};
+
+describe('runtime (dataFirst)', () => {
+  test('should merge objects', () => {
+    const a = { foo: 'baz', x: 1 };
+    const b = { foo: 'bar', y: 2 };
+    expect(mergeDeep(a, b)).toEqual({ foo: 'bar', x: 1, y: 2 });
+  });
+
+  test('should merge nested objects', () => {
+    const a = { foo: { bar: 'baz' } };
+    const b = { foo: { qux: 'quux' } };
+    expect(mergeDeep(a, b)).toEqual({ foo: { bar: 'baz', qux: 'quux' } });
+  });
+
+  test('should not merge object and array', () => {
+    const a = { foo: ['qux'] };
+    const b = { foo: { bar: 'baz' } };
+    expect(mergeDeep(a, b)).toEqual({ foo: { bar: 'baz' } });
+  });
+
+  test('should not merge array and object', () => {
+    const a = { foo: { bar: 'baz' } };
+    const b = { foo: ['qux'] };
+    expect(mergeDeep(a, b)).toEqual({ foo: ['qux'] });
+  });
+
+  it('should not merge arrays', () => {
+    const a = { foo: ['bar'] };
+    const b = { foo: ['baz'] };
+    expect(mergeDeep(a, b)).toEqual({ foo: ['baz'] });
+  });
+
+  it('should merge different types', () => {
+    const a = { foo: 'bar' };
+    const b = { foo: 123 };
+    expect(mergeDeep(a, b)).toEqual({ foo: 123 });
+  });
+
+  it('should work with weird object types, null', () => {
+    const a = { foo: null };
+    const b = { foo: 123 };
+    expect(mergeDeep(a, b)).toEqual({ foo: 123 });
+    expect(mergeDeep(b, a)).toEqual({ foo: null });
+  });
+
+  it('should work with weird object types, functions', () => {
+    const a = { foo: NOOP };
+    const b = { foo: 123 };
+    expect(mergeDeep(a, b)).toEqual({ foo: 123 });
+    expect(mergeDeep(b, a)).toEqual({ foo: NOOP });
+  });
+
+  it('should work with weird object types, date', () => {
+    const a = { foo: new Date(1337) };
+    const b = { foo: 123 };
+    expect(mergeDeep(a, b)).toEqual({ foo: 123 });
+    expect(mergeDeep(b, a)).toEqual({ foo: new Date(1337) });
+  });
+
+  it("doesn't spread arrays", () => {
+    const a = { foo: ['bar'] };
+    const b = { foo: ['baz'] };
+    expect(mergeDeep(a, b)).toEqual({ foo: ['baz'] });
+  });
+
+  it("doesn't recurse into arrays", () => {
+    const a = { foo: [{ bar: 'baz' }] };
+    const b = { foo: [{ bar: 'hello, world' }] };
+    expect(mergeDeep(a, b)).toEqual({ foo: [{ bar: 'hello, world' }] });
+  });
+});
+
+describe('runtime (dataLast)', () => {
+  test('should merge objects', () => {
+    const a = { foo: 'baz', x: 1 };
+    const b = { foo: 'bar', y: 2 };
+    expect(mergeDeep(b)(a)).toEqual({ foo: 'bar', x: 1, y: 2 });
+  });
+});
+
+describe('typing', () => {
+  it('trivially merges disjoint objects', () => {
+    const a = { foo: 'bar' };
+    const b = { bar: 'baz' };
+    const result = mergeDeep(a, b);
+    expectTypeOf(result).toEqualTypeOf<{ foo: string; bar: string }>();
+  });
+
+  it('merges fully overlapping types', () => {
+    const a = { foo: 'bar' };
+    const b = { foo: 'baz' };
+    const result = mergeDeep(a, b);
+    expectTypeOf(result).toEqualTypeOf<{ foo: string }>();
+  });
+
+  it('merges semi-overlapping types', () => {
+    const a = { foo: 'bar', x: 1 };
+    const b = { foo: 'baz', y: 2 };
+    const result = mergeDeep(a, b);
+    expectTypeOf(result).toEqualTypeOf<{ foo: string; x: number; y: number }>();
+  });
+
+  it('deeply merges', () => {
+    const a = { foo: { bar: 'baz' } };
+    const b = { foo: { qux: 'quux' } };
+    const result = mergeDeep(a, b);
+    expectTypeOf(result).toEqualTypeOf<{ foo: { bar: string; qux: string } }>();
+  });
+
+  it('overrides types', () => {
+    const a = { foo: { bar: 'baz' } };
+    const b = { foo: 'qux' };
+    expectTypeOf(mergeDeep(a, b)).toEqualTypeOf<typeof b>();
+    expectTypeOf(mergeDeep(b, a)).toEqualTypeOf<typeof a>();
+  });
+
+  it("doesn't spread arrays", () => {
+    const a = { foo: ['bar'] as const };
+    const b = { foo: ['baz'] as const };
+    const result = mergeDeep(a, b);
+    expectTypeOf(result).toEqualTypeOf<{ foo: readonly ['baz'] }>();
+  });
+
+  it("doesn't recurse into arrays", () => {
+    const a = { foo: [{ bar: 'baz' }] };
+    const b = { foo: [{ bar: 'hello, world' }] };
+    expectTypeOf(mergeDeep(a, b)).toEqualTypeOf<typeof b>();
+  });
+});