skip to content

You need a deep copy of an application state object that holds class instances, an options object with a callback in it, and a back-reference cycle. What happens if you call structuredClone on it, and how do you design around the parts it cannot handle?

level: seniorimportance: should knowfreq 38%

answer

  1. all-or-nothing, not partial
  2. one of the three parts is free
  3. the failure that stays silent is the dangerous one
  4. state and behaviour want separating
  5. rehydration should be an explicit step

basics

~20 s

The call throws a DataCloneError because of the callback, and it fails entirely rather than partially. The cycle would have been fine and the class instances would have survived as plain data without methods, so the fix is to separate cloneable state from behaviour and rehydrate explicitly.

solid answer

~50 s

Run it as written and you get a `DataCloneError` from the callback — the clone is all-or-nothing, so nothing comes back. Remove the callback and it succeeds, but the class instances arrive as plain objects: same own data, no prototype, no methods, `instanceof` false. The cycle is the one part that needs no work at all. The design answer is to stop treating one object as both state and behaviour. Keep the cloneable state as plain data, hold callbacks and services outside it in a registry or closure, and if you need rich objects back, make rehydration an explicit step — a `fromData` factory per type, driven by a discriminator field you cloned along with the data. If you genuinely cannot separate them, write a purpose-built clone for that structure and test it against cycles, rather than hoping a generic one guesses right.

code

javascript · 21 lines
javascript
class Money {
  constructor(cents) { this.kind = 'Money'; this.cents = cents; }
  static fromData(d) { return new Money(d.cents); }
  format() { return `$${(this.cents / 100).toFixed(2)}`; }
}

const handlers = { refresh: () => 'refreshed' };

// cloneable: no functions, no reliance on prototypes
const state = { price: new Money(1999), options: { onDone: 'refresh' } };
state.self = state;

const copy = structuredClone(state);
console.log(copy.price instanceof Money); // false
console.log(copy.self === copy);          // true

const registry = { Money: Money.fromData };
const revive = (d) => (registry[d?.kind] ? registry[d.kind](d) : d);

console.log(revive(copy.price).format());   // "$19.99"
console.log(handlers[copy.options.onDone]()); // "refreshed"

go deeper

for a junior

Know the two outcomes to expect: a function anywhere in the value makes the whole call throw, and a class instance comes back as plain data without its methods.

for a middle

Explain that the throw is all-or-nothing, and be able to restructure a value so behaviour sits outside the cloneable state rather than inside it.

for a senior

Demonstrate the boundary thinking: decide where snapshots are needed, keep state plain and serialisable by construction, and make rehydration into rich objects an explicit, tested conversion.

for a principal

Own the rule across the system — state that crosses any copy, worker, storage or transport boundary is plain data with a versioned discriminator, and every rich-object reconstruction happens in one reviewed place.

## Read the three parts separately The object in the question has three different relationships with the structured clone algorithm, and a good answer takes them one at a time. **The cycle is free.** The algorithm tracks objects it has already cloned, so a back-reference is reproduced inside the copy. This is the part a hand-rolled recursive clone typically gets wrong (stack overflow) and the built-in gets right. **The class instances are copied but demoted.** Their own data properties come across; their prototype does not. The clone is a plain object, so methods are gone and `instanceof` is `false`. Private `#fields` are not own properties and do not survive either. Nothing warns you — this is the failure that shows up later as `copy.total is not a function`. **The callback is fatal.** Functions are not cloneable, and the algorithm throws `DataCloneError` rather than skipping the property. That throw aborts the whole operation: you do not receive a partial clone. ```js structuredClone({ data: [1, 2], options: { onDone() {} } }); // DataCloneError — nothing is returned ``` ## Why the loud failure is the good outcome It is tempting to see the throw as an inconvenience next to `JSON.stringify`, which drops functions quietly. It is the opposite. A quiet drop moves the failure far from its cause: the copy looks fine, some later code calls `options.onDone()`, and you debug the call site instead of the copy site. The throw names the property, at the moment of copying, before the corrupted value can travel. So the first move is not "work around the throw". It is "ask why behaviour is inside a value I am trying to snapshot". ## The design fix: separate data from behaviour Most objects that resist cloning are objects that mix two roles. Split them: - **State** is plain, serialisable data: primitives, plain objects, arrays, and the built-ins the algorithm understands (`Date`, `Map`, `Set`, typed arrays). - **Behaviour** — callbacks, service handles, sockets, class methods — lives outside the state, reached by a key that *is* cloneable. A callback stored directly becomes a callback looked up by name: ```js // before: not cloneable const state = { rows: [], options: { onDone: () => refresh() } }; // after: cloneable state + a registry the state only names const handlers = { refresh: () => refresh() }; const state = { rows: [], options: { onDone: 'refresh' } }; handlers[state.options.onDone](); ``` The state now clones, and the indirection is small and explicit. ## Rehydration: getting rich objects back When you do need class instances on the other side of a copy, make the conversion a first-class step rather than an assumption. Carry a discriminator in the data and give each type a static factory: ```js class Money { constructor(cents) { this.kind = 'Money'; this.cents = cents; } static fromData(d) { return new Money(d.cents); } format() { return `$${(this.cents / 100).toFixed(2)}`; } } const registry = { Money: Money.fromData }; const revive = (d) => (registry[d?.kind] ? registry[d.kind](d) : d); const copy = structuredClone({ price: new Money(1999) }); const price = revive(copy.price); price.format(); // '$19.99' ``` This has a real cost — you maintain the registry — and a real benefit: the moment where plain data becomes a rich object is visible, testable, and the same code path you will need anyway if that state is ever persisted or sent to a worker. Note that `structuredClone` will not help you here implicitly: it ignores `toJSON()`, and there is no reviver hook. ## When not to use structuredClone at all Three cases argue for something else: 1. **Behaviour is genuinely intrinsic** to the structure and cannot be lifted out. Then write a clone method on the type itself — it knows what to copy and what to share — and unit-test it against the cycle case. 2. **You only need a shallow copy.** Deep-copying a large graph to change one field is wasted work; `{ ...state, field: next }` is cheaper and clearer, and structural sharing is the point of immutable-update styles. 3. **The graph is huge.** A structured clone walks and allocates the entire reachable graph. On a hot path that is real cost; snapshot the subtree you actually need rather than the root. ## What a strong answer sounds like Name the specific outcomes — throw from the function, silent prototype loss, cycle handled — then move to the shape of the data. The point an interviewer is listening for is that "my state will not clone" is usually a design signal, not an API limitation.

  • Why not just strip the non-cloneable properties before calling structuredClone?
    You can, and for a small known shape a explicit `pick` of the cloneable fields is fine. The problem is a generic stripper: it has to walk the graph itself, so you have reimplemented half the clone algorithm — including cycle tracking — just to prepare an argument for the real one. Prefer state that is cloneable by construction.
  • How would you detect this class of problem before it reaches production?
    Put a clone in the test. If state is supposed to be snapshot-able, a test that structuredClones a realistic instance fails loudly the day someone adds a callback or a class instance to it. That converts an occasional runtime DataCloneError into a build-time signal, and it documents the intended shape of the state.
  • The clone succeeds but a downstream check `x instanceof Money` now fails. What is the minimum fix, and what is the better one?
    The minimum fix is to stop branching on `instanceof` for cloned data and branch on a data discriminator you cloned along with it. The better one is to rehydrate at the boundary — convert plain data back into instances once, in a known place — so the rest of the code keeps working with real objects and never has to know a copy happened.

saying these in an interview costs you the question

  • Expects the offending property to be skipped instead of throwing
  • Plans to restore methods by reassigning them onto the clone
  • Assumes the cycle is what breaks structuredClone
  • Treats prototype loss as a bug in the API rather than its contract
  • Deep-clones a whole state tree to change one field

context