Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(collections): add deepMerge #1075

Merged
merged 17 commits into from
Aug 5, 2021
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions collections/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,25 @@ console.assert(
);
```

## deepMerge

Merges the two given Records, recursively merging any nested Records with the
second collection overriding the first in case of conflict

For arrays, maps and sets, a merging strategy can be specified to either
`replace` values, or `merge` them instead. Use `includeNonEnumerable` option to
include non enumerable properties too.

```ts
import { deepMerge } from "./deep_merge.ts";
import { assertEquals } from "../testing/asserts.ts";

const a = { foo: true };
const b = { foo: { bar: true } };

assertEquals(deepMerge(a, b), { foo: { bar: true } });
```

## distinctBy

Returns all elements in the given array that produce a distinct value using the
Expand Down
184 changes: 184 additions & 0 deletions collections/deep_merge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
// Copyright 2018-2021 the Deno authors. All rights reserved. MIT license.

/**
* Merges the two given Records, recursively merging any nested Records with
* the second collection overriding the first in case of conflict
*
* For arrays, maps and sets, a merging strategy can be specified to either
* "replace" values, or "merge" them instead.
* Use "includeNonEnumerable" option to include non enumerable properties too.
lowlighter marked this conversation as resolved.
Show resolved Hide resolved
*
* Example:
*
* ```ts
* import { deepMerge } from "./deep_merge.ts";
* import { assertEquals } from "../testing/asserts.ts";
*
* const a = {foo: true}
* const b = {foo: {bar: true}}
*
* assertEquals(deepMerge(a, b), {foo: {bar: true}});
* ```
*/
lowlighter marked this conversation as resolved.
Show resolved Hide resolved
export function deepMerge<
T extends Record<PropertyKey, unknown>,
>(
record: Partial<T>,
other: Partial<T>,
options?: DeepMergeOptions,
): T;

export function deepMerge<
T extends Record<PropertyKey, unknown>,
U extends Record<PropertyKey, unknown>,
>(
record: T,
other: U,
options?: DeepMergeOptions,
): T;
lowlighter marked this conversation as resolved.
Show resolved Hide resolved

export function deepMerge<
T extends Record<PropertyKey, unknown>,
U extends Record<PropertyKey, unknown>,
>(
record: T,
other: U,
{
arrays = "merge",
maps = "merge",
sets = "merge",
includeNonEnumerable = false,
}: DeepMergeOptions = {},
): T & U {
const result = clone(record, { includeNonEnumerable });

// Extract property and symbols
const keys = [
...Object.getOwnPropertyNames(other),
...Object.getOwnPropertySymbols(other),
].filter((key) => includeNonEnumerable || other.propertyIsEnumerable(key));

// Iterate through each key of other object and use correct merging strategy
for (const key of keys as PropertyKeys) {
const a = result[key], b = other[key];

// Handle arrays
if ((Array.isArray(a)) && (Array.isArray(b))) {
if (arrays === "merge") {
(result[key] as typeof a).push(...b);
} else {
result[key] = b;
}
continue;
}

// Handle maps
if ((a instanceof Map) && (b instanceof Map)) {
if (maps === "merge") {
for (const [k, v] of b.entries()) {
a.set(k, v);
}
} else {
result[key] = b;
}
continue;
}

// Handle sets
if ((a instanceof Set) && (b instanceof Set)) {
if (sets === "merge") {
for (const v of b.values()) {
a.add(v);
}
} else {
result[key] = b;
}
continue;
}

// Recursively merge mergeable objects
if (isMergeable<T | U>(a) && isMergeable<T | U>(b)) {
result[key] = deepMerge(a ?? {}, b);
continue;
}

// Override value
result[key] = b;
}

return result as T & U;
}

/**
* Clone a record
* Arrays, maps, sets and objects are cloned so references doesn't point
* anymore to those of cloned record
*/
function clone<T extends Record<PropertyKey, unknown>>(
record: T,
{ includeNonEnumerable = false } = {},
) {
// Extract property and symbols
const keys = [
...Object.getOwnPropertyNames(record),
...Object.getOwnPropertySymbols(record),
].filter((key) => includeNonEnumerable || record.propertyIsEnumerable(key));

// Build cloned record
const cloned = {} as T;
for (const key of keys as PropertyKeys) {
const v = record[key];
if (Array.isArray(v)) {
cloned[key] = [...v];
continue;
}
if (v instanceof Map) {
cloned[key] = new Map(v);
continue;
}
if (v instanceof Set) {
cloned[key] = new Set(v);
continue;
}
if (isMergeable<Record<PropertyKey, unknown>>(v)) {
cloned[key] = clone(v);
continue;
}
cloned[key] = v;
}

return cloned;
}

/**
* Test whether a value is mergeable or not
* Builtins object like, null and user classes are not considered mergeable
*/
function isMergeable<T>(value: unknown): value is T {
// Ignore null
if (value === null) {
return false;
}
// Ignore builtins and classes
if ((typeof value === "object") && ("constructor" in value!)) {
return Object.getPrototypeOf(value) === Object.prototype;
}
return typeof value === "object";
}

/** Deep merge options */
export type DeepMergeOptions = {
/** Merging strategy for arrays */
arrays?: "replace" | "merge";
/** Merging strategy for Maps */
maps?: "replace" | "merge";
/** Merging strategy for Sets */
sets?: "replace" | "merge";
/** Whether to include non enumerable properties */
includeNonEnumerable?: boolean;
};

// TypeScript does not support 'symbol' as index type currently though
// it's perfectly valid
// deno-lint-ignore no-explicit-any
type PropertyKeys = any[];
Loading