From 406a53ace2a6e11171ddc88133d6451d58f46e80 Mon Sep 17 00:00:00 2001 From: Rebecca Stevens Date: Fri, 13 Nov 2020 16:30:51 +1300 Subject: [PATCH] add typedefs from #211 --- src/deepmerge.ts | 80 ++++++++++++++++----- src/index.ts | 1 + src/options.ts | 74 ++++++++++++++++--- src/types.ts | 182 +++++++++++++++++++++++++++++++++++++++++++++++ src/utils.ts | 42 ++++++----- 5 files changed, 338 insertions(+), 41 deletions(-) create mode 100644 src/types.ts diff --git a/src/deepmerge.ts b/src/deepmerge.ts index c5ac725..43db614 100644 --- a/src/deepmerge.ts +++ b/src/deepmerge.ts @@ -1,19 +1,27 @@ -import { getFullOptions } from './options' +import type { ExplicitOptions, 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 Options +>(target: T1, source: T2, options: FullOptions): 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,73 @@ 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: FullOptions +): 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, + ExplicitOptions + > } 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 = {} +>(target: T1, source: T2, options?: O) { return deepmergeImpl(target, source, getFullOptions(options)) } -export function deepmergeAll(array, options) { - if (!Array.isArray(array)) { - throw new Error('first argument should be an array') +/** + * 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 = {} +>(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 +export function deepmergeAll(objects: ReadonlyArray, options?: Options): object { + if (!Array.isArray(objects)) { + throw new Error(`first argument should be an array`) } - return array.reduce((prev, next) => - deepmergeImpl(prev, next, getFullOptions(options)), {} - ) + return objects.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 7712c06..383b412 100644 --- a/src/options.ts +++ b/src/options.ts @@ -1,31 +1,89 @@ import isPlainObj from "is-plain-obj" +import type { Property } from "./types" import { cloneUnlessOtherwiseSpecified } from "./utils" -function defaultIsMergeable(value) { +/** + * Deep merge options. + */ +export type Options = Partial<{ + arrayMerge?: ArrayMerge + clone?: boolean + customMerge?: ObjectMerge + isMergeable?: IsMergeable +}> + +/** + * Deep merge options with explicit keys. + */ +export type ExplicitOptions = { + [K in keyof Options]-?: undefined extends O[K] ? never : O[K] +} + +/** + * Deep merge options with defaults applied. + */ +export type FullOptions = { + arrayMerge: O["arrayMerge"] extends undefined + ? typeof defaultArrayMerge + : NonNullable + clone: O["arrayMerge"] extends undefined ? true : NonNullable + customMerge?: O["customMerge"] + isMergeable: O["arrayMerge"] extends undefined + ? typeof defaultIsMergeable + : NonNullable + cloneUnlessOtherwiseSpecified: (value: T, options: FullOptions) => T +} + +/** + * 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: T1[], source: T2[], options: FullOptions) => any + +/** + * A function that merges any 2 non-arrays values. + */ +export type ObjectMerge = ( + key: K +) => ((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: readonly T1[], + source: readonly 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) { const overrides = options === undefined ? undefined : (Object.fromEntries( // Filter out keys explicitly set to undefined. Object.entries(options).filter(([key, value]) => value !== undefined) - )) + ) as O) return { arrayMerge: defaultArrayMerge, isMergeable: defaultIsMergeable, clone: true, - ...overrides, - cloneUnlessOtherwiseSpecified - }; + ...overrides, + cloneUnlessOtherwiseSpecified, + } as FullOptions } diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..99f7e5a --- /dev/null +++ b/src/types.ts @@ -0,0 +1,182 @@ +import type { 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, O> + : 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 + : IsUndefinedOrNever extends true + ? IsUndefinedOrNever extends true + ? 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. + * + * Cannot get return type from arrayMerge passing generics. + * TypeScript does not yet support higher order types. + * @see https://github.com/Microsoft/TypeScript/issues/1213 + */ +type DeepMergeArrays = IsUndefinedOrNever extends true + ? T1 extends readonly [...infer E1] + ? T2 extends readonly [...infer E2] + ? [...E1, ...E2] + : never + : never + : ReturnType> + +/** + * 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.