Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions pr-body.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
## Summary

Adds a new `MergeDeep<T, S>` type that performs a recursive deep merge of two object types,
mirroring the runtime behavior of `merge`, `mergeWith`, and `toMerged`.

Also introduces `.deep()` namespace methods on `merge`, `mergeWith`, and `toMerged`
that return `MergeDeep<T, S>` instead of `T & S`, allowing users to get structurally-merged
types without breaking existing callers.

## Motivation

The existing `merge()` return type is `T & S`, which produces intersection types like
`{ a: number } & { a: string }` instead of `{ a: number | string }`. This makes it hard
to use the result type in practice.

Adding an overload to `merge()` directly was considered, but following the precedent of
#1498 → #1595 (`omit` overload revert), we chose namespace methods (`merge.deep()`)
to avoid inference drift on existing callers.

## Changes

- **New type:** `MergeDeep<T, S>` in `src/_internal/types/MergeDeep.ts`
- Deeply merges plain objects
- Preserves tuple lengths (`[A, B]` + `[C, D]` → `[MergeDeep<A,C>, MergeDeep<B,D>]`)
- Preserves and merges index signatures (`[x: string]: T` + `[x: string]: S`)
- Handles `null`, `undefined`, and built-in types (Date, RegExp, etc.) correctly
- **New APIs:**
- `merge.deep(target, source)` → `MergeDeep<T, S>`
- `mergeWith.deep(target, source, mergeFn)` → `MergeDeep<T, S>`
- `toMerged.deep(target, source)` → `MergeDeep<T, S>`
- **JSDoc updates:** Added `@example` blocks with type annotations for all three `.deep()` methods
- **Export:** `MergeDeep` type exported from `src/object/index.ts`

## No breaking changes

- Existing `merge` / `mergeWith` / `toMerged` signatures are unchanged
- The `compat/` modules are intentionally left untouched to preserve lodash type compatibility
- All existing tests pass without modification

## Before / After

**Before:**
```ts
const result = merge({ a: { x: 1 } }, { a: { y: '2' } });
// ^? { a: { x: number } & { y: string } } ❌
```

**After:**
```ts
const result = merge.deep({ a: { x: 1 } }, { a: { y: '2' } });
// ^? { a: { x: number; y: string } } ✅
```

## Test plan

- [x] Type tests added for `MergeDeep` (objects, arrays, tuples, null, undefined, built-ins, index signatures)
- [x] Type tests added for `merge.deep`, `mergeWith.deep`, `toMerged.deep`
- [x] Runtime tests pass (no behavioral change)
- [x] `yarn typecheck` passes
- [x] Compat type tests pass (lodash compatibility preserved)
199 changes: 199 additions & 0 deletions src/_internal/types/MergeDeep.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
import { describe, expectTypeOf, it } from 'vitest';
import type { MergeDeep } from './MergeDeep';

describe('MergeDeep', () => {
it('should deeply merge plain objects', () => {
type Target = { a: { x: number; y: string } };
type Source = { a: { y: boolean; z: string } };
type Result = MergeDeep<Target, Source>;

expectTypeOf<Result>().toEqualTypeOf<{ a: { x: number; y: boolean; z: string } }>();
});

it('should add new keys from source', () => {
type Target = { a: number };
type Source = { b: string };
type Result = MergeDeep<Target, Source>;

expectTypeOf<Result>().toEqualTypeOf<{ a: number; b: string }>();
});

it('should preserve target keys not in source', () => {
type Target = { a: number; b: string };
type Source = { b: boolean };
type Result = MergeDeep<Target, Source>;

expectTypeOf<Result>().toEqualTypeOf<{ a: number; b: boolean }>();
});

it('should replace null target with source', () => {
type Target = { a: null };
type Source = { a: string[] };
type Result = MergeDeep<Target, Source>;

expectTypeOf<Result>().toEqualTypeOf<{ a: string[] }>();
});

it('should preserve target when source property is undefined', () => {
type Target = { a: number };
type Source = { a: undefined };
type Result = MergeDeep<Target, Source>;

expectTypeOf<Result>().toEqualTypeOf<{ a: number }>();
});

it('should replace non-plain objects like Date and RegExp', () => {
type Target = { a: Date };
type Source = { a: RegExp };
type Result = MergeDeep<Target, Source>;

expectTypeOf<Result>().toEqualTypeOf<{ a: RegExp }>();
});

it('should merge non-tuple arrays element-wise', () => {
type Target = { arr: number[] };
type Source = { arr: string[] };
type Result = MergeDeep<Target, Source>;

expectTypeOf<Result['arr']>().toEqualTypeOf<Array<string>>();
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The type test for merging non-tuple arrays expects MergeDeep<{ arr: number[] }, { arr: string[] }>['arr'] to be string[], but the runtime merge keeps target elements at indices not present in the source (so a merge can produce both number and string elements). The expected type here should be updated to match the index-based runtime behavior once the array branch in MergeDeep is corrected.

Suggested change
expectTypeOf<Result['arr']>().toEqualTypeOf<Array<string>>();
expectTypeOf<Result['arr']>().toEqualTypeOf<Array<number | string>>();

Copilot uses AI. Check for mistakes.
});

it('should merge tuple elements deeply', () => {
type Target = { arr: [{ a: number }] };
type Source = { arr: [{ b: string }] };
type Result = MergeDeep<Target, Source>;

expectTypeOf<Result['arr']>().toEqualTypeOf<[{ a: number; b: string }]>();
});

it('should preserve tuple length when merging tuples', () => {
type Target = [{ a: 1 }, { b: 2 }];
type Source = [{ c: 3 }, { d: 4 }];
type Result = MergeDeep<Target, Source>;

expectTypeOf<Result>().toEqualTypeOf<[{ a: 1; c: 3 }, { b: 2; d: 4 }]>();
});

it('should merge tuple with shorter tuple', () => {
type Target = [{ a: 1 }, { b: 2 }, { c: 3 }];
type Source = [{ x: 9 }];
type Result = MergeDeep<Target, Source>;

expectTypeOf<Result>().toEqualTypeOf<[{ a: 1; x: 9 }, { b: 2 }, { c: 3 }]>();
});

it('should merge tuple elements by replacement', () => {
type Target = { arr: [1, 2, 3] };
type Source = { arr: [4, 5] };
type Result = MergeDeep<Target, Source>;

expectTypeOf<Result['arr']>().toEqualTypeOf<[4, 5, 3]>();
});

it('should degrade tuple to array when merged with array', () => {
type Target = { arr: [1, 2, 3] };
type Source = { arr: number[] };
type Result = MergeDeep<Target, Source>;

expectTypeOf<Result['arr']>().toEqualTypeOf<Array<number>>();
});

it('should preserve optional properties from both sides', () => {
type Target = { a?: number; b: string };
type Source = { c?: boolean };
type Result = MergeDeep<Target, Source>;

expectTypeOf<Result>().toEqualTypeOf<{ a?: number; b: string; c?: boolean }>();
});

it('should preserve optional properties in nested objects', () => {
type Target = { nested: { a?: number } };
type Source = { nested: { b?: string } };
type Result = MergeDeep<Target, Source>;

expectTypeOf<Result>().toEqualTypeOf<{ nested: { a?: number; b?: string } }>();
});

it('should handle deeply nested types', () => {
type Target = { l1: { l2: { l3: { a: 1 } } } };
type Source = { l1: { l2: { l3: { b: 2 } } } };
type Result = MergeDeep<Target, Source>;

expectTypeOf<Result['l1']['l2']['l3']>().toEqualTypeOf<{ a: 1; b: 2 }>();
});

it('should handle merging into an empty object', () => {
type Target = {};
type Source = { a: number };
type Result = MergeDeep<Target, Source>;

expectTypeOf<Result>().toEqualTypeOf<{ a: number }>();
});

it('should handle merging from an empty object', () => {
type Target = { a: number };
type Source = {};
type Result = MergeDeep<Target, Source>;

expectTypeOf<Result>().toEqualTypeOf<{ a: number }>();
});

it('should fallback to source for primitive target + object source', () => {
type Target = { a: number };
type Source = { a: { b: string } };
type Result = MergeDeep<Target, Source>;

expectTypeOf<Result>().toEqualTypeOf<{ a: { b: string } }>();
});

it('should fallback to intersection for array + object merge', () => {
type Target = { x: string[] };
type Source = { x: { a: number } };
type Result = MergeDeep<Target, Source>;

// Runtime: array gets object properties merged in. Best type approximation is an intersection.
expectTypeOf<Result['x']>().toMatchTypeOf<string[] & { a: number }>();
});

it('should handle functions as non-plain objects (replace)', () => {
type Target = { fn: () => void };
type Source = { fn: (x: number) => string };
type Result = MergeDeep<Target, Source>;

expectTypeOf<Result['fn']>().toEqualTypeOf<(x: number) => string>();
});

it('should handle string index signatures', () => {
type Target = { [x: string]: number; a: 1 };
type Source = { [x: string]: string; b: '2' };
type Result = MergeDeep<Target, Source>;

expectTypeOf<Result>().toMatchTypeOf<{
[x: string]: number | string;
a: 1;
b: '2';
}>();
expectTypeOf<{
[x: string]: number | string;
a: 1;
b: '2';
}>().toMatchTypeOf<Result>();
});

it('should handle number index signatures', () => {
type Target = { [x: number]: boolean; 0: true };
type Source = { [x: number]: string; 1: 'hello' };
type Result = MergeDeep<Target, Source>;

expectTypeOf<Result>().toMatchTypeOf<{
[x: number]: boolean | string;
0: true;
1: 'hello';
}>();
expectTypeOf<{
[x: number]: boolean | string;
0: true;
1: 'hello';
}>().toMatchTypeOf<Result>();
});
});
115 changes: 115 additions & 0 deletions src/_internal/types/MergeDeep.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
type Simplify<T> = { [K in keyof T]: T[K] } & {};

type BuiltIn =
| Date
| RegExp
| Map<any, any>
| Set<any>
| WeakMap<any, any>
| WeakSet<any>
| Promise<any>
| Error
| ArrayBuffer
| DataView
| Int8Array
| Uint8Array
| Uint8ClampedArray
| Int16Array
| Uint16Array
| Int32Array
| Uint32Array
| Float32Array
| Float64Array
| BigInt64Array
| BigUint64Array
| ((...args: any[]) => any);

// Index Signature helpers
type PickIndexSignature<T> = {
[K in keyof T as string extends K ? K : number extends K ? K : never]: T[K];
};

type OmitIndexSignature<T> = {
[K in keyof T as string extends K ? never : number extends K ? never : K]: T[K];
};

type GetStringIndex<T> = string extends keyof T ? T[string] : never;
type GetNumberIndex<T> = number extends keyof T ? T[number] : never;

type MergeIndexSignatures<T, S> = Simplify<
(string extends keyof PickIndexSignature<T> | keyof PickIndexSignature<S>
? { [x: string]: GetStringIndex<PickIndexSignature<T>> | GetStringIndex<PickIndexSignature<S>> }
: {}) &
(number extends keyof PickIndexSignature<T> | keyof PickIndexSignature<S>
? { [x: number]: GetNumberIndex<PickIndexSignature<T>> | GetNumberIndex<PickIndexSignature<S>> }
: {})
>;

// Optional/Required key helpers
type OptionalKeys<T> = { [K in keyof T]-?: {} extends Pick<T, K> ? K : never }[keyof T];
type RequiredKeys<T> = Exclude<keyof T, OptionalKeys<T>>;

// Tuple helpers
type Head<T> = T extends readonly [infer H, ...infer _] ? H : never;
type Tail<T> = T extends readonly [infer _, ...infer Rest] ? Rest : never;

type IsTuple<T> = T extends readonly any[]
? number extends T['length']
? false
: true
: false;

type MergeArrays<T extends readonly any[], S extends readonly any[]> = T extends []
? S
: S extends []
? T
: [MergeDeep<Head<T>, Head<S>>, ...MergeArrays<Tail<T>, Tail<S>>];

// Merge literal keys deeply
type _MergeLiteralObjects<T, S> = Simplify<{
[K in (keyof T | keyof S) & (RequiredKeys<T> | RequiredKeys<S>)]: K extends keyof S
? K extends keyof T
? MergeDeep<T[K], S[K]>
: S[K]
: K extends keyof T
? T[K]
: never;
} & {
[K in (keyof T | keyof S) as K extends RequiredKeys<T> | RequiredKeys<S> ? never : K]?: K extends keyof S
? K extends keyof T
? MergeDeep<T[K], S[K]>
: S[K]
: K extends keyof T
? T[K]
: never;
}>;

// Combine index signatures with deep literal merge
type _MergeObjects<T, S> = Simplify<
MergeIndexSignatures<T, S> & _MergeLiteralObjects<OmitIndexSignature<T>, OmitIndexSignature<S>>
>;

export type MergeDeep<T, S> =
S extends undefined
? T
: T extends null
? S
: S extends BuiltIn
? S
: T extends BuiltIn
? S
: T extends readonly any[]
? S extends readonly any[]
? IsTuple<T> extends true
? IsTuple<S> extends true
? MergeArrays<T, S>
: Array<MergeDeep<T[number], S[number]>>
: Array<MergeDeep<T[number], S[number]>>
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MergeDeep’s non-tuple array branch currently computes the element type as MergeDeep<T[number], S[number]> (which becomes the source element type for primitives). At runtime, array merge is index-based and only overwrites indices present in the source array (e.g. merging [1, 2] with ['a'] yields ['a', 2]), so the resulting array’s element type should also allow the original target element type for indices not present in the source. Consider widening the element type (e.g. include T[number] in addition to the merged element type) so the type reflects the documented runtime behavior.

Suggested change
: Array<MergeDeep<T[number], S[number]>>
: Array<MergeDeep<T[number], S[number]>>
: Array<T[number] | MergeDeep<T[number], S[number]>>
: Array<T[number] | MergeDeep<T[number], S[number]>>

Copilot uses AI. Check for mistakes.
: S extends object
? T & S
: S
: T extends object
? S extends object
? _MergeObjects<T, S>
: S
: S;
1 change: 1 addition & 0 deletions src/object/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export { mapKeys } from './mapKeys.ts';
export { mapValues } from './mapValues.ts';
export { merge } from './merge.ts';
export { mergeWith } from './mergeWith.ts';
export type { MergeDeep } from '../_internal/types/MergeDeep.ts';
export { omit } from './omit.ts';
export { omitBy } from './omitBy.ts';
export { pick } from './pick.ts';
Expand Down
Loading
Loading