Last active
February 5, 2024 21:39
-
-
Save trvswgnr/04fda2aea228ab3e42a2bcdf5881765a to your computer and use it in GitHub Desktop.
typescript deep merge and deep clone
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | |
}); | |
}); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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