-
Notifications
You must be signed in to change notification settings - Fork 558
feat: add MergeDeep type and merge.deep/mergeWith.deep/toMerged.deep APIs #1692
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
ed52bf1
061fb2a
90dedf5
b71287e
2269aae
7b3dc31
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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) |
| 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>>(); | ||
| }); | ||
|
|
||
| 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>(); | ||
| }); | ||
| }); | ||
| 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]>> | ||||||||||
|
||||||||||
| : 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]>> |
There was a problem hiding this comment.
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 bestring[], but the runtime merge keeps target elements at indices not present in the source (so a merge can produce bothnumberandstringelements). The expected type here should be updated to match the index-based runtime behavior once the array branch inMergeDeepis corrected.