/* eslint-disable complexity */
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/naming-convention */
import { cloneDeep } from './clone-deep';
import { isObject } from '../guards/is-object';
export type IterableMergeStrategy = 'concat' | 'replace' | 'merge';
export interface MergeDeepOptions {
/**
* Strategy to merge arrays, maps, and sets.
* - `'concat'` will concatenate,
* - `'replace'` will replace the target with the source,
* - `'merge'` will merge the two.
* - A function can be provided as well that conditionally selects one of the options above, depending on the property path
*/
iterableMergeStrategy?:
| IterableMergeStrategy
| ((path: string[]) => IterableMergeStrategy);
/**
* Skip assigning undefined values from the source object. defaults to `false`.
*/
skipAssignUndefined?: boolean;
}
/**
* Deep merge two objects. The source object takes precedence over the target object.
*
* @param target object to merge into
* @param source object to merge (takes precedence)
* @param options iterableMergeStrategy: 'concat' | 'replace' | 'merge' - default: 'concat'
* @returns new object where all values of both are present, in case of conflicts, the source value is used.
*/
export const mergeDeep = (
target: A,
source: B,
options: MergeDeepOptions = {},
path: string[] = []
): DeepMergeObjects => {
const resolveMergeStrategy = (
propertyPath: string[]
): IterableMergeStrategy =>
typeof options.iterableMergeStrategy === 'function'
? options.iterableMergeStrategy(propertyPath)
: (options.iterableMergeStrategy ?? 'concat');
const skipAssignUndefined = options.skipAssignUndefined ?? false;
if (
!(Array.isArray(target) || isObject(target)) ||
!(Array.isArray(source) || isObject(source))
) {
throw new Error(
'[deepMergeObjects] target and source must be objects or arrays'
);
}
const result: Record = cloneDeep(target);
for (const [key, value] of Object.entries(source)) {
// cast to any, as we are going to check for the type later on. TS will not allow us to do this otherwise.
const valueToMerge = value as any;
const currentValue = result[key] as any;
if (skipAssignUndefined && valueToMerge === undefined) {
continue;
}
const childPath = [...path, key];
if (Array.isArray(valueToMerge)) {
const mergeStrategy = resolveMergeStrategy(childPath);
switch (mergeStrategy) {
case 'concat': {
result[key] = (currentValue ?? []).concat(valueToMerge);
break;
}
case 'replace': {
result[key] = cloneDeep(valueToMerge);
break;
}
case 'merge': {
result[key] = deepMergeArray(
currentValue ?? [],
valueToMerge,
options,
childPath
);
break;
}
}
} else if (valueToMerge instanceof Set) {
const mergeStrategy = resolveMergeStrategy(childPath);
if (mergeStrategy === 'replace' || currentValue === undefined) {
result[key] = new Set(valueToMerge);
} else {
if (!(currentValue instanceof Set)) {
throw new Error(
'[deepMergeObjects] Cannot merge or concat a Set with a non-Set'
);
}
result[key] = new Set([...currentValue, ...valueToMerge]);
}
} else if (valueToMerge instanceof Map) {
const mergeStrategy = resolveMergeStrategy(childPath);
if (mergeStrategy === 'replace' || currentValue === undefined) {
result[key] = new Map(valueToMerge);
} else {
if (!(currentValue instanceof Map)) {
throw new Error(
'[deepMergeObjects] Cannot merge or concat a Map with a non-Map'
);
}
result[key] = new Map([...currentValue, ...valueToMerge]);
}
} else if (isObject(valueToMerge)) {
result[key] = mergeDeep(
isObject(currentValue) ? currentValue : {},
valueToMerge,
options,
childPath
);
} else if (valueToMerge instanceof Date) {
result[key] = new Date(valueToMerge);
} else {
// only real primitives left, we don't need to clone them.
result[key] = valueToMerge;
}
}
return result as DeepMergeObjects;
};
/**
* returns an array, by adding the non-sparsely populated values from the target and source array
* @param target array to merge
* @param source array to merge (takes precedence)
* @returns new array where all values of both are present, in the same order. in case of conflicts, the source value is used.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const deepMergeArray = (
target: T[],
source: T[],
options: MergeDeepOptions = {},
path: string[] = []
): T[] => {
const result: T[] = target.reduce((acc, value, index) => {
acc[index] = cloneDeep(value); // clone in the original value.
return acc;
}, []);
source.forEach((value, index) => {
let valueToMerge = result[index] as any;
if (Array.isArray(value)) {
valueToMerge = deepMergeArray(valueToMerge ?? [], value, options, path);
} else if (isObject(value)) {
valueToMerge = mergeDeep(
valueToMerge ?? {},
value as Record,
options,
path
);
} else {
valueToMerge = cloneDeep(value);
}
result[index] = valueToMerge;
});
return result;
};
/** helper to merge types into each other. */
export type DeepMergeObjects = Target extends NonObject
? Target
: Source extends NonObject
? Source
: {
[K in keyof (Target & Source)]: K extends keyof Source
? K extends keyof Target
? DeepMergeObjects
: Source[K]
: K extends keyof Target
? Target[K]
: never;
};
export type NonObject =
| null
| undefined
| boolean
| number
| string
| unknown[]
| Function
| Date
| Set
| Map;