diff --git a/src/deepmerge.ts b/src/deepmerge.ts index c5ac725..e2c12cd 100644 --- a/src/deepmerge.ts +++ b/src/deepmerge.ts @@ -1,19 +1,27 @@ -import { getFullOptions } from './options' +import type { DefaultOptions, FullOptions, Options } from "./options" +import { getFullOptions } from "./options" +import type { DeepMerge, DeepMergeAll, DeepMergeObjects, Property } from "./types" import { cloneUnlessOtherwiseSpecified, getKeys, getMergeFunction, propertyIsOnObject, - propertyIsUnsafe -} from './utils' + propertyIsUnsafe, +} from "./utils" + +function mergeObject< + T1 extends Record, + T2 extends Record, + O extends FullOptions +>(target: T1, source: T2, options: O): DeepMergeObjects { + const destination: any = {} -function mergeObject(target, source, options) { - const destination = {} if (options.isMergeable(target)) { getKeys(target).forEach((key) => { destination[key] = cloneUnlessOtherwiseSpecified(target[key], options) }) } + getKeys(source).forEach((key) => { if (propertyIsUnsafe(target, key)) { return @@ -25,33 +33,77 @@ function mergeObject(target, source, options) { destination[key] = getMergeFunction(key, options)(target[key], source[key], options) } }) + return destination } -export function deepmergeImpl(target, source, options) { +export function deepmergeImpl( + target: T1, + source: T2, + options: O +): DeepMerge { const sourceIsArray = Array.isArray(source) const targetIsArray = Array.isArray(target) const sourceAndTargetTypesMatch = sourceIsArray === targetIsArray if (!sourceAndTargetTypesMatch) { - return cloneUnlessOtherwiseSpecified(source, options) + return cloneUnlessOtherwiseSpecified(source, options) as DeepMerge } else if (sourceIsArray) { - return options.arrayMerge(target, source, options) + return options.arrayMerge(target as unknown[], source as unknown[], options) as DeepMerge< + T1, + T2, + O + > } else { - return mergeObject(target, source, options) + return mergeObject( + target as Record, + source as Record, + options + ) as DeepMerge } } -export default function deepmerge(target, source, options) { +/** + * Deeply merge two objects. + * + * @param target The first object. + * @param source The second object. + * @param options Deep merge options. + */ +export default function deepmerge< + T1 extends object, + T2 extends object, + O extends Options = DefaultOptions +>(target: T1, source: T2, options?: O): DeepMerge> { return deepmergeImpl(target, source, getFullOptions(options)) } -export function deepmergeAll(array, options) { +/** + * Deeply merge two or more objects. + * + * @param objects An tuple of the objects to merge. + * @param options Deep merge options. + */ +export function deepmergeAll< + Ts extends readonly [object, ...object[]], + O extends Options = DefaultOptions +>(objects: [...Ts], options?: O): DeepMergeAll + +/** + * Deeply merge two or more objects. + * + * @param objects An array of the objects to merge. + * @param options Deep merge options. + */ +export function deepmergeAll(objects: ReadonlyArray, options?: Options): object + +/** + * Deeply merge all implementation. + */ +export function deepmergeAll(array: ReadonlyArray, options?: Options): object { if (!Array.isArray(array)) { - throw new Error('first argument should be an array') + throw new Error("first argument should be an array") } - return array.reduce((prev, next) => - deepmergeImpl(prev, next, getFullOptions(options)), {} - ) + return array.reduce((prev, next) => deepmergeImpl(prev, next, getFullOptions(options)), {}) } diff --git a/src/index.ts b/src/index.ts index 8146336..6f4835b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1,2 @@ export { default, deepmergeAll } from "./deepmerge" +export type { Options } from "./options" diff --git a/src/options.ts b/src/options.ts index 29dffc8..3675bb7 100644 --- a/src/options.ts +++ b/src/options.ts @@ -1,23 +1,74 @@ import isPlainObj from "is-plain-obj" +import type { Property } from "./types" import { cloneUnlessOtherwiseSpecified } from "./utils" -function defaultIsMergeable(value) { +/** + * Deep merge options. + */ +export interface Options { + arrayMerge?: ArrayMerge + clone?: boolean + customMerge?: ObjectMerge + isMergeable?: IsMergeable +} + +export interface FullOptions extends Options { + arrayMerge: NonNullable + clone: NonNullable + customMerge?: O["customMerge"] + isMergeable: NonNullable + cloneUnlessOtherwiseSpecified: (value: T, options: FullOptions) => T +} + +export interface DefaultOptions extends Options { + arrayMerge: typeof defaultArrayMerge + clone: true + customMerge: undefined + isMergeable: typeof defaultIsMergeable +} + +/** + * A function that determins if a type is mergable. + */ +export type IsMergeable = (value: any) => boolean + +/** + * A function that merges any 2 arrays. + */ +export type ArrayMerge = (target: any[], source: any[], options: FullOptions) => any + +/** + * A function that merges any 2 non-arrays values. + */ +export type ObjectMerge = ( + key: any +) => ((target: any, source: any, options: FullOptions) => any) | undefined + +function defaultIsMergeable(value: unknown): value is Record | Array { return Array.isArray(value) || isPlainObj(value) } -function defaultArrayMerge(target, source, options) { +function defaultArrayMerge( + target: T1, + source: T2, + options: FullOptions +) { return [...target, ...source].map((element) => cloneUnlessOtherwiseSpecified(element, options) - ) + ) as T1 extends readonly [...infer E1] + ? T2 extends readonly [...infer E2] + ? [...E1, ...E2] + : never + : never } -export function getFullOptions(options) { +export function getFullOptions(options?: O) { return { arrayMerge: defaultArrayMerge, isMergeable: defaultIsMergeable, clone: true, ...options, cloneUnlessOtherwiseSpecified: cloneUnlessOtherwiseSpecified, - } + } as FullOptions } diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..3641573 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,172 @@ +import type { FullOptions, Options } from "./options" + +/** + * Deep merge 1 or more types given in an array. + */ +export type DeepMergeAll< + Ts extends readonly [any, ...any[]], + O extends Options +> = Ts extends readonly [infer T1, ...any[]] + ? Ts extends readonly [T1, infer T2, ...infer TRest] + ? TRest extends readonly never[] + ? DeepMerge> + : DeepMerge, FullOptions> + : T1 + : never + +/** + * Deep merge 2 types. + */ +export type DeepMerge = IsSame extends true + ? T1 | T2 + : And, IsObjectOrArray> extends true + ? DeepMergeValues + : Leaf + +/** + * Deep merge 2 objects (they may be arrays). + */ +type DeepMergeValues = And, IsArray> extends true + ? DeepMergeArrays + : And, IsObject> extends true + ? DeepMergeObjects + : Leaf + +/** + * Deep merge 2 non-array objects. + */ +export type DeepMergeObjects = FlatternAlias< + // @see https://github.com/microsoft/TypeScript/issues/41448 + { + -readonly [K in keyof T1]: DeepMergeObjectProps, ValueOfKey, O> + } & + { + -readonly [K in keyof T2]: DeepMergeObjectProps, ValueOfKey, O> + } +> + +/** + * Deep merge 2 types that are known to be properties of an object being deeply + * merged. + */ +type DeepMergeObjectProps = Or< + IsUndefinedOrNever, + IsUndefinedOrNever +> extends true + ? Leaf + : O["isMergeable"] extends undefined + ? O["customMerge"] extends undefined + ? DeepMerge + : DeepMergeObjectPropsCustom + : MaybeLeaf + +/** + * Deep merge 2 types that are known to be properties of an object being deeply + * merged and where a "customMerge" function has been provided. + */ +type DeepMergeObjectPropsCustom = ReturnType< + NonNullable +> extends undefined + ? DeepMerge + : undefined extends ReturnType> + ? Or, IsArray> extends true + ? And, IsArray> extends true + ? DeepMergeArrays + : Leaf + : DeepMerge | ReturnType>>> + : ReturnType>>> + +/** + * Deep merge 2 arrays. + */ +type DeepMergeArrays = ReturnType["arrayMerge"]>> + +/** + * Get the leaf type from 2 types that can't be merged. + */ +type Leaf = IsNever extends true + ? T1 + : IsNever extends true + ? T2 + : IsUndefinedOrNever extends true + ? T1 + : T2 + +/** + * Get the leaf type from 2 types that might not be able to be merged. + */ +type MaybeLeaf = Or< + Or, IsUndefinedOrNever>, + Not, IsObjectOrArray>> +> extends true + ? Leaf + : // TODO: Handle case where return type of "isMergeable" is a typeguard. If it is we can do better than just "unknown". + unknown + +/** + * Flatten a complex type such as a union or intersection of objects into a + * single object. + */ +type FlatternAlias = {} & { [P in keyof T]: T[P] } + +/** + * Get the value of the given key in the given object. + */ +type ValueOfKey = K extends keyof T ? T[K] : never + +/** + * Safely test whether or not the first given types extends the second. + * + * Needed in particular for testing if a type is "never". + */ +type Is = [T1] extends [T2] ? true : false + +/** + * Safely test whether or not the given type is "never". + */ +type IsNever = Is + +/** + * Is the given type undefined or never? + */ +type IsUndefinedOrNever = Is + +/** + * Returns whether or not the give two types are the same. + */ +type IsSame = Is extends true ? Is : false + +/** + * Returns whether or not the given type an object (arrays are objects). + */ +type IsObjectOrArray = And>, T extends object ? true : false> + +/** + * Returns whether or not the given type a non-array object. + */ +type IsObject = And, Not>> + +/** + * Returns whether or not the given type an array. + */ +type IsArray = And>, T extends ReadonlyArray ? true : false> + +/** + * And operator for types. + */ +type And = T1 extends false ? false : T2 + +/** + * Or operator for types. + */ +type Or = T1 extends true ? true : T2 + +/** + * Not operator for types. + */ +type Not = T extends true ? false : true + +/** + * A property that can index an object. + */ +export type Property = string | number | symbol diff --git a/src/utils.ts b/src/utils.ts index a040967..f626e6f 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,44 +1,52 @@ -import { deepmergeImpl } from './deepmerge'; +import { deepmergeImpl } from "./deepmerge" +import type { FullOptions, ObjectMerge } from "./options" +import type { Property } from "./types" -function emptyTarget(value) { +function emptyTarget(value: unknown) { return Array.isArray(value) ? [] : {} } -export function cloneUnlessOtherwiseSpecified(value, options) { - return (options.clone !== false && options.isMergeable(value)) - ? deepmergeImpl(emptyTarget(value), value, options) +export function cloneUnlessOtherwiseSpecified(value: T, options: FullOptions): T { + return options.clone !== false && options.isMergeable(value) + ? (deepmergeImpl(emptyTarget(value), value, options) as T) : value } -function getEnumerableOwnPropertySymbols(target) { +function getEnumerableOwnPropertySymbols(target: object) { return Object.getOwnPropertySymbols - ? Object.getOwnPropertySymbols(target).filter((symbol) => target.propertyIsEnumerable(symbol) - ) - : []; + ? Object.getOwnPropertySymbols(target).filter((symbol) => target.propertyIsEnumerable(symbol)) + : [] } -export function getKeys(target) { - return Object.keys(target).concat(getEnumerableOwnPropertySymbols(target)); +export function getKeys(target: object) { + // Symbols cannot be used to index objects yet. + // So cast to an array of strings for simplicity. + // @see https://github.com/microsoft/TypeScript/issues/1863 + // TODO: Remove cast once symbols indexing of objects is supported. + return [...Object.keys(target), ...getEnumerableOwnPropertySymbols(target)] as string[] } -export function propertyIsOnObject(object, property) { +export function propertyIsOnObject(object: object, property: Property) { try { - return property in object; + return property in object } catch (_) { - return false; + return false } } -export function getMergeFunction(key, options) { +export function getMergeFunction( + key: Property, + options: FullOptions +): NonNullable> { if (!options.customMerge) { return deepmergeImpl } const customMerge = options.customMerge(key) - return typeof customMerge === 'function' ? customMerge : deepmergeImpl + return typeof customMerge === "function" ? customMerge : deepmergeImpl } // Protects from prototype poisoning and unexpected merging up the prototype chain. -export function propertyIsUnsafe(target, key) { +export function propertyIsUnsafe(target: object, key: Property) { return propertyIsOnObject(target, key) // Properties are safe to merge if they don't exist in the target yet, && !(Object.hasOwnProperty.call(target, key) // unsafe if they exist up the prototype chain, && Object.propertyIsEnumerable.call(target, key)) // and also unsafe if they're nonenumerable.