~/blog

structuredClone rejects functions and strips class prototypes

published

#javascript#web-api

TL;DR

structuredClone() is the built-in deep copy every runtime now ships (Node 17+, all modern browsers). It handles what JSON.parse(JSON.stringify(x)) corrupts — Date, Map, Set, BigInt, undefined, circular references — but it throws DataCloneError on any function and silently discards prototypes: a class instance comes back as a plain object with no methods and a failing instanceof. It is a data cloner, not an object cloner.

The problem

You replace the old JSON round-trip with the modern API:

const state = {
  user: { name: 'ada' },
  save() { /* ... */ },
};
const copy = structuredClone(state);

Node 26 rejects it at runtime:

DataCloneError: save() {} could not be cloned.

So you move the method off the object and clone a class instance instead:

class User {
  constructor(name) { this.name = name; }
  greet() { return 'hi ' + this.name; }
}
const copy = structuredClone(new User('ada'));

copy;                 // { name: 'ada' }  — a plain object
copy instanceof User; // false
copy.greet;           // undefined

No error this time. The data survives; the class does not. This one bites harder than the throw, because nothing warns you — the failure shows up later, as copy.greet is not a function three files away.

Why it happens

structuredClone implements the HTML spec’s structured clone algorithm, originally built to pass values between workers via postMessage. A function’s code can’t cross that boundary, so functions are non-serializable by definition — hence the hard error. Symbols throw the same way (Symbol(x) could not be cloned.), as do DOM nodes.

Prototypes are different: the algorithm walks an object’s own enumerable properties and records their values. It never walks the prototype chain, so anything living there — your methods, the class identity — simply isn’t part of what gets copied. The same value-only rule erases property metadata: a getter is invoked once and stored as a plain writable value.

const obj = { get now() { return 42; } };
Object.getOwnPropertyDescriptor(structuredClone(obj), 'now');
// { value: 42, writable: true, enumerable: true, configurable: true }

What each copy strategy actually preserves

Input{ ...x } (spread)JSON.parse(JSON.stringify(x))structuredClone(x)
Nested objectsshared (shallow)copiedcopied
Dateshared referencebecomes ISO stringstays Date
Map / Setshared referencebecomes {}copied
undefined propertykeptdroppedkept
NaN / Infinitykeptbecomes nullkept
BigIntkeptthrows TypeErrorcopied
Circular referencen/a (shallow)throws TypeErrorcopied
Function propertykept (shared)silently droppedthrows DataCloneError
Class prototype / methodskeptlostlost
Getters / settersgetter invoked, value keptgetter invoked, value keptgetter invoked, value kept

The JSON column is the one people underestimate: it doesn’t just miss features, it silently rewrites your data. structuredClone at least fails loudly on the truly un-cloneable — except for prototypes, its one silent loss.

What to do

Cloning plain data — state snapshots, config, anything that could be JSON but with real Date/Map/Set values: use structuredClone, it is the right tool.

Cloning class instances — don’t clone the instance; reconstruct it. Give the class a way to rebuild from plain data:

class User {
  constructor(name) { this.name = name; }
  greet() { return 'hi ' + this.name; }
  static from(data) { return new User(data.name); }
}
const copy = User.from(structuredClone({ ...original }));
copy.greet(); // 'hi ada'

This is the same shape as toJSON()/revive: data crosses the copy boundary, behavior is reattached by the class. It also survives postMessage and IndexedDB, which use the identical algorithm — if structuredClone rejects your object, those will too.

Transferring instead of copying — for large ArrayBuffers, pass the transfer option to move the buffer instead of duplicating it; the source is detached afterward:

const clone = structuredClone(payload, { transfer: [payload.buffer] });

Caveats

References