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 new file mode 100644 index 000000000..318ce7158 --- /dev/null +++ b/src/_internal/types/MergeDeep.spec.ts @@ -0,0 +1,215 @@ +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 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 }] }; + 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; a: '1'; b: '2' }; + type Result = MergeDeep; + + expectTypeOf().toMatchTypeOf<{ + [x: string]: string; + a: '1'; + b: '2'; + }>(); + expectTypeOf<{ + [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; 0: 'true'; 1: 'hello' }; + type Result = MergeDeep; + + expectTypeOf().toMatchTypeOf<{ + [x: number]: string; + 0: 'true'; + 1: 'hello'; + }>(); + expectTypeOf<{ + [x: number]: 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..eb64391e6 --- /dev/null +++ b/src/_internal/types/MergeDeep.ts @@ -0,0 +1,121 @@ +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: never[]) => unknown); + +// 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 MergeIndexedValues = [T] extends [never] ? S : [S] extends [never] ? T : MergeDeep; + +type MergeIndexSignatures = Simplify< + (string extends keyof PickIndexSignature | keyof PickIndexSignature + ? { [x: string]: MergeIndexedValues>, GetStringIndex>> } + : {}) & + (number extends keyof PickIndexSignature | keyof PickIndexSignature + ? { [x: number]: MergeIndexedValues>, 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 unknown[] + ? number extends T['length'] + ? false + : true + : false; + +type MergeArrayElements = + | T[number] + | S[number] + | MergeDeep; + +type MergeArrays = T extends [] + ? S + : S extends [] + ? T + : [MergeDeep, Head>, ...MergeArrays, Tail>]; + +// Merge literal keys deeply +type _MergeLiteralObjects = Simplify<{ + [K in RequiredKeys | RequiredKeys]: K extends keyof S + ? K extends keyof T + ? MergeDeep + : S[K] + : K extends keyof T + ? T[K] + : never; +} & { + [K in OptionalKeys | OptionalKeys]?: 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 unknown[] + ? S extends readonly unknown[] + ? 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..6e6f9d736 100644 --- a/src/object/merge.ts +++ b/src/object/merge.ts @@ -1,5 +1,5 @@ -import { isUnsafeProperty } from '../_internal/isUnsafeProperty.ts'; -import { isPlainObject } from '../predicate/isPlainObject.ts'; +import { type MergeInput, mergeInto } from '../_internal/mergeInternal.ts'; +import type { MergeDeep } from '../_internal/types/MergeDeep.ts'; /** * Merges the properties of the source object into the target object. @@ -41,36 +41,27 @@ 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; } -function isMergeableValue(value: unknown) { - return isPlainObject(value) || Array.isArray(value); +/** + * Deeply merges the properties of the source object into the target object + * and returns a type-safe result with recursively merged types. + * + * @param {T} target - The target object into which the source object properties will be merged. + * @param {S} source - The source object whose properties will be merged into the target object. + * @returns {MergeDeep} 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(target: T, source: S): MergeDeep { + return merge(target, source) as MergeDeep; + } } diff --git a/src/object/mergeWith.spec.ts b/src/object/mergeWith.spec.ts index c2b0be3d1..84576a873 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'; @@ -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; } }); @@ -125,4 +134,45 @@ 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 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 ebec941ed..530ad46ec 100644 --- a/src/object/mergeWith.ts +++ b/src/object/mergeWith.ts @@ -1,5 +1,30 @@ -import { isUnsafeProperty } from '../_internal/isUnsafeProperty.ts'; -import { isPlainObject } from '../predicate/isPlainObject.ts'; +import { type MergeInput, type MergeRuntimeCustomizer, mergeWithInto } from '../_internal/mergeInternal.ts'; +import type { MergeDeep } from '../_internal/types/MergeDeep.ts'; + +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. @@ -15,7 +40,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. @@ -26,6 +51,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 }; @@ -49,43 +75,43 @@ 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); + return mergeWithInto(target, source, merge as MergeRuntimeCustomizer) as T & S; +} - 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; - } +/** + * Deeply merges the properties of the source object into the target object using a customizer function + * and returns a type-safe result with recursively merged types. + * + * @param {T} target - The target object into which the source object properties will be merged. + * @param {S} source - The source object whose properties will be merged into the target object. + * @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. + * + * 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 }; + * 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( + target: T, + source: S, + merge: MergeWithCustomizer + ): MergeWithDeepResult, R> { + return mergeWith(target, source, merge) as MergeWithDeepResult, R>; } - - return target; } diff --git a/src/object/toMerged.spec.ts b/src/object/toMerged.spec.ts index 6642fa257..dfac3cc3a 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', () => { @@ -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 } }; @@ -183,4 +235,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..23ba8d695 100644 --- a/src/object/toMerged.ts +++ b/src/object/toMerged.ts @@ -1,6 +1,5 @@ -import { clone } from './clone.ts'; -import { mergeWith } from './mergeWith.ts'; -import { isPlainObject } from '../predicate/isPlainObject.ts'; +import { type MergeInput, toMergedInto } from '../_internal/mergeInternal.ts'; +import type { MergeDeep } from '../_internal/types/MergeDeep.ts'; /** * Merges the properties of the source object into a deep clone of the target object. @@ -44,23 +43,29 @@ 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; +} + +/** + * Deeply merges the properties of the source object into a deep clone of the target object + * and returns a type-safe result with recursively merged types. + * Unlike `merge.deep`, this function does not modify the original target object. + * + * @param {T} target - The target object to be cloned and merged into. This object is not modified directly. + * @param {S} source - The source object whose properties will be merged into the cloned target object. + * @returns {MergeDeep} 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(target: T, source: S): MergeDeep { + return toMerged(target, source) as MergeDeep; + } }