Skip to content
academia.sh

Lesson 17 / 17

Structured Cloning

The references shallow copying shares, the losses of copying through serialization, the scope of structured cloning, and a deep copy that preserves the prototype and descriptors.

Contents

The previous lesson showed the copy–view distinction at the byte level. The same question has been open for objects since the start of the course: object spread lost the chain, Object.assign turned accessors into plain values, JSON.stringify dropped methods and Map content.

This lesson answers that question in full. Three copying paths — spread, a round trip through serialization, and structured cloning — are examined separately; what each one carries and what it drops is shown. Then a deep copy preserving both is written.

The Limit of Shallow Copy

Object spread and Object.assign write the source’s own enumerable properties to the target. If a value is an object, what gets written is that object’s reference; the object itself is not copied.

const source = {
  sensor: "S-01",
  value: 21.4,
  sourceInfo: { file: "measurement-2.csv", line: 17 },
  tags: ["raw", "temperature"],
};

const spread = { ...source };
const assigned = Object.assign({}, source);

console.log(spread.sensor);
console.log(spread.sourceInfo === source.sourceInfo);
console.log(assigned.tags === source.tags);

spread.sensor = "S-99";
spread.sourceInfo.line = 99;
spread.tags.push("changed");

console.log(source.sensor);
console.log(source.sourceInfo.line);
console.log(source.tags.join(","));
console.log(JSON.stringify(source.sourceInfo) === JSON.stringify(spread.sourceInfo));
S-01
true
true
S-01
99
raw,temperature,changed
true

A top-level field is independent: when sensor changes in the copy, the source is unaffected. Inner objects, though, are shared; every change made through the copy is also visible in the source. This is the shallow-copy–deep-copy distinction introduced in the Value and Reference and Passing lesson of the Programming Fundamentals course.

The reason freezing stayed shallow in the Immutability Techniques lesson is the same: both operations are limited to the object’s own properties and do not descend into the objects they point to.

Copying Through Serialization

Converting an object to text and reading it back recreates every inner object anew. The losses are large, and these losses are silent.

const source = {
  sensor: "S-01",
  value: 21.4,
  offset: undefined,
  measuredAt: new Date(0),
  tagSet: new Set(["raw", "temperature"]),
  calibration: new Map([["S-01", -0.4]]),
  format() {
    return this.sensor;
  },
};

const jsonCopy = JSON.parse(JSON.stringify(source));

console.log(Object.keys(jsonCopy).join(","));
console.log(typeof jsonCopy.format);
console.log("offset" in jsonCopy);
console.log(typeof jsonCopy.measuredAt);
console.log(jsonCopy.measuredAt);
console.log(JSON.stringify(jsonCopy.tagSet));
console.log(JSON.stringify(jsonCopy.calibration));

const cyclic = { sensor: "S-02" };
cyclic.self = cyclic;

try {
  JSON.stringify(cyclic);
} catch (error) {
  console.log(`cycle: ${error.constructor.name}`);
}
sensor,value,measuredAt,tagSet,calibration
undefined
false
string
1970-01-01T00:00:00.000Z
{}
{}
cycle: TypeError

Five losses can be listed. Functions and fields with an undefined value drop entirely. A date object turns into a string, and reading it back gives text, not a date. Set and Map content collapses to an empty object — the behavior seen in the first lesson of the Advanced Collections topic. Non-enumerable properties and symbol keys are also skipped. Finally, circular references throw an error.

The prototype chain is lost too: the object read back is always an ordinary object. This path is suitable only for plain, nested objects that carry data alone — which is, after all, JSON format’s own scope.

Structured Cloning

The structuredClone function offered by the runtime deep-copies a far wider set of values than serialization does and preserves cycles.

class MeasurementRecord {
  constructor(sensor, value) {
    this.sensor = sensor;
    this.value = value;
    this.tagSet = new Set(["raw"]);
    this.measuredAt = new Date(0);
  }
  format() {
    return `${this.sensor}: ${this.value}`;
  }
}

const record = new MeasurementRecord("S-01", 21.4);
record.self = record;

const copy = structuredClone(record);

console.log(copy.sensor);
console.log(copy.self === copy);
console.log(copy.tagSet instanceof Set);
console.log(copy.tagSet.has("raw"));
console.log(copy.measuredAt instanceof Date);
console.log(copy.measuredAt.getTime());
console.log(copy.tagSet === record.tagSet);

console.log(Object.getPrototypeOf(copy) === MeasurementRecord.prototype);
console.log(copy instanceof MeasurementRecord);
console.log(typeof copy.format);

try {
  structuredClone({ fn: () => 1 });
} catch (error) {
  console.log(`function: ${error.name}`);
}

const typed = new Float64Array([21.4, 19.8]);
const typedCopy = structuredClone(typed);
console.log(typedCopy instanceof Float64Array);
console.log(typedCopy.buffer === typed.buffer);
S-01
true
true
true
true
0
false
false
false
undefined
function: DataCloneError
true
false

The gains are clear: circular references are preserved — the copy’s field pointing back to itself points to the copy, not the source. Set, Map, Date, and typed arrays are copied along with their types, and inner objects are not shared.

There is only one loss, but it is decisive for this course’s axis: the prototype chain is not carried. The eighth, ninth, and tenth lines show this in three separate ways — the copy’s prototype is not the class’s prototype, the type query is negative, and the method is not found. When a class instance is cloned, only the data remains.

Functions cannot be cloned either and throw an error. These two limits come from the same reasoning: structured cloning is defined to transfer values into another execution context; code and the chain are things that cannot be transferred.

A Copy That Preserves the Chain and Descriptors

If both depth and the chain are needed, the copy has to be written by hand. The pattern is the sibling of deep freezing in the Immutability Techniques lesson: a recursive walk, a visit table for cycles, and rebuilding the prototype at every node.

function deepCopy(value, seen = new WeakMap()) {
  if (value === null || typeof value !== "object") return value;
  if (seen.has(value)) return seen.get(value);

  if (value instanceof Date) return new Date(value.getTime());
  if (value instanceof Set) {
    const set = new Set();
    seen.set(value, set);
    for (const item of value) set.add(deepCopy(item, seen));
    return set;
  }
  if (value instanceof Map) {
    const map = new Map();
    seen.set(value, map);
    for (const [key, content] of value) {
      map.set(deepCopy(key, seen), deepCopy(content, seen));
    }
    return map;
  }

  const copy = Array.isArray(value)
    ? []
    : Object.create(Object.getPrototypeOf(value));
  seen.set(value, copy);

  for (const name of Reflect.ownKeys(value)) {
    const descriptor = Object.getOwnPropertyDescriptor(value, name);
    if ("value" in descriptor) descriptor.value = deepCopy(descriptor.value, seen);
    Object.defineProperty(copy, name, descriptor);
  }
  return copy;
}

class MeasurementRecord {
  constructor(sensor, value) {
    this.sensor = sensor;
    this.value = value;
    this.sourceInfo = { file: "measurement-2.csv", line: 17 };
  }
  format() {
    return `${this.sensor}: ${this.value}`;
  }
}

const record = new MeasurementRecord("S-01", 21.4);
record.self = record;
Object.defineProperty(record, "hiddenCounter", {
  value: 3,
  enumerable: false,
  writable: true,
  configurable: true,
});

const copy = deepCopy(record);

console.log(copy.format());
console.log(copy instanceof MeasurementRecord);
console.log(copy.self === copy);
console.log(copy.sourceInfo === record.sourceInfo);
console.log(copy.sourceInfo.line);
console.log(copy.hiddenCounter);
console.log(Object.keys(copy).join(","));
console.log(Object.getOwnPropertyDescriptor(copy, "hiddenCounter").enumerable);

copy.sourceInfo.line = 99;
console.log(record.sourceInfo.line);
S-01: 21.4
true
true
false
17
3
sensor,value,sourceInfo,self
false
17

The copy is a genuine measurement record: its method works, the type query is positive, its cycle points back to itself, its inner object is independent, and its non-enumerable field is preserved along with its flag. Choosing Reflect.ownKeys ensures symbol keys are covered too; copying the descriptor keeps accessors from collapsing into plain values.

This pattern has its limits too. Private fields cannot be copied — because they cannot be read from outside, a library function has no access to them. State held in a closure is not carried for the same reason. The general rule is: what can be copied is what can be seen from outside.

Three Setups Under Copying

Throughout the course, the measurement record was built in three forms. Copying is the test that lays the difference between them bare most sharply.

function plainRecord(sensor, value) {
  return {
    sensor,
    value,
    format() {
      return `${this.sensor}: ${this.value}`;
    },
  };
}

function closureRecord(sensor, value) {
  return {
    format() {
      return `${sensor}: ${value}`;
    },
  };
}

class ClassRecord {
  constructor(sensor, value) {
    this.sensor = sensor;
    this.value = value;
  }
  format() {
    return `${this.sensor}: ${this.value}`;
  }
}

function check(name, obj) {
  const spread = { ...obj };
  let cloneResult;
  try {
    const clone = structuredClone(obj);
    cloneResult = typeof clone.format === "function" ? clone.format() : "no behavior";
  } catch (error) {
    cloneResult = `error:${error.name}`;
  }
  const spreadResult =
    typeof spread.format === "function" ? spread.format() : "no behavior";
  console.log(`${name}: json=${JSON.stringify(obj)}, spread=${spreadResult}, clone=${cloneResult}`);
}

check("plain   ", plainRecord("S-01", 21.4));
check("closure ", closureRecord("S-01", 21.4));
check("class   ", new ClassRecord("S-01", 21.4));

const classInstance = new ClassRecord("S-01", 21.4);
const chainedCopy = Object.defineProperties(
  Object.create(Object.getPrototypeOf(classInstance)),
  Object.getOwnPropertyDescriptors(classInstance),
);
console.log(chainedCopy.format());
console.log(chainedCopy instanceof ClassRecord);
plain   : json={"sensor":"S-01","value":21.4}, spread=S-01: 21.4, clone=error:DataCloneError
closure : json={}, spread=S-01: 21.4, clone=error:DataCloneError
class   : json={"sensor":"S-01","value":21.4}, spread=no behavior, clone=no behavior
S-01: 21.4
true

The three setups show three separate behaviors. In the plain object, because data and behavior stand in the same object, serialization takes the data, spread also carries the behavior, and structured cloning throws an error because of the function. In the closure-based setup, because the data is never a property at all, serialization comes out empty; spread copies only the function, and because the function carries its own closure, the result is still correct.

In the class setup, data and behavior are in separate places: data in the instance, behavior in the chain. Because both copying paths take only the instance, the behavior drops — but no error is thrown either. If the chain is needed, it has to be set up explicitly, as in the last two lines.

The sentence from the course’s first lesson closes here: an object’s data is in itself, its identity is in its chain. Copying operations carry own properties; the chain is carried only when it is set up explicitly.

Summary

  • Shallow copy separates only top-level fields; inner objects are shared with the source.
  • Copying through serialization drops functions, undefined fields, non-enumerable and symbol-keyed properties; it corrupts Set, Map, and Date types, and throws an error on cycles.
  • structuredClone preserves cycles and copies many built-in types along with their type, but does not carry the prototype chain and throws an error on functions.
  • A copy preserving the chain and descriptors is written by hand: the prototype is rebuilt at every node, descriptors are carried exactly, cycles are tracked with a visit table.
  • Private fields and state in a closure cannot be copied; what can be copied is what can be seen from outside.

Course Wrap-Up

The course started with a single question: how is a property that was never written at all found? The answer was the prototype link every object carries, and this link was measured across seventeen lessons. Property lookup started from the object itself and proceeded up the chain; writing never climbed upward, it always shadowed. The job the new operator does was rewritten in three lines. Which prototype operation each line of a class declaration corresponds to was shown with the same tests — a class is the syntactic face of the chain; there is no different object model underneath it.

The second topic took up the question the chain does not answer. A prototype determines where a function will be found; this determines which data it will work on. Because the two are independent, a method can be detached from its object, lent to another object, or permanently bound. Arrow functions fall outside these four rules and take their context from the scope they are defined in. Closures, meanwhile, offered a second construction path that hides state without touching the chain at all.

The third topic examined collections by the same criteria: Map preserves key identity, weak collections do not extend objects’ lifetime, the iteration protocol is a single method found on the chain, generators produce that method themselves, typed arrays have a separate chain. The last lesson ran all of them through the copying test.

The measurement record’s three setups — plain object, factory with closure, class — were held side by side throughout the course and compared at every new concept: sharing, visibility, binding, privacy, serialization, and copying. There is no single correct choice among them; the criteria are explicit, and the comparisons can be measured with the outputs in these lessons.

One thing is left out. Every example in this course resolved instantly: every console.log call ran only after the line before it had finished. This assumption breaks down once measurements come from a file or a network — the result is not ready the moment it is requested. The Asynchronous JavaScript and the Runtime course takes up this situation: the single-threaded execution model, the event loop’s task and microtask queues, promise-based flows, and the memory life cycle. The chain and context rules you learned in this course apply there too; the only thing that changes is when the code runs.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close