From ed52bf1e29c4724462d12e1521b66374af1b5111 Mon Sep 17 00:00:00 2001 From: DaeHee Lee Date: Tue, 21 Apr 2026 14:35:03 +0900 Subject: [PATCH 1/5] feat: add MergeDeep type and merge.deep/mergeWith.deep/toMerged.deep APIs --- pr-body.md | 60 ++++++++ src/_internal/types/MergeDeep.spec.ts | 199 ++++++++++++++++++++++++++ src/_internal/types/MergeDeep.ts | 115 +++++++++++++++ src/object/index.ts | 1 + src/object/merge.spec.ts | 7 +- src/object/merge.ts | 25 ++++ src/object/mergeWith.spec.ts | 7 +- src/object/mergeWith.ts | 31 ++++ src/object/toMerged.spec.ts | 7 +- src/object/toMerged.ts | 27 ++++ 10 files changed, 476 insertions(+), 3 deletions(-) create mode 100644 pr-body.md create mode 100644 src/_internal/types/MergeDeep.spec.ts create mode 100644 src/_internal/types/MergeDeep.ts diff --git a/pr-body.md b/pr-body.md new file mode 100644 index 000000000..1b611e579 --- /dev/null +++ b/pr-body.md @@ -0,0 +1,60 @@ +## Summary + +Adds a new `MergeDeep` 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` 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` in `src/_internal/types/MergeDeep.ts` + - Deeply merges plain objects + - Preserves tuple lengths (`[A, B]` + `[C, D]` → `[MergeDeep, MergeDeep]`) + - 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` + - `mergeWith.deep(target, source, mergeFn)` → `MergeDeep` + - `toMerged.deep(target, source)` → `MergeDeep` +- **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) diff --git a/src/_internal/types/MergeDeep.spec.ts b/src/_internal/types/MergeDeep.spec.ts new file mode 100644 index 000000000..bfb11dbdc --- /dev/null +++ b/src/_internal/types/MergeDeep.spec.ts @@ -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; + + expectTypeOf().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; + + expectTypeOf().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; + + expectTypeOf().toEqualTypeOf<{ a: number; b: boolean }>(); + }); + + it('should replace null target with source', () => { + type Target = { a: null }; + type Source = { a: string[] }; + type Result = MergeDeep; + + expectTypeOf().toEqualTypeOf<{ a: string[] }>(); + }); + + it('should preserve target when source property is undefined', () => { + type Target = { a: number }; + type Source = { a: undefined }; + type Result = MergeDeep; + + expectTypeOf().toEqualTypeOf<{ a: number }>(); + }); + + it('should replace non-plain objects like Date and RegExp', () => { + type Target = { a: Date }; + type Source = { a: RegExp }; + type Result = MergeDeep; + + expectTypeOf().toEqualTypeOf<{ a: RegExp }>(); + }); + + it('should merge non-tuple arrays element-wise', () => { + type Target = { arr: number[] }; + type Source = { arr: string[] }; + type Result = MergeDeep; + + expectTypeOf().toEqualTypeOf>(); + }); + + it('should merge tuple elements deeply', () => { + type Target = { arr: [{ a: number }] }; + type Source = { arr: [{ b: string }] }; + type Result = MergeDeep; + + expectTypeOf().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; + + expectTypeOf().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; + + expectTypeOf().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; + + expectTypeOf().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; + + expectTypeOf().toEqualTypeOf>(); + }); + + it('should preserve optional properties from both sides', () => { + type Target = { a?: number; b: string }; + type Source = { c?: boolean }; + type Result = MergeDeep; + + expectTypeOf().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; + + expectTypeOf().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; + + expectTypeOf().toEqualTypeOf<{ a: 1; b: 2 }>(); + }); + + it('should handle merging into an empty object', () => { + type Target = {}; + type Source = { a: number }; + type Result = MergeDeep; + + expectTypeOf().toEqualTypeOf<{ a: number }>(); + }); + + it('should handle merging from an empty object', () => { + type Target = { a: number }; + type Source = {}; + type Result = MergeDeep; + + expectTypeOf().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; + + expectTypeOf().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; + + // Runtime: array gets object properties merged in. Best type approximation is an intersection. + expectTypeOf().toMatchTypeOf(); + }); + + it('should handle functions as non-plain objects (replace)', () => { + type Target = { fn: () => void }; + type Source = { fn: (x: number) => string }; + type Result = MergeDeep; + + expectTypeOf().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; + + expectTypeOf().toMatchTypeOf<{ + [x: string]: number | string; + a: 1; + b: '2'; + }>(); + expectTypeOf<{ + [x: string]: number | string; + a: 1; + b: '2'; + }>().toMatchTypeOf(); + }); + + it('should handle number index signatures', () => { + type Target = { [x: number]: boolean; 0: true }; + type Source = { [x: number]: string; 1: 'hello' }; + type Result = MergeDeep; + + expectTypeOf().toMatchTypeOf<{ + [x: number]: boolean | string; + 0: true; + 1: 'hello'; + }>(); + expectTypeOf<{ + [x: number]: boolean | string; + 0: true; + 1: 'hello'; + }>().toMatchTypeOf(); + }); +}); diff --git a/src/_internal/types/MergeDeep.ts b/src/_internal/types/MergeDeep.ts new file mode 100644 index 000000000..807bae720 --- /dev/null +++ b/src/_internal/types/MergeDeep.ts @@ -0,0 +1,115 @@ +type Simplify = { [K in keyof T]: T[K] } & {}; + +type BuiltIn = + | Date + | RegExp + | Map + | Set + | WeakMap + | WeakSet + | Promise + | Error + | ArrayBuffer + | DataView + | Int8Array + | Uint8Array + | Uint8ClampedArray + | Int16Array + | Uint16Array + | Int32Array + | Uint32Array + | Float32Array + | Float64Array + | BigInt64Array + | BigUint64Array + | ((...args: any[]) => any); + +// Index Signature helpers +type PickIndexSignature = { + [K in keyof T as string extends K ? K : number extends K ? K : never]: T[K]; +}; + +type OmitIndexSignature = { + [K in keyof T as string extends K ? never : number extends K ? never : K]: T[K]; +}; + +type GetStringIndex = string extends keyof T ? T[string] : never; +type GetNumberIndex = number extends keyof T ? T[number] : never; + +type MergeIndexSignatures = Simplify< + (string extends keyof PickIndexSignature | keyof PickIndexSignature + ? { [x: string]: GetStringIndex> | GetStringIndex> } + : {}) & + (number extends keyof PickIndexSignature | keyof PickIndexSignature + ? { [x: number]: GetNumberIndex> | GetNumberIndex> } + : {}) +>; + +// Optional/Required key helpers +type OptionalKeys = { [K in keyof T]-?: {} extends Pick ? K : never }[keyof T]; +type RequiredKeys = Exclude>; + +// Tuple helpers +type Head = T extends readonly [infer H, ...infer _] ? H : never; +type Tail = T extends readonly [infer _, ...infer Rest] ? Rest : never; + +type IsTuple = T extends readonly any[] + ? number extends T['length'] + ? false + : true + : false; + +type MergeArrays = T extends [] + ? S + : S extends [] + ? T + : [MergeDeep, Head>, ...MergeArrays, Tail>]; + +// Merge literal keys deeply +type _MergeLiteralObjects = Simplify<{ + [K in (keyof T | keyof S) & (RequiredKeys | RequiredKeys)]: K extends keyof S + ? K extends keyof T + ? MergeDeep + : S[K] + : K extends keyof T + ? T[K] + : never; +} & { + [K in (keyof T | keyof S) as K extends RequiredKeys | RequiredKeys ? never : K]?: K extends keyof S + ? K extends keyof T + ? MergeDeep + : S[K] + : K extends keyof T + ? T[K] + : never; +}>; + +// Combine index signatures with deep literal merge +type _MergeObjects = Simplify< + MergeIndexSignatures & _MergeLiteralObjects, OmitIndexSignature> +>; + +export type MergeDeep = + 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 extends true + ? IsTuple extends true + ? MergeArrays + : Array> + : Array> + : S extends object + ? T & S + : S + : T extends object + ? S extends object + ? _MergeObjects + : S + : S; diff --git a/src/object/index.ts b/src/object/index.ts index 1c75c7bdf..18000a730 100644 --- a/src/object/index.ts +++ b/src/object/index.ts @@ -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'; diff --git a/src/object/merge.spec.ts b/src/object/merge.spec.ts index d727556c5..bbfbc6b6f 100644 --- a/src/object/merge.spec.ts +++ b/src/object/merge.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, expectTypeOf, it } from 'vitest'; import { merge } from './merge'; describe('merge', () => { @@ -141,4 +141,9 @@ describe('merge', () => { expect(nestedArray.x[0]).toBe('1'); expect((nestedArray.x as any).a).toBe(2); }); + + it('should have correct types for merge.deep', () => { + const result = merge.deep({ a: { x: 1 } }, { a: { y: '2' } }); + expectTypeOf(result).toEqualTypeOf<{ a: { x: number; y: string } }>(); + }); }); diff --git a/src/object/merge.ts b/src/object/merge.ts index 533537ef5..f5ebcbe34 100644 --- a/src/object/merge.ts +++ b/src/object/merge.ts @@ -1,3 +1,4 @@ +import type { MergeDeep } from '../_internal/types/MergeDeep.ts'; import { isUnsafeProperty } from '../_internal/isUnsafeProperty.ts'; import { isPlainObject } from '../predicate/isPlainObject.ts'; @@ -71,6 +72,30 @@ export function merge, S extends Record} The updated target object with deeply merged types. + * + * @example + * const target = { a: 1, b: { x: 1, y: 2 } }; + * const source = { b: { y: 3, z: 4 }, c: 5 }; + * + * const result = merge.deep(target, source); + * // result type: { a: number; b: { x: number; y: number; z: number }; c: number } + */ +export namespace merge { + export function deep, S extends Record>( + target: T, + source: S + ): MergeDeep { + return merge(target, source) as any; + } +} + function isMergeableValue(value: unknown) { return isPlainObject(value) || Array.isArray(value); } diff --git a/src/object/mergeWith.spec.ts b/src/object/mergeWith.spec.ts index c2b0be3d1..4dec57887 100644 --- a/src/object/mergeWith.spec.ts +++ b/src/object/mergeWith.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, expectTypeOf, it } from 'vitest'; import { cloneDeep } from './cloneDeep'; import { mergeWith } from './mergeWith'; @@ -125,4 +125,9 @@ describe('mergeWith', () => { expect(result).toEqual({ a: { b: { x: 1 }, c: { y: 2 }, d: { z: 3 } } }); }); + + it('should have correct types for mergeWith.deep', () => { + const result = mergeWith.deep({ a: { x: 1 } }, { a: { y: '2' } }, () => undefined); + expectTypeOf(result).toEqualTypeOf<{ a: { x: number; y: string } }>(); + }); }); diff --git a/src/object/mergeWith.ts b/src/object/mergeWith.ts index ebec941ed..0d9d18244 100644 --- a/src/object/mergeWith.ts +++ b/src/object/mergeWith.ts @@ -1,3 +1,4 @@ +import type { MergeDeep } from '../_internal/types/MergeDeep.ts'; import { isUnsafeProperty } from '../_internal/isUnsafeProperty.ts'; import { isPlainObject } from '../predicate/isPlainObject.ts'; @@ -89,3 +90,33 @@ export function mergeWith, S extends Record

any} merge - A custom merge function that defines how properties should be combined. + * @returns {MergeDeep} The updated target object with deeply merged types. + * + * @example + * const target = { a: 1, b: 2 }; + * const source = { b: 3, c: 4 }; + * + * const result = mergeWith.deep(target, source, (targetValue, sourceValue) => { + * if (typeof targetValue === 'number' && typeof sourceValue === 'number') { + * return targetValue + sourceValue; + * } + * }); + * // result type: { a: number; b: number; c: number } + */ +export namespace mergeWith { + export function deep, S extends Record>( + target: T, + source: S, + merge: (targetValue: any, sourceValue: any, key: string, target: T, source: S) => any + ): MergeDeep { + return mergeWith(target, source, merge) as any; + } +} diff --git a/src/object/toMerged.spec.ts b/src/object/toMerged.spec.ts index 6642fa257..9a9263e70 100644 --- a/src/object/toMerged.spec.ts +++ b/src/object/toMerged.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, expectTypeOf, it } from 'vitest'; import { toMerged } from './toMerged'; describe('toMerged', () => { @@ -183,4 +183,9 @@ describe('toMerged', () => { expect(result).toEqual({ a: { b: { x: 1 }, c: { y: 2 }, d: { z: 3 } } }); expect(target).toEqual({ a: { b: null, c: undefined, d: 'text' } }); }); + + it('should have correct types for toMerged.deep', () => { + const result = toMerged.deep({ a: { x: 1 } }, { a: { y: '2' } }); + expectTypeOf(result).toEqualTypeOf<{ a: { x: number; y: string } }>(); + }); }); diff --git a/src/object/toMerged.ts b/src/object/toMerged.ts index 42d3fd6f7..ed698dff2 100644 --- a/src/object/toMerged.ts +++ b/src/object/toMerged.ts @@ -1,3 +1,4 @@ +import type { MergeDeep } from '../_internal/types/MergeDeep.ts'; import { clone } from './clone.ts'; import { mergeWith } from './mergeWith.ts'; import { isPlainObject } from '../predicate/isPlainObject.ts'; @@ -64,3 +65,29 @@ export function toMerged, S extends Record} A new object with deeply merged types. + * + * @example + * const target = { a: 1, b: { x: 1, y: 2 } }; + * const source = { b: { y: 3, z: 4 }, c: 5 }; + * + * const result = toMerged.deep(target, source); + * // result type: { a: number; b: { x: number; y: number; z: number }; c: number } + * // target remains unchanged + */ +export namespace toMerged { + export function deep, S extends Record>( + target: T, + source: S + ): MergeDeep { + return toMerged(target, source) as any; + } +} From 061fb2a60665a17dcd3deb7891cfa9232d3ff608 Mon Sep 17 00:00:00 2001 From: DaeHee Lee Date: Tue, 21 Apr 2026 14:47:06 +0900 Subject: [PATCH 2/5] refactor: address Copilot review feedback - Widen non-tuple array element type to include T[number] - Replace 'as any' with explicit 'as MergeDeep' casts - Add JSDoc disclaimer for mergeWith.deep customizer behavior --- pr-body.md | 60 --------------------------- src/_internal/types/MergeDeep.spec.ts | 2 +- src/_internal/types/MergeDeep.ts | 4 +- src/object/merge.ts | 2 +- src/object/mergeWith.ts | 6 ++- src/object/toMerged.ts | 2 +- 6 files changed, 10 insertions(+), 66 deletions(-) delete mode 100644 pr-body.md diff --git a/pr-body.md b/pr-body.md deleted file mode 100644 index 1b611e579..000000000 --- a/pr-body.md +++ /dev/null @@ -1,60 +0,0 @@ -## Summary - -Adds a new `MergeDeep` 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` 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` in `src/_internal/types/MergeDeep.ts` - - Deeply merges plain objects - - Preserves tuple lengths (`[A, B]` + `[C, D]` → `[MergeDeep, MergeDeep]`) - - 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` - - `mergeWith.deep(target, source, mergeFn)` → `MergeDeep` - - `toMerged.deep(target, source)` → `MergeDeep` -- **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) diff --git a/src/_internal/types/MergeDeep.spec.ts b/src/_internal/types/MergeDeep.spec.ts index bfb11dbdc..f31ea26f0 100644 --- a/src/_internal/types/MergeDeep.spec.ts +++ b/src/_internal/types/MergeDeep.spec.ts @@ -55,7 +55,7 @@ describe('MergeDeep', () => { type Source = { arr: string[] }; type Result = MergeDeep; - expectTypeOf().toEqualTypeOf>(); + expectTypeOf().toEqualTypeOf>(); }); it('should merge tuple elements deeply', () => { diff --git a/src/_internal/types/MergeDeep.ts b/src/_internal/types/MergeDeep.ts index 807bae720..e8a86c50b 100644 --- a/src/_internal/types/MergeDeep.ts +++ b/src/_internal/types/MergeDeep.ts @@ -103,8 +103,8 @@ export type MergeDeep = ? IsTuple extends true ? IsTuple extends true ? MergeArrays - : Array> - : Array> + : Array> + : Array> : S extends object ? T & S : S diff --git a/src/object/merge.ts b/src/object/merge.ts index f5ebcbe34..35033be6f 100644 --- a/src/object/merge.ts +++ b/src/object/merge.ts @@ -92,7 +92,7 @@ export namespace merge { target: T, source: S ): MergeDeep { - return merge(target, source) as any; + return merge(target, source) as MergeDeep; } } diff --git a/src/object/mergeWith.ts b/src/object/mergeWith.ts index 0d9d18244..1eca15b34 100644 --- a/src/object/mergeWith.ts +++ b/src/object/mergeWith.ts @@ -100,6 +100,10 @@ export function mergeWith, S extends Record

any} merge - A custom merge function that defines how properties should be combined. * @returns {MergeDeep} The updated target object with deeply merged types. * + * Note: The return type `MergeDeep` assumes the default merge path + * (the customizer returns `undefined`). If the customizer returns a non-undefined value, + * the actual result type may differ. + * * @example * const target = { a: 1, b: 2 }; * const source = { b: 3, c: 4 }; @@ -117,6 +121,6 @@ export namespace mergeWith { source: S, merge: (targetValue: any, sourceValue: any, key: string, target: T, source: S) => any ): MergeDeep { - return mergeWith(target, source, merge) as any; + return mergeWith(target, source, merge) as MergeDeep; } } diff --git a/src/object/toMerged.ts b/src/object/toMerged.ts index ed698dff2..4c2112ebe 100644 --- a/src/object/toMerged.ts +++ b/src/object/toMerged.ts @@ -88,6 +88,6 @@ export namespace toMerged { target: T, source: S ): MergeDeep { - return toMerged(target, source) as any; + return toMerged(target, source) as MergeDeep; } } From 90dedf5abb66dbb2d8c43cb3a1c0a774b91f35b2 Mon Sep 17 00:00:00 2001 From: DaeHee Lee Date: Tue, 21 Apr 2026 14:51:48 +0900 Subject: [PATCH 3/5] refactor(MergeDeep): simplify _MergeLiteralObjects using RequiredKeys/OptionalKeys directly --- src/_internal/types/MergeDeep.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/_internal/types/MergeDeep.ts b/src/_internal/types/MergeDeep.ts index e8a86c50b..7bd665633 100644 --- a/src/_internal/types/MergeDeep.ts +++ b/src/_internal/types/MergeDeep.ts @@ -67,7 +67,7 @@ type MergeArrays = T extends // Merge literal keys deeply type _MergeLiteralObjects = Simplify<{ - [K in (keyof T | keyof S) & (RequiredKeys | RequiredKeys)]: K extends keyof S + [K in RequiredKeys | RequiredKeys]: K extends keyof S ? K extends keyof T ? MergeDeep : S[K] @@ -75,7 +75,7 @@ type _MergeLiteralObjects = Simplify<{ ? T[K] : never; } & { - [K in (keyof T | keyof S) as K extends RequiredKeys | RequiredKeys ? never : K]?: K extends keyof S + [K in OptionalKeys | OptionalKeys]?: K extends keyof S ? K extends keyof T ? MergeDeep : S[K] From b71287eecdc56b13edb630d6865af88713ac2a89 Mon Sep 17 00:00:00 2001 From: DaeHee Lee Date: Tue, 21 Apr 2026 17:01:43 +0900 Subject: [PATCH 4/5] Refactor merge internals and tighten deep types --- src/_internal/mergeInternal.ts | 109 ++++++++++++++++++++++++++ src/_internal/types/MergeDeep.spec.ts | 36 ++++++--- src/_internal/types/MergeDeep.ts | 34 ++++---- src/object/merge.ts | 42 +--------- src/object/mergeWith.spec.ts | 30 ++++++- src/object/mergeWith.ts | 88 ++++++++++----------- src/object/toMerged.spec.ts | 52 ++++++++++++ src/object/toMerged.ts | 30 +------ 8 files changed, 284 insertions(+), 137 deletions(-) create mode 100644 src/_internal/mergeInternal.ts diff --git a/src/_internal/mergeInternal.ts b/src/_internal/mergeInternal.ts new file mode 100644 index 000000000..5fb16ae0f --- /dev/null +++ b/src/_internal/mergeInternal.ts @@ -0,0 +1,109 @@ +import { isUnsafeProperty } from './isUnsafeProperty.ts'; +import { clone } from '../object/clone.ts'; +import { isPlainObject } from '../predicate/isPlainObject.ts'; + +export type MergeArray = unknown[]; +export type MergeObject = Record; +export type MergeInput = MergeObject | MergeArray; +type MergeContainer = Record; + +export type MergeRuntimeCustomizer = ( + targetValue: unknown, + sourceValue: unknown, + key: string, + target: MergeInput, + source: MergeInput +) => unknown; + +export function isMergeArray(value: unknown): value is MergeArray { + return Array.isArray(value); +} + +export function isMergeObject(value: unknown): value is MergeObject { + return isPlainObject(value); +} + +export function mergeInto(target: T, source: MergeInput): T { + const targetContainer = target as MergeContainer; + const sourceContainer = source as MergeContainer; + const sourceKeys = Object.keys(sourceContainer); + + for (let i = 0; i < sourceKeys.length; i++) { + const key = sourceKeys[i]; + + if (isUnsafeProperty(key)) { + continue; + } + + const sourceValue = sourceContainer[key]; + const targetValue = targetContainer[key]; + const sourceIsArray = isMergeArray(sourceValue); + const sourceIsObject = isMergeObject(sourceValue); + const targetIsArray = isMergeArray(targetValue); + const targetIsObject = isMergeObject(targetValue); + + if ((sourceIsArray || sourceIsObject) && (targetIsArray || targetIsObject)) { + targetContainer[key] = mergeInto(targetValue as MergeInput, sourceValue as MergeInput); + } else if (sourceIsArray) { + targetContainer[key] = mergeInto([], sourceValue); + } else if (sourceIsObject) { + targetContainer[key] = mergeInto({}, sourceValue); + } else if (targetValue === undefined || sourceValue !== undefined) { + targetContainer[key] = sourceValue; + } + } + + return target; +} + +export function mergeWithInto(target: T, source: MergeInput, merge: MergeRuntimeCustomizer): T { + const targetContainer = target as MergeContainer; + const sourceContainer = source as MergeContainer; + const sourceKeys = Object.keys(sourceContainer); + + for (let i = 0; i < sourceKeys.length; i++) { + const key = sourceKeys[i]; + + if (isUnsafeProperty(key)) { + continue; + } + + const sourceValue = sourceContainer[key]; + const targetValue = targetContainer[key]; + const merged = merge(targetValue, sourceValue, key, target, source); + + if (merged !== undefined) { + targetContainer[key] = merged; + } else if (isMergeArray(sourceValue)) { + const nextTarget = isMergeArray(targetValue) ? targetValue : []; + targetContainer[key] = mergeWithInto(nextTarget, sourceValue, merge); + } else if (isMergeObject(sourceValue)) { + const nextTarget = isMergeObject(targetValue) ? targetValue : {}; + targetContainer[key] = mergeWithInto(nextTarget, sourceValue, merge); + } else if (targetValue === undefined || sourceValue !== undefined) { + targetContainer[key] = sourceValue; + } + } + + return target; +} + +export function toMergedInto(target: MergeInput, source: MergeInput): MergeInput { + function mergeRecursively(targetValue: unknown, sourceValue: unknown): unknown { + if (isMergeArray(sourceValue)) { + return isMergeArray(targetValue) + ? mergeWithInto(clone(targetValue), sourceValue, mergeRecursively) + : mergeWithInto([], sourceValue, mergeRecursively); + } + + if (isMergeObject(sourceValue)) { + return isMergeObject(targetValue) + ? mergeWithInto(clone(targetValue), sourceValue, mergeRecursively) + : mergeWithInto({}, sourceValue, mergeRecursively); + } + + return undefined; + } + + return mergeWithInto(clone(target), source, mergeRecursively); +} diff --git a/src/_internal/types/MergeDeep.spec.ts b/src/_internal/types/MergeDeep.spec.ts index f31ea26f0..318ce7158 100644 --- a/src/_internal/types/MergeDeep.spec.ts +++ b/src/_internal/types/MergeDeep.spec.ts @@ -58,6 +58,14 @@ describe('MergeDeep', () => { expectTypeOf().toEqualTypeOf>(); }); + it('should preserve source-only appended element types in non-tuple arrays', () => { + type Target = { arr: Array<{ a: number }> }; + type Source = { arr: Array<{ b: string }> }; + type Result = MergeDeep; + + expectTypeOf().toEqualTypeOf>(); + }); + it('should merge tuple elements deeply', () => { type Target = { arr: [{ a: number }] }; type Source = { arr: [{ b: string }] }; @@ -165,34 +173,42 @@ describe('MergeDeep', () => { it('should handle string index signatures', () => { type Target = { [x: string]: number; a: 1 }; - type Source = { [x: string]: string; b: '2' }; + type Source = { [x: string]: string; a: '1'; b: '2' }; type Result = MergeDeep; expectTypeOf().toMatchTypeOf<{ - [x: string]: number | string; - a: 1; + [x: string]: string; + a: '1'; b: '2'; }>(); expectTypeOf<{ - [x: string]: number | string; - a: 1; + [x: string]: string; + a: '1'; b: '2'; }>().toMatchTypeOf(); }); + it('should deeply merge string index signature values', () => { + type Target = Record; + type Source = Record; + type Result = MergeDeep; + + expectTypeOf().toEqualTypeOf<{ a: number; b: string }>(); + }); + it('should handle number index signatures', () => { type Target = { [x: number]: boolean; 0: true }; - type Source = { [x: number]: string; 1: 'hello' }; + type Source = { [x: number]: string; 0: 'true'; 1: 'hello' }; type Result = MergeDeep; expectTypeOf().toMatchTypeOf<{ - [x: number]: boolean | string; - 0: true; + [x: number]: string; + 0: 'true'; 1: 'hello'; }>(); expectTypeOf<{ - [x: number]: boolean | string; - 0: true; + [x: number]: string; + 0: 'true'; 1: 'hello'; }>().toMatchTypeOf(); }); diff --git a/src/_internal/types/MergeDeep.ts b/src/_internal/types/MergeDeep.ts index 7bd665633..eb64391e6 100644 --- a/src/_internal/types/MergeDeep.ts +++ b/src/_internal/types/MergeDeep.ts @@ -3,11 +3,11 @@ type Simplify = { [K in keyof T]: T[K] } & {}; type BuiltIn = | Date | RegExp - | Map - | Set - | WeakMap - | WeakSet - | Promise + | Map + | Set + | WeakMap + | WeakSet + | Promise | Error | ArrayBuffer | DataView @@ -22,7 +22,7 @@ type BuiltIn = | Float64Array | BigInt64Array | BigUint64Array - | ((...args: any[]) => any); + | ((...args: never[]) => unknown); // Index Signature helpers type PickIndexSignature = { @@ -35,13 +35,14 @@ type OmitIndexSignature = { type GetStringIndex = string extends keyof T ? T[string] : never; type GetNumberIndex = number extends keyof T ? T[number] : never; +type MergeIndexedValues = [T] extends [never] ? S : [S] extends [never] ? T : MergeDeep; type MergeIndexSignatures = Simplify< (string extends keyof PickIndexSignature | keyof PickIndexSignature - ? { [x: string]: GetStringIndex> | GetStringIndex> } + ? { [x: string]: MergeIndexedValues>, GetStringIndex>> } : {}) & (number extends keyof PickIndexSignature | keyof PickIndexSignature - ? { [x: number]: GetNumberIndex> | GetNumberIndex> } + ? { [x: number]: MergeIndexedValues>, GetNumberIndex>> } : {}) >; @@ -53,13 +54,18 @@ type RequiredKeys = Exclude>; type Head = T extends readonly [infer H, ...infer _] ? H : never; type Tail = T extends readonly [infer _, ...infer Rest] ? Rest : never; -type IsTuple = T extends readonly any[] +type IsTuple = T extends readonly unknown[] ? number extends T['length'] ? false : true : false; -type MergeArrays = T extends [] +type MergeArrayElements = + | T[number] + | S[number] + | MergeDeep; + +type MergeArrays = T extends [] ? S : S extends [] ? T @@ -98,13 +104,13 @@ export type MergeDeep = ? S : T extends BuiltIn ? S - : T extends readonly any[] - ? S extends readonly any[] + : T extends readonly unknown[] + ? S extends readonly unknown[] ? IsTuple extends true ? IsTuple extends true ? MergeArrays - : Array> - : Array> + : Array> + : Array> : S extends object ? T & S : S diff --git a/src/object/merge.ts b/src/object/merge.ts index 35033be6f..6e6f9d736 100644 --- a/src/object/merge.ts +++ b/src/object/merge.ts @@ -1,6 +1,5 @@ +import { type MergeInput, mergeInto } from '../_internal/mergeInternal.ts'; import type { MergeDeep } from '../_internal/types/MergeDeep.ts'; -import { isUnsafeProperty } from '../_internal/isUnsafeProperty.ts'; -import { isPlainObject } from '../predicate/isPlainObject.ts'; /** * Merges the properties of the source object into the target object. @@ -42,34 +41,8 @@ import { isPlainObject } from '../predicate/isPlainObject.ts'; * console.log(result); * // Output: { a: [1, 2, 3] } */ -export function merge, S extends Record>( - target: T, - source: S -): T & S { - const sourceKeys = Object.keys(source) as Array; - - for (let i = 0; i < sourceKeys.length; i++) { - const key = sourceKeys[i]; - - if (isUnsafeProperty(key)) { - continue; - } - - const sourceValue = source[key]; - const targetValue = target[key]; - - if (isMergeableValue(sourceValue) && isMergeableValue(targetValue)) { - target[key] = merge(targetValue, sourceValue); - } else if (Array.isArray(sourceValue)) { - target[key] = merge([], sourceValue); - } else if (isPlainObject(sourceValue)) { - target[key] = merge({}, sourceValue); - } else if (targetValue === undefined || sourceValue !== undefined) { - target[key] = sourceValue; - } - } - - return target; +export function merge(target: T, source: S): T & S { + return mergeInto(target, source) as T & S; } /** @@ -88,14 +61,7 @@ export function merge, S extends Record, S extends Record>( - target: T, - source: S - ): MergeDeep { + export function deep(target: T, source: S): MergeDeep { return merge(target, source) as MergeDeep; } } - -function isMergeableValue(value: unknown) { - return isPlainObject(value) || Array.isArray(value); -} diff --git a/src/object/mergeWith.spec.ts b/src/object/mergeWith.spec.ts index 4dec57887..6de70b487 100644 --- a/src/object/mergeWith.spec.ts +++ b/src/object/mergeWith.spec.ts @@ -30,7 +30,16 @@ describe('mergeWith', () => { const source3 = { a: { x: 2, y: 3 }, b: [4] }; const result3 = mergeWith(target3, source3, (objValue, srcValue) => { - if (objValue.x && srcValue.x) { + if ( + typeof objValue === 'object' && + objValue !== null && + 'x' in objValue && + typeof srcValue === 'object' && + srcValue !== null && + 'x' in srcValue && + typeof objValue.x === 'number' && + typeof srcValue.x === 'number' + ) { return objValue.x + srcValue.x; } }); @@ -126,8 +135,27 @@ describe('mergeWith', () => { expect(result).toEqual({ a: { b: { x: 1 }, c: { y: 2 }, d: { z: 3 } } }); }); + it('should prefer the source container kind for nested mixed array and object values when the customizer falls back to the default merge', () => { + const nestedArray = mergeWith({ x: ['1'] }, { x: { a: 2 } }, () => undefined); + const nestedObject = mergeWith({ x: { a: 2 } }, { x: ['1'] }, () => undefined); + const sourceArray = ['1']; + const sourceObject = { a: 2 }; + + expect(nestedArray).toEqual({ x: sourceObject }); + expect(nestedArray.x).not.toBe(sourceObject); + + expect(nestedObject).toEqual({ x: sourceArray }); + expect(Array.isArray(nestedObject.x)).toBe(true); + expect(nestedObject.x).not.toBe(sourceArray); + }); + it('should have correct types for mergeWith.deep', () => { const result = mergeWith.deep({ a: { x: 1 } }, { a: { y: '2' } }, () => undefined); expectTypeOf(result).toEqualTypeOf<{ a: { x: number; y: string } }>(); }); + + it('should widen mergeWith.deep property types when customizer can override values', () => { + const result = mergeWith.deep({ a: { x: 1 } }, { a: { y: '2' } }, () => true as const); + expectTypeOf(result).toEqualTypeOf<{ a: true | { x: true | number; y: true | string } }>(); + }); }); diff --git a/src/object/mergeWith.ts b/src/object/mergeWith.ts index 1eca15b34..2365938fc 100644 --- a/src/object/mergeWith.ts +++ b/src/object/mergeWith.ts @@ -1,6 +1,31 @@ +import { type MergeObject, type MergeRuntimeCustomizer, mergeWithInto } from '../_internal/mergeInternal.ts'; import type { MergeDeep } from '../_internal/types/MergeDeep.ts'; -import { isUnsafeProperty } from '../_internal/isUnsafeProperty.ts'; -import { isPlainObject } from '../predicate/isPlainObject.ts'; + +type MergeRecord = MergeObject; +type Defined = Exclude; +type MergeWithCustomizer = ( + targetValue: unknown, + sourceValue: unknown, + key: string, + target: T, + source: S +) => R | undefined; + +type MergeWithDeepValue = [Defined] extends [never] + ? T + : T extends readonly unknown[] + ? Defined | Array> + : T extends object + ? Defined | { [K in keyof T]: MergeWithDeepValue } + : Defined | T; + +type MergeWithDeepResult = [Defined] extends [never] + ? T + : T extends readonly unknown[] + ? Array> + : T extends object + ? { [K in keyof T]: MergeWithDeepValue } + : T; /** * Merges the properties of the source object into the target object. @@ -16,7 +41,7 @@ import { isPlainObject } from '../predicate/isPlainObject.ts'; * * @param {T} target - The target object into which the source object properties will be merged. This object is modified in place. * @param {S} source - The source object whose properties will be merged into the target object. - * @param {(targetValue: any, sourceValue: any, key: string, target: T, source: S) => any} merge - A custom merge function that defines how properties should be combined. It receives the following arguments: + * @param {MergeWithCustomizer} merge - A custom merge function that defines how properties should be combined. It receives the following arguments: * - `targetValue`: The current value of the property in the target object. * - `sourceValue`: The value of the property in the source object. * - `key`: The key of the property being merged. @@ -27,6 +52,7 @@ import { isPlainObject } from '../predicate/isPlainObject.ts'; * * @template T - Type of the target object. * @template S - Type of the source object. + * @template R - Type returned by the custom merge function when overriding a value. * * @example * const target = { a: 1, b: 2 }; @@ -50,45 +76,12 @@ import { isPlainObject } from '../predicate/isPlainObject.ts'; * * expect(result).toEqual({ a: [1, 3], b: [2, 4] }); */ -export function mergeWith, S extends Record>( +export function mergeWith( target: T, source: S, - merge: (targetValue: any, sourceValue: any, key: string, target: T, source: S) => any + merge: MergeWithCustomizer ): T & S { - const sourceKeys = Object.keys(source) as Array; - - for (let i = 0; i < sourceKeys.length; i++) { - const key = sourceKeys[i]; - - if (isUnsafeProperty(key)) { - continue; - } - - const sourceValue = source[key]; - const targetValue = target[key]; - - const merged = merge(targetValue, sourceValue, key as string, target, source); - - if (merged !== undefined) { - target[key] = merged; - } else if (Array.isArray(sourceValue)) { - if (Array.isArray(targetValue)) { - target[key] = mergeWith(targetValue, sourceValue, merge); - } else { - target[key] = mergeWith([], sourceValue, merge); - } - } else if (isPlainObject(sourceValue)) { - if (isPlainObject(targetValue)) { - target[key] = mergeWith(targetValue, sourceValue, merge); - } else { - target[key] = mergeWith({}, sourceValue, merge); - } - } else if (targetValue === undefined || sourceValue !== undefined) { - target[key] = sourceValue; - } - } - - return target; + return mergeWithInto(target, source, merge as MergeRuntimeCustomizer) as T & S; } /** @@ -97,12 +90,11 @@ export function mergeWith, S extends Record

any} merge - A custom merge function that defines how properties should be combined. - * @returns {MergeDeep} The updated target object with deeply merged types. + * @param {MergeWithCustomizer} merge - A custom merge function that defines how properties should be combined. + * @returns {MergeWithDeepResult, R>} The updated target object with deeply merged types. * - * Note: The return type `MergeDeep` assumes the default merge path - * (the customizer returns `undefined`). If the customizer returns a non-undefined value, - * the actual result type may differ. + * If the customizer returns a non-`undefined` value, the corresponding property type is widened + * to include that override value in addition to the default deep-merge result. * * @example * const target = { a: 1, b: 2 }; @@ -116,11 +108,11 @@ export function mergeWith, S extends Record

, S extends Record>( + export function deep( target: T, source: S, - merge: (targetValue: any, sourceValue: any, key: string, target: T, source: S) => any - ): MergeDeep { - return mergeWith(target, source, merge) as MergeDeep; + merge: MergeWithCustomizer + ): MergeWithDeepResult, R> { + return mergeWith(target, source, merge) as MergeWithDeepResult, R>; } } diff --git a/src/object/toMerged.spec.ts b/src/object/toMerged.spec.ts index 9a9263e70..dfac3cc3a 100644 --- a/src/object/toMerged.spec.ts +++ b/src/object/toMerged.spec.ts @@ -166,6 +166,58 @@ describe('toMerged', () => { expect(target).toEqual({ a: { b: { c: [1] } } }); }); + it('should preserve top-level mixed array and object behavior without mutating the target', () => { + const topLevelArrayTarget = ['1']; + const topLevelObjectTarget = { a: 2 }; + + const topLevelArray = toMerged(topLevelArrayTarget, { a: 2 }); + const topLevelObject = toMerged(topLevelObjectTarget, ['1']); + + expect(Array.isArray(topLevelArray)).toBe(true); + expect(topLevelArray[0]).toBe('1'); + expect(topLevelArray.a).toBe(2); + + expect(typeof topLevelObject).toBe('object'); + expect(topLevelObject).toEqual({ a: 2, 0: '1' }); + + expect(topLevelArrayTarget).toEqual(['1']); + expect(topLevelObjectTarget).toEqual({ a: 2 }); + }); + + it('should prefer the source container kind for nested mixed array and object values without mutating the target', () => { + const nestedArrayTarget = { x: ['1'] }; + const nestedObjectTarget = { x: { a: 2 } }; + const sourceArray = ['1']; + const sourceObject = { a: 2 }; + + const nestedArray = toMerged(nestedArrayTarget, { x: sourceObject }); + const nestedObject = toMerged(nestedObjectTarget, { x: sourceArray }); + + expect(nestedArray).toEqual({ x: sourceObject }); + expect(nestedArray.x).not.toBe(sourceObject); + + expect(nestedObject).toEqual({ x: sourceArray }); + expect(Array.isArray(nestedObject.x)).toBe(true); + expect(nestedObject.x).not.toBe(sourceArray); + + expect(nestedArrayTarget).toEqual({ x: ['1'] }); + expect(nestedObjectTarget).toEqual({ x: { a: 2 } }); + }); + + it('should clone arrays and plain objects copied from source', () => { + const sourceArray = [1, 2, 3]; + const sourceObject = { x: 1 }; + const target: { a?: number[]; b?: { x: number } } = {}; + const source = { a: sourceArray, b: sourceObject }; + + const result = toMerged(target, source); + + expect(result).toEqual({ a: sourceArray, b: sourceObject }); + expect(result.a).not.toBe(sourceArray); + expect(result.b).not.toBe(sourceObject); + expect(target).toEqual({}); + }); + it('should replace non-plain-object target value with plain object from source', () => { const target = { a: 'string', b: 123, c: true }; const source = { a: { x: 1 }, b: { y: 2 }, c: { z: 3 } }; diff --git a/src/object/toMerged.ts b/src/object/toMerged.ts index 4c2112ebe..23ba8d695 100644 --- a/src/object/toMerged.ts +++ b/src/object/toMerged.ts @@ -1,7 +1,5 @@ +import { type MergeInput, toMergedInto } from '../_internal/mergeInternal.ts'; import type { MergeDeep } from '../_internal/types/MergeDeep.ts'; -import { clone } from './clone.ts'; -import { mergeWith } from './mergeWith.ts'; -import { isPlainObject } from '../predicate/isPlainObject.ts'; /** * Merges the properties of the source object into a deep clone of the target object. @@ -45,25 +43,8 @@ import { isPlainObject } from '../predicate/isPlainObject.ts'; * console.log(result); * // Output: { a: [1, 2, 3] } */ -export function toMerged, S extends Record>( - target: T, - source: S -): T & S { - return mergeWith(clone(target), source, function mergeRecursively(targetValue, sourceValue) { - if (Array.isArray(sourceValue)) { - if (Array.isArray(targetValue)) { - return mergeWith(clone(targetValue), sourceValue, mergeRecursively); - } else { - return mergeWith([], sourceValue, mergeRecursively); - } - } else if (isPlainObject(sourceValue)) { - if (isPlainObject(targetValue)) { - return mergeWith(clone(targetValue), sourceValue, mergeRecursively); - } else { - return mergeWith({}, sourceValue, mergeRecursively); - } - } - }); +export function toMerged(target: T, source: S): T & S { + return toMergedInto(target, source) as T & S; } /** @@ -84,10 +65,7 @@ export function toMerged, S extends Record, S extends Record>( - target: T, - source: S - ): MergeDeep { + export function deep(target: T, source: S): MergeDeep { return toMerged(target, source) as MergeDeep; } } From 2269aae961cab94449fcb00692c23355e3cc7ab0 Mon Sep 17 00:00:00 2001 From: DaeHee Lee Date: Tue, 21 Apr 2026 17:13:35 +0900 Subject: [PATCH 5/5] Align mergeWith input types with merge --- src/object/mergeWith.spec.ts | 17 +++++++++++++++++ src/object/mergeWith.ts | 9 ++++----- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/object/mergeWith.spec.ts b/src/object/mergeWith.spec.ts index 6de70b487..84576a873 100644 --- a/src/object/mergeWith.spec.ts +++ b/src/object/mergeWith.spec.ts @@ -149,11 +149,28 @@ describe('mergeWith', () => { expect(nestedObject.x).not.toBe(sourceArray); }); + it('should support top-level mixed array and object values', () => { + const topLevelArray = mergeWith(['1'], { a: 2 }, () => undefined); + const topLevelObject = mergeWith({ a: 2 }, ['1'], () => undefined); + + expect(Array.isArray(topLevelArray)).toBe(true); + expect(topLevelArray[0]).toBe('1'); + expect(topLevelArray.a).toBe(2); + + expect(typeof topLevelObject).toBe('object'); + expect(topLevelObject).toEqual({ a: 2, 0: '1' }); + }); + it('should have correct types for mergeWith.deep', () => { const result = mergeWith.deep({ a: { x: 1 } }, { a: { y: '2' } }, () => undefined); expectTypeOf(result).toEqualTypeOf<{ a: { x: number; y: string } }>(); }); + it('should have correct types for top-level array inputs', () => { + const result = mergeWith.deep([{ x: 1 }], [{ y: '2' }], () => undefined); + expectTypeOf(result).toEqualTypeOf>(); + }); + it('should widen mergeWith.deep property types when customizer can override values', () => { const result = mergeWith.deep({ a: { x: 1 } }, { a: { y: '2' } }, () => true as const); expectTypeOf(result).toEqualTypeOf<{ a: true | { x: true | number; y: true | string } }>(); diff --git a/src/object/mergeWith.ts b/src/object/mergeWith.ts index 2365938fc..530ad46ec 100644 --- a/src/object/mergeWith.ts +++ b/src/object/mergeWith.ts @@ -1,9 +1,8 @@ -import { type MergeObject, type MergeRuntimeCustomizer, mergeWithInto } from '../_internal/mergeInternal.ts'; +import { type MergeInput, type MergeRuntimeCustomizer, mergeWithInto } from '../_internal/mergeInternal.ts'; import type { MergeDeep } from '../_internal/types/MergeDeep.ts'; -type MergeRecord = MergeObject; type Defined = Exclude; -type MergeWithCustomizer = ( +type MergeWithCustomizer = ( targetValue: unknown, sourceValue: unknown, key: string, @@ -76,7 +75,7 @@ type MergeWithDeepResult = [Defined] extends [never] * * expect(result).toEqual({ a: [1, 3], b: [2, 4] }); */ -export function mergeWith( +export function mergeWith( target: T, source: S, merge: MergeWithCustomizer @@ -108,7 +107,7 @@ export function mergeWith( + export function deep( target: T, source: S, merge: MergeWithCustomizer