Deep clone object - JSON.stringify/parse, fast-copy, structuredClone
Everyone, at a certain point in a developer's career, is going to search the internet for "how to make a deep copy of an object". I'll focus on "data objects" which can be defined by:
no methods, setters or getters, only properties
properties can be nested (arrays, objects)
all properties are "cloneable", i.e. they can't be
Error
,Promise
,Function
,undefined
and some other typesno circular references
I'll show three ways to do so. There are of course hundreds of other ways but these three should be more than enough for data objects. In the end, I'll write some tests in Jest to verify the objects' equality.
Setup
Requirements:
node 17
npm 8
npx
Initialize npm with default settings
npm init -y
Install dependencies
npm i jest@29 @types/jest@29 ts-jest@29 typescript@5 fast-copy
Initialize the default configuration of ts-jest
npx ts-jest config:init
Simple examples
JSON.stringify, JSON.parse
Documentation:
These methods do exactly what the names suggest. First, we call JSON.stringify()
method that converts a value to plain JSON format (the returned value is a string). Then we call JSON.parse()
method to parse the JSON string into a plain javascript object. In this process, all the references and class types are lost. In terms of speed that's the slowest solution and should only be used when performance is not a big deal.
Usage:
const variable = { myProp: [1, 'two'] };
console.log('original:', variable);
const clone = JSON.parse(JSON.stringify(variable));
console.log('cloned: ', clone);
fast-copy
Documentation: github.com/planttheidea/fast-copy
This library has a few methods, but I'll focus on copy()
. This method takes a single argument and returns a cloned value. One of the advantages this library has is that it tries to retain the source class type which the other two libraries don't.
Usage
import copy from 'fast-copy';
const variable = { myProp: [1, 'two'] };
console.log('original:', variable);
const clone = copy(variable)
console.log('cloned: ', clone);
structuredClone
Documentation: developer.mozilla.org/en-US/docs/Web/API/st..
This built-in function is available only in the newest browsers (caniuse.com/?search=structuredClone) and by default in Node 17+. Its usage is very simple: just call the function and pass a value to clone it.
const variable = { myProp: [1, 'two'] };
console.log('original:', variable);
const clone = structuredClone(variable)
console.log('cloned: ', clone);
The result
All the previous examples were quite simple. Each time the result should be the same:
original: { myProp: [ 1, 'two' ] }
cloned: { myProp: [ 1, 'two' ] }
Complex example
Create an example to check multiple values
// index.js
import copy from 'fast-copy';
class TestClass {
number: number;
string: string;
array: unknown[];
object: object;
class: TestClass;
}
const simpleObject = { propNumber: 256, propString: 'a string prop' };
const otherTestClass = new TestClass();
otherTestClass.array = [2, '3', 'five', [], simpleObject, null, undefined];
otherTestClass.string = 'a new string';
otherTestClass.number = -88;
otherTestClass.object = { prop2: 'val2' };
const myTestClass = new TestClass();
myTestClass.array = [1, '2', 'three', [], simpleObject, null, undefined];
myTestClass.string = 'a string';
myTestClass.number = 99;
myTestClass.object = { prop: 'val' };
myTestClass.class = otherTestClass;
const values = [
{ key: 'null', value: null },
{ key: 'false', value: false },
{ key: 'true', value: true },
{ key: '0', value: 0 },
{ key: '-10', value: -10 },
{ key: '99', value: 99 },
{ key: '0.1', value: 0.1 },
{ key: '-1.0009', value: -1.0009 },
{ key: 'Infinity', value: Infinity },
{ key: '\'\'', value: '' },
{ key: 'a string', value: 'a string' },
{ key: '[]', value: [] },
{ key: '[1,\'2\',\'three\', [], {prop: \'val\'}]', value: [1, '2', 'three', [], { prop: 'val' }] },
{ key: '{}', value: {} },
{ key: 'myTestClass', value: myTestClass }
]
class Result {
constructor(input: any, jsonStringifyParse: any, fastCopy: any, structuredClone: any) {
this.input = input;
this.jsonStringifyParse = jsonStringifyParse;
this.fastCopy = fastCopy;
this.structuredClone = structuredClone;
}
input: any;
jsonStringifyParse: any;
fastCopy: any;
structuredClone: any;
}
const group = {};
values.forEach(value => {
group[value.key] = new Result(
value.value,
JSON.parse(JSON.stringify(value.value)),
copy(value.value),
structuredClone(value.value)
);
})
console.table(group, ['jsonStringifyParse', 'fastCopy', 'structuredClone'])
console.dir(group, {depth: null})
Run it
npx tsc index.ts && node index.js
The output of console.table()
┌────────────────────────────────────┬───────────────────────────────────────┬───────────────────────────────────────┬───────────────────────────────────────┐
│ (index) │ jsonStringifyParse │ fastCopy │ structuredClone │
├────────────────────────────────────┼───────────────────────────────────────┼───────────────────────────────────────┼───────────────────────────────────────┤
│ 0 │ 0 │ 0 │ 0 │
│ 99 │ 99 │ 99 │ 99 │
│ null │ null │ null │ null │
│ false │ false │ false │ false │
│ true │ true │ true │ true │
│ -10 │ -10 │ -10 │ -10 │
│ 0.1 │ 0.1 │ 0.1 │ 0.1 │
│ -1.0009 │ -1.0009 │ -1.0009 │ -1.0009 │
│ Infinity │ null │ Infinity │ Infinity │
│ '' │ '' │ '' │ '' │
│ a string │ 'a string' │ 'a string' │ 'a string' │
│ [] │ [] │ [] │ [] │
│ [1,'2','three', [], {prop: 'val'}] │ [ 1, '2', 'three', ... 2 more items ] │ [ 1, '2', 'three', ... 2 more items ] │ [ 1, '2', 'three', ... 2 more items ] │
│ {} │ {} │ {} │ {} │
│ myTestClass │ [Object] │ [TestClass] │ [Object] │
└────────────────────────────────────┴───────────────────────────────────────┴───────────────────────────────────────┴───────────────────────────────────────┘
A quick look can tell if the values match and where the class types were retained. Since console.table()
does not expand objects, the console.dir()
can be used. To expand variables without the nesting limit pass {depth: null}
as the second argument.
The output of console.dir()
{
'0': Result {
input: 0,
jsonStringifyParse: 0,
fastCopy: 0,
structuredClone: 0
},
'99': Result {
input: 99,
jsonStringifyParse: 99,
fastCopy: 99,
structuredClone: 99
},
null: Result {
input: null,
jsonStringifyParse: null,
fastCopy: null,
structuredClone: null
},
false: Result {
input: false,
jsonStringifyParse: false,
fastCopy: false,
structuredClone: false
},
true: Result {
input: true,
jsonStringifyParse: true,
fastCopy: true,
structuredClone: true
},
'-10': Result {
input: -10,
jsonStringifyParse: -10,
fastCopy: -10,
structuredClone: -10
},
'0.1': Result {
input: 0.1,
jsonStringifyParse: 0.1,
fastCopy: 0.1,
structuredClone: 0.1
},
'-1.0009': Result {
input: -1.0009,
jsonStringifyParse: -1.0009,
fastCopy: -1.0009,
structuredClone: -1.0009
},
Infinity: Result {
input: Infinity,
jsonStringifyParse: null,
fastCopy: Infinity,
structuredClone: Infinity
},
"''": Result {
input: '',
jsonStringifyParse: '',
fastCopy: '',
structuredClone: ''
},
'a string': Result {
input: 'a string',
jsonStringifyParse: 'a string',
fastCopy: 'a string',
structuredClone: 'a string'
},
'[]': Result {
input: [],
jsonStringifyParse: [],
fastCopy: [],
structuredClone: []
},
"[1,'2','three', [], {prop: 'val'}]": Result {
input: [ 1, '2', 'three', [], { prop: 'val' } ],
jsonStringifyParse: [ 1, '2', 'three', [], { prop: 'val' } ],
fastCopy: [ 1, '2', 'three', [], { prop: 'val' } ],
structuredClone: [ 1, '2', 'three', [], { prop: 'val' } ]
},
'{}': Result {
input: {},
jsonStringifyParse: {},
fastCopy: {},
structuredClone: {}
},
myTestClass: Result {
input: TestClass {
array: [
1,
'2',
'three',
[],
{ propNumber: 256, propString: 'a string prop' },
null,
undefined
],
string: 'a string',
number: 99,
object: { prop: 'val' },
class: TestClass {
array: [
2,
'3',
'five',
[],
{ propNumber: 256, propString: 'a string prop' },
null,
undefined
],
string: 'a new string',
number: -88,
object: { prop2: 'val2' }
}
},
jsonStringifyParse: {
array: [
1,
'2',
'three',
[],
{ propNumber: 256, propString: 'a string prop' },
null,
null
],
string: 'a string',
number: 99,
object: { prop: 'val' },
class: {
array: [
2,
'3',
'five',
[],
{ propNumber: 256, propString: 'a string prop' },
null,
null
],
string: 'a new string',
number: -88,
object: { prop2: 'val2' }
}
},
fastCopy: TestClass {
array: [
1,
'2',
'three',
[],
{ propNumber: 256, propString: 'a string prop' },
null,
undefined
],
string: 'a string',
number: 99,
object: { prop: 'val' },
class: TestClass {
array: [
2,
'3',
'five',
[],
{ propNumber: 256, propString: 'a string prop' },
null,
undefined
],
string: 'a new string',
number: -88,
object: { prop2: 'val2' }
}
},
structuredClone: {
array: [
1,
'2',
'three',
[],
{ propNumber: 256, propString: 'a string prop' },
null,
undefined
],
string: 'a string',
number: 99,
object: { prop: 'val' },
class: {
array: [
2,
'3',
'five',
[],
{ propNumber: 256, propString: 'a string prop' },
null,
undefined
],
string: 'a new string',
number: -88,
object: { prop2: 'val2' }
}
}
}
}
Now, you can visually compare all the outputs and tell if that's the desired output.
Testing
Nevertheless, a solid test case is surely needed. I'll use the same input data.
// index.spec.ts
import copy from 'fast-copy';
class TestClass {
number: number;
string: string;
array: unknown[];
object: object;
class: TestClass;
}
const simpleObject = { propNumber: 256, propString: 'a string prop' };
const otherTestClass = new TestClass();
otherTestClass.array = [2, '3', 'five', [], simpleObject, null, undefined];
otherTestClass.string = 'a new string';
otherTestClass.number = -88;
otherTestClass.object = { prop2: 'val2' };
const myTestClass = new TestClass();
myTestClass.array = [1, '2', 'three', [], simpleObject, null, undefined];
myTestClass.string = 'a string';
myTestClass.number = 99;
myTestClass.object = { prop: 'val' };
myTestClass.class = otherTestClass;
const values = [
{ key: 'null', value: null },
{ key: 'false', value: false },
{ key: 'true', value: true },
{ key: '0', value: 0 },
{ key: '-10', value: -10 },
{ key: '99', value: 99 },
{ key: '0.1', value: 0.1 },
{ key: '-1.0009', value: -1.0009 },
{ key: 'Infinity', value: Infinity },
{ key: '\'\'', value: '' },
{ key: 'a string', value: 'a string' },
{ key: '[]', value: [] },
{ key: '[1,\'2\',\'three\', [], {prop: \'val\'}]', value: [1, '2', 'three', [], { prop: 'val' }] },
{ key: '{}', value: {} },
{ key: 'myTestClass', value: myTestClass }
]
const jsonStringifyParseDatasets = values.map(value => {
return {
key: value.key,
input: value.value,
result: JSON.parse(JSON.stringify(value.value))
};
});
const fastCopyDatasets = values.map(value => {
return {
key: value.key,
input: value.value,
result: copy(value.value)
};
});
const structuredCloneDatasets = values.map(value => {
return {
key: value.key,
input: value.value,
result: structuredClone(value.value)
};
});
describe('jsonStringifyParse', () => {
it.each(jsonStringifyParseDatasets)('value: $key', ({key, input, result}) => {
expect(result).toEqual(input)
});
});
describe('fastCopy', () => {
it.each(fastCopyDatasets)('value: $key', ({key, input, result}) => {
expect(result).toEqual(input)
});
});
describe('structuredClone', () => {
it.each(structuredCloneDatasets)('value: $key', ({key, input, result}) => {
expect(result).toEqual(input)
});
});
Run it
npx jest index.spec.ts
The result is
FAIL ./index.spec.ts
jsonStringifyParse
✓ value: null (2 ms)
✓ value: false
✓ value: true (1 ms)
✓ value: 0
✓ value: -10
✓ value: 99
✓ value: 0.1
✓ value: -1.0009 (1 ms)
✕ value: Infinity (1 ms)
✓ value: '' (1 ms)
✓ value: a string
✓ value: [] (1 ms)
✓ value: [1,'2','three', [], {prop: 'val'}]
✓ value: {} (1 ms)
✕ value: myTestClass (5 ms)
fastCopy
✓ value: null
✓ value: false (1 ms)
✓ value: true
✓ value: 0
✓ value: -10
✓ value: 99
✓ value: 0.1
✓ value: -1.0009
✓ value: Infinity
✓ value: '' (1 ms)
✓ value: a string
✓ value: []
✓ value: [1,'2','three', [], {prop: 'val'}]
✓ value: {} (1 ms)
✓ value: myTestClass
structuredClone
✓ value: null
✓ value: false (1 ms)
✓ value: true
✓ value: 0
✓ value: -10
✓ value: 99
✓ value: 0.1
✓ value: -1.0009
✓ value: Infinity
✓ value: ''
✓ value: a string (1 ms)
✓ value: []
✓ value: [1,'2','three', [], {prop: 'val'}]
✓ value: {}
✓ value: myTestClass (1 ms)
● jsonStringifyParse › value: Infinity
expect(received).toEqual(expected) // deep equality
Expected: Infinity
Received: null
69 | describe('jsonStringifyParse', () => {
70 | it.each(jsonStringifyParseDatasets)('value: $key', ({key, input, result}) => {
> 71 | expect(result).toEqual(input)
| ^
72 | });
73 | });
74 |
at index.spec.ts:71:20
● jsonStringifyParse › value: myTestClass
expect(received).toEqual(expected) // deep equality
- Expected - 4
+ Received + 4
@@ -1,30 +1,30 @@
- TestClass {
+ Object {
"array": Array [
1,
"2",
"three",
Array [],
Object {
"propNumber": 256,
"propString": "a string prop",
},
+ null,
null,
- undefined,
],
- "class": TestClass {
+ "class": Object {
"array": Array [
2,
"3",
"five",
Array [],
Object {
"propNumber": 256,
"propString": "a string prop",
},
null,
- undefined,
+ null,
],
"number": -88,
"object": Object {
"prop2": "val2",
},
69 | describe('jsonStringifyParse', () => {
70 | it.each(jsonStringifyParseDatasets)('value: $key', ({key, input, result}) => {
> 71 | expect(result).toEqual(input)
| ^
72 | });
73 | });
74 |
at index.spec.ts:71:20
Test Suites: 1 failed, 1 total
Tests: 2 failed, 43 passed, 45 total
As you can see the 2 tests failed while using json stringify / parse
method:
the
undefined
value was converted to nullthe
Infinity
value was converted to nullthe
TestClass
type was converted back toObject
type
These errors are caused due to the lack of support of these types by JSON format.