structuredClone rejects functions and strips class prototypes
published
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 objects | shared (shallow) | copied | copied |
Date | shared reference | becomes ISO string | stays Date |
Map / Set | shared reference | becomes {} | copied |
undefined property | kept | dropped | kept |
NaN / Infinity | kept | becomes null | kept |
BigInt | kept | throws TypeError | copied |
| Circular reference | n/a (shallow) | throws TypeError | copied |
| Function property | kept (shared) | silently dropped | throws DataCloneError |
| Class prototype / methods | kept | lost | lost |
| Getters / setters | getter invoked, value kept | getter invoked, value kept | getter 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
RegExpobjects clone, butlastIndexresets to0— a mid-scan sticky regex loses its position.Errorobjects are on the serializable list; custom error subclasses lose their subclass identity like any other class.- Property descriptors don’t survive: read-only properties come back writable, accessors come back as plain values.
- The exact
DataCloneErrormessage text varies by engine — match onerror.name, never on the message.