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 types

  • no 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 null

  • the Infinity value was converted to null

  • the TestClass type was converted back to Object type

These errors are caused due to the lack of support of these types by JSON format.