Skip to content

Instantly share code, notes, and snippets.

@trvswgnr
Last active February 5, 2024 21:39
Show Gist options
  • Save trvswgnr/04fda2aea228ab3e42a2bcdf5881765a to your computer and use it in GitHub Desktop.
Save trvswgnr/04fda2aea228ab3e42a2bcdf5881765a to your computer and use it in GitHub Desktop.
typescript deep merge and deep clone
import { describe, it, expect } from "bun:test";
import { deepClone, deepMerge, isObject } from "./lib.ts";
describe("isObject", () => {
it("returns false for null", () => expect(isObject(null)).toBe(false));
it("returns false for undefined", () => expect(isObject(undefined)).toBe(false));
it("returns false for numbers", () => expect(isObject(0)).toBe(false));
it("returns false for strings", () => expect(isObject("")).toBe(false));
it("returns false for booleans", () => expect(isObject(false)).toBe(false));
it("returns true for objects", () => expect(isObject({})).toBe(true));
it("returns false for functions", () => expect(isObject(() => {})).toBe(false));
it("returns true for arrays", () => expect(isObject([])).toBe(true));
it("returns true for class instances", () => expect(isObject(new (class {})())).toBe(true));
it("returns true for objects with a null prototype", () =>
expect(isObject(Object.create(null))).toBe(true));
});
describe("deepClone", () => {
it("returns the same value for simple types", () => {
const a = 0;
expect(deepClone(a)).toBe(a);
const b = "";
expect(deepClone(b)).toBe(b);
const c = false;
expect(deepClone(c)).toBe(c);
const d = null;
expect(deepClone(d)).toBe(d);
const e = undefined;
expect(deepClone(e)).toBe(e);
});
it("returns a new object with the same values for objects", () => {
const a = { a: 1, b: "2", c: true };
const cloneA = deepClone(a);
expect(cloneA).toEqual(a);
expect(cloneA).not.toBe(a);
});
it("returns a new array with the same values for arrays", () => {
const a = [1, "2", true];
const cloneA = deepClone(a);
expect(cloneA).toEqual(a);
expect(cloneA).not.toBe(a);
});
it("returns a new object with the same values for nested objects", () => {
const a = { a: { b: { c: { d: 1 } } } };
const cloneA = deepClone(a);
expect(cloneA).toEqual(a);
expect(cloneA).not.toBe(a);
expect(cloneA.a).toEqual(a.a);
expect(cloneA.a).not.toBe(a.a);
expect(cloneA.a.b).toEqual(a.a.b);
expect(cloneA.a.b).not.toBe(a.a.b);
expect(cloneA.a.b.c).toEqual(a.a.b.c);
expect(cloneA.a.b.c).not.toBe(a.a.b.c);
});
it("returns a new array with the same values for nested arrays", () => {
const a = [[[[1]]]];
const cloneA = deepClone(a);
expect(cloneA).toEqual(a);
expect(cloneA).not.toBe(a);
expect(cloneA[0]).toEqual(a[0]);
expect(cloneA[0]).not.toBe(a[0]);
expect(cloneA[0][0]).toEqual(a[0][0]);
expect(cloneA[0][0]).not.toBe(a[0][0]);
expect(cloneA[0][0][0]).toEqual(a[0][0][0]);
expect(cloneA[0][0][0]).not.toBe(a[0][0][0]);
});
it("returns a new object with the same values for objects with a null prototype", () => {
const a = Object.create(null);
a.a = 1;
const cloneA = deepClone(a);
expect(cloneA).toEqual(a);
expect(cloneA).not.toBe(a);
});
it("works with class instances", () => {
class A {
a = 1;
}
const a = new A();
const cloneA = deepClone(a);
expect(cloneA).toEqual(a);
expect(cloneA).not.toBe(a);
});
it("works with complex objects", () => {
const a = { a: { b: [1, { c: 2 }] } };
const cloneA = deepClone(a);
expect(cloneA).toEqual(a);
expect(cloneA).not.toBe(a);
expect(cloneA.a).toEqual(a.a);
expect(cloneA.a).not.toBe(a.a);
expect(cloneA.a.b).toEqual(a.a.b);
expect(cloneA.a.b).not.toBe(a.a.b);
});
it("works with complex arrays", () => {
const a = [
[1, [2, { a: 3 }]],
[4, [5, { b: 6 }]],
] as const;
const cloneA = deepClone(a);
expect(cloneA).toEqual(a);
expect(cloneA).not.toBe(a);
expect(cloneA[0]).toEqual(a[0]);
expect(cloneA[0]).not.toBe(a[0]);
expect(cloneA[0][1]).toEqual(a[0][1]);
expect(cloneA[0][1]).not.toBe(a[0][1]);
expect(cloneA[0][1][1]).toEqual(a[0][1][1]);
expect(cloneA[0][1][1]).not.toBe(a[0][1][1]);
expect(cloneA[1]).toEqual(a[1]);
expect(cloneA[1]).not.toBe(a[1]);
expect(cloneA[1][1]).toEqual(a[1][1]);
expect(cloneA[1][1]).not.toBe(a[1][1]);
});
it("works with circular references", () => {
const a: any = { a: 1 };
a.b = a;
const cloneA = deepClone(a);
expect(cloneA).toEqual(a);
expect(cloneA).not.toBe(a);
expect(cloneA.b).toBe(cloneA);
});
it("works with circular references in arrays", () => {
const a: any = [1];
a.push(a);
const cloneA = deepClone(a);
expect(cloneA).toEqual(a);
expect(cloneA).not.toBe(a);
expect(cloneA[1]).toBe(cloneA);
});
it("works with circular references in nested objects", () => {
const a: any = { a: { b: { c: 1 } } };
a.a.b.d = a;
const cloneA = deepClone(a);
expect(cloneA).toEqual(a);
expect(cloneA).not.toBe(a);
expect(cloneA.a.b.d).toBe(cloneA);
});
it("works with circular references in nested arrays", () => {
const a: any = [[1]];
a[0].push(a);
const cloneA = deepClone(a);
expect(cloneA).toEqual(a);
expect(cloneA).not.toBe(a);
expect(cloneA[0][1]).toBe(cloneA);
});
it("works with symbols", () => {
const a = Symbol("a");
const cloneA = deepClone(a);
expect(cloneA).toBe(a);
});
it("works with BigInts", () => {
const a = BigInt(1);
const cloneA = deepClone(a);
expect(cloneA).toBe(a);
});
it("works with object containing symbols", () => {
const symA = Symbol("a");
const symB = Symbol("b");
const a = { [symA]: { b: { [symB]: 1 } } };
const cloneA = deepClone(a);
expect(cloneA).toEqual(a);
expect(cloneA).not.toBe(a);
expect(cloneA[symA]).toEqual(a[symA]);
expect(cloneA[symA]).not.toBe(a[symA]);
expect(cloneA[symA].b).toEqual(a[symA].b);
expect(cloneA[symA].b).not.toBe(a[symA].b);
});
});
describe("deepMerge", () => {
it("returns the first argument if the second argument is not an object", () => {
const a = { a: 1, b: "2", c: true };
// @ts-expect-error
const m1 = deepMerge(a, 1);
expect(m1).toEqual(a);
expect(m1).not.toBe(a);
// @ts-expect-error
const m2 = deepMerge(a, "2");
expect(m2).toEqual(a);
expect(m2).not.toBe(a);
// @ts-expect-error
const m3 = deepMerge(a, true);
expect(m3).toEqual(a);
expect(m3).not.toBe(a);
// @ts-expect-error
const m4 = deepMerge(a, null);
expect(m4).toEqual(a);
expect(m4).not.toBe(a);
// @ts-expect-error
const m5 = deepMerge(a, undefined);
expect(m5).toEqual(a);
expect(m5).not.toBe(a);
});
it("returns the first argument if the second argument is an empty object", () => {
const a = { a: 1, b: "2", c: true };
const merged = deepMerge(a, {});
expect(merged).toEqual(a);
expect(merged).not.toBe(a);
});
it("returns a new object with the same values for objects", () => {
const a = { a: 1, b: "2", c: true };
const b = { a: 2, b: "3", c: false };
const merged = deepMerge(a, b);
expect(merged).toEqual({ a: 2, b: "3", c: false });
expect(merged).not.toBe(a);
expect(merged).not.toBe(b);
});
it("returns a new array with the same values for arrays", () => {
const a = [1, "2", true];
const b = [2, "3", false];
const merged = deepMerge(a, b);
expect(merged).toEqual([2, "3", false]);
expect(merged).not.toBe(a);
expect(merged).not.toBe(b);
});
it("returns a new object with the same values for nested objects", () => {
const a = { a: { b: { c: { d: 1 } } } };
const b = { a: { b: { c: { d: 2 } } } };
const merged = deepMerge(a, b);
expect(merged).toEqual({ a: { b: { c: { d: 2 } } } });
expect(merged).not.toBe(a);
expect(merged).not.toBe(b);
expect(merged.a).not.toBe(a.a);
expect(merged.a).not.toBe(b.a);
expect(merged.a.b).not.toBe(a.a.b);
expect(merged.a.b.c).not.toBe(a.a.b.c);
});
it("works with partial objects", () => {
const a = { a: 1, b: "2", c: true };
const b = { a: 2 };
const merged = deepMerge(a, b);
expect(merged).toEqual({ a: 2, b: "2", c: true });
expect(merged).not.toBe(a);
expect(merged).not.toBe(b);
const c = { a: 1, b: "2", c: { d: 3, e: 4 } };
const d = { c: { d: 4 } };
const merged2 = deepMerge(c, d);
expect(merged2).toEqual({ a: 1, b: "2", c: { d: 4, e: 4 } });
expect(merged2).not.toBe(c);
});
it("works with complex objects", () => {
const a = { a: { b: [1, { c: 2 }] } };
const b = { a: { b: [2, { c: 3 }] } };
const merged = deepMerge(a, b);
expect(merged).toEqual({ a: { b: [2, { c: 3 }] } });
expect(merged).not.toBe(a);
expect(merged).not.toBe(b);
expect(merged.a).not.toBe(a.a);
expect(merged.a.b).not.toBe(a.a.b);
});
it("works with objects containing symbols", () => {
const symA = Symbol("a");
const symB = Symbol("b");
const a = { [symA]: { b: { [symB]: 1 } } };
const b = { [symA]: { b: { [symB]: 2 } } };
const merged = deepMerge(a, b);
expect(merged).toEqual({ [symA]: { b: { [symB]: 2 } } });
expect(merged).not.toBe(a);
expect(merged).not.toBe(b);
expect(merged[symA]).not.toBe(a[symA]);
expect(merged[symA].b).not.toBe(a[symA].b);
});
it("works with objects with circular references", () => {
const a: any = { a: 1 };
a.b = a;
const b: any = { a: 2 };
b.b = b;
const merged = deepMerge(a, b);
expect(merged).toEqual({ a: 2, b: merged });
expect(merged).not.toBe(a);
expect(merged).not.toBe(b);
expect(merged.b).toBe(merged);
});
});
export function isObject<T>(obj: T): obj is T & object {
return obj !== null && typeof obj === "object";
}
export function deepClone<T>(og: T, map: WeakMap<WeakKey, unknown> = new WeakMap()): T {
// check if the current object is a primitive or not clonable
if (!isObject(og)) {
return og;
}
// check if this object has already been cloned
if (map.has(og)) {
return map.get(og) as T;
}
const clonedObject: DeepPartial<T> = Array.isArray(og) ? [] : {};
// store the cloned object in the map
map.set(og, clonedObject);
for (const key in og) {
const val = og[key];
if (isObject(val)) {
clonedObject[key] = deepClone(val, map);
continue;
}
clonedObject[key] = val;
}
for (const sym of Object.getOwnPropertySymbols(og)) {
const key = sym as keyof T;
const val = og[key];
if (isObject(val)) {
clonedObject[key] = deepClone(val, map);
continue;
}
clonedObject[key] = val;
}
return clonedObject as T;
}
export function deepMerge<A>(
_a: A,
b: DeepPartial<A>,
map: WeakMap<WeakKey, unknown> = new WeakMap(),
): A {
const a = deepClone(_a);
if (!isObject(_a) || !isObject(b)) {
return a;
}
if (map.has(b)) {
return map.get(b) as A;
}
map.set(b, a);
for (const key in b) {
const bVal = b[key];
if (isObject(bVal) && !Array.isArray(bVal)) {
a[key] = deepMerge(a[key], bVal, map);
continue;
}
a[key] = bVal as A[Extract<keyof A, string>];
}
for (const sym of Object.getOwnPropertySymbols(b)) {
const key = sym as keyof A;
const bVal = b[key];
if (bVal === undefined) {
continue;
}
if (isObject(bVal) && !Array.isArray(bVal)) {
a[key] = deepMerge(a[key], bVal, map);
continue;
}
a[key] = bVal as A[Extract<keyof A, string>];
}
return a;
}
type DeepPartial<T> = {
[P in keyof T]?: DeepPartial<T[P]>;
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment