Developer reviewing JavaScript class rehydration tests on a wall-mounted display while holding a printed test report.

Rebuild a Cloned JavaScript Class Without Resetting Its State

Tue, Oct 27, 2026

A backend review can go wrong in a deceptively ordinary way. A structuredClone() call returns an object whose visible taskId and limit are exactly what the receiver expected, so the code looks ready to continue. But the receiver does not merely need those two values. It needs a WorkQuota whose original capacity of 3 has already been consumed once, leaving 2, and whose next legitimate local transition is therefore 2→1. That history lives in private state, not in the clone's public shape.

The question is not whether the clone “looks right.” It is what evidence is sufficient before a consumer treats the result as the original kind of stateful class instance. This article uses one bounded ES-module fixture and the supplied commissioning run from October 27, 2026. The run is Node v24.22.1 in one realm on Linux x64; it does not test a Worker, browser, queue, network boundary, persistent store, or real job execution.

Three negative controls matter. First, cloning preserves this specimen's enumerable public values while losing the class prototype and private element, behavior consistent with Node's class-cloning guidance and MDN's practical limitations. Second, Object.setPrototypeOf() can make instanceof pass and expose the method while private access still fails. Third, a fresh constructor can create a genuine instance and still be wrong because it silently resets remaining capacity from 2 to 3. The right answer depends on the receiving contract: a data consumer may accept a validated record, while a stateful consumer must reconstruct from explicit, authorized state and then verify the next transition.

Define what the receiving component must preserve

Start with the receiving component, not with the cloning API. For this fixture, the stateful contract is concrete: taskId === 'owned-task', limit === 3, remaining capacity is already 2, and the next local call to reserveOne() must produce 1. The reconstructed object must be distinct from the source, and reserving on it must not mutate the source, which must remain at 2.

That is a stronger contract than “the record has the right keys.” It asks for public identity, the authored limit, the consumed-state value, the class behavior that operates on that state, and one exact state transition. A reviewer should write those acceptance predicates before choosing an implementation. The same discipline appears in broader backend API engineering foundations: data shape and application behavior are separate responsibilities at a boundary.

The consumer must also declare whether it needs data or a WorkQuota. If it only reads a validated record, reconstructing a class creates unnecessary semantics. If it calls reserveOne(), then a plain object is insufficient even when its public values are correct.

There is one more boundary: local reconstruction is not authority. Two independent reconstructions can reproduce the same {limit: 3, remaining: 2} state. That does not authorize two consumers to spend the same production quota. This experiment establishes local state-and-behavior preservation only; it does not establish freshness, uniqueness, ownership of a shared resource, or distributed consistency.

The resulting decision matrix is intentionally small:

Decision

Use it when

Required evidence

REHYDRATE

A clone is data-shaped but the consumer requires class behavior

Valid complete state record is available; do not patch the clone

REJECT_PROTOTYPE_PATCH

Prototype replacement makes methods or instanceof appear correct

Private-state predicate still fails or method access throws

REJECT_STATE_RESET

A fresh constructor produces a genuine instance with defaulted state

Reconstructed state differs from the approved consumed value

REJECT_DTO

Declared input contract is violated

Reject before constructor call and before admission

ACCEPT_REHYDRATED

Explicit state reconstructs the class correctly

Brand/presence, exact state, constructor count, next transition, source isolation

ACCEPT_DATA_ONLY

Consumer explicitly needs only a validated record

DTO validates; no class construction required

HOLD

Authoritative state or complete evidence is missing

Preserve evidence; do not guess defaults

Pin the runtime and the class boundary

The supplied commissioning record identifies one execution, not a universal runtime result: it started on 2026-10-27 at 07:13:41 UTC and reports Node v24.22.1, V8 13.6.233.17-node.58, Linux x64, OS release 6.18.55, source SHA-256 2c2201bf8d78b1aff1f5ffed05ef7cd606ad780c81d71a0b530fb35de5aaddd5, and PASS_14_CASES. Elapsed time is not used as a benchmark.

The rolling Node.js global structuredClone documentation displayed a different version when inspected: v26.12.1. Its global-objects page says structuredClone was added in v17.0.0 and exposes the WHATWG method. That documentation is useful for the API contract, but it is not the execution record for Node v24.22.1. The normative HTML Living Standard structured-data section displayed “Last Updated 26 October 2026” when inspected; that is a specification-page date, not a feature-launch date.

Likewise, the TC39 page inspected for private-element semantics is labeled ECMAScript 2027. That label identifies the current draft edition being viewed; it should not be read as saying private-field access behavior was newly introduced in 2027.

Keep environment assumptions visible

The fixture is deliberately narrow: one Node.js realm, ordinary owned objects, unmodified built-ins, synchronous local execution, fresh values for each case, and no Proxy, subclass, cross-realm object, concurrent mutation, transport, or actual work execution. The .mjs file uses global structuredClone; no package installation is required. These assumptions matter because the evidence supports exactly this boundary and should not be promoted into claims about Workers, browsers, hostile object graphs, queues, or remote services.

Run the fourteen-case fixture and retain its report

Save the following source exactly as js_clone_fixture.mjs in an owned writable directory. The published digest corresponds to this exact source. Run it with the Node executable being qualified:

node js_clone_fixture.mjs

The driver creates a unique adjacent clone-contract-run-* directory, writes results.json with exclusive creation, and prints a compact object containing the overall result, report path, and source digest. For the supplied commissioning execution, the expected overall result is PASS_14_CASES. A missing, truncated, or failed report is not “close enough”: retain the failure and choose HOLD. Do the same for a nonzero process exit. The result file is evidence from a local qualification run; creating it is not transactional publication of a business object.

// Owned, same-realm Node.js experiment; no worker, DOM, network, or packages.
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { release } from 'node:os';

const sourcePath = fileURLToPath(import.meta.url);
const runDir = mkdtempSync(join(dirname(sourcePath), 'clone-contract-run-'));
const report = {
  revision: 'clone-class-contract-v1', started_utc: new Date().toISOString(),
  source_sha256: createHash('sha256').update(readFileSync(sourcePath)).digest('hex'),
  runtime: {node: process.version, v8: process.versions.v8,
    platform: process.platform, arch: process.arch, os_release: release()},
  result: 'FAIL', cases: [], admissions: []
};
let constructions = 0;

function fieldsValid(taskId, limit, remaining) {
  return typeof taskId === 'string' && /^[a-z][a-z0-9-]{0,31}$/.test(taskId)
    && Number.isSafeInteger(limit) && limit > 0 && limit <= 100
    && Number.isSafeInteger(remaining) && remaining >= 0 && remaining <= limit;
}
class WorkQuota {
  #remaining;
  constructor(taskId, limit, remaining = limit) {
    if (!fieldsValid(taskId, limit, remaining)) throw new TypeError('invalid fields');
    this.taskId = taskId;
    this.limit = limit;
    this.#remaining = remaining;
    constructions++;
  }
  remaining() { return this.#remaining; }
  reserveOne() {
    if (this.#remaining === 0) throw new RangeError('no capacity');
    return --this.#remaining;
  }
  toDTO() {
    return {kind: 'work-quota', version: 1, taskId: this.taskId,
      limit: this.limit, remaining: this.#remaining};
  }
  static hasState(value) {
    return typeof value === 'object' && value !== null && #remaining in value;
  }
}
function makeSource() {
  const source = new WorkQuota('owned-task', 3);
  assert.equal(source.reserveOne(), 2);
  return source;
}
function readDTO(value) {
  if (value === null || typeof value !== 'object'
      || Object.getPrototypeOf(value) !== Object.prototype) {
    throw new TypeError('plain DTO required');
  }
  const expected = ['kind', 'limit', 'remaining', 'taskId', 'version'];
  const keys = Reflect.ownKeys(value);
  if (keys.some(k => typeof k !== 'string')
      || JSON.stringify(keys.sort()) !== JSON.stringify(expected)) {
    throw new TypeError('exact DTO keys required');
  }
  const descriptors = Object.getOwnPropertyDescriptors(value);
  if (expected.some(k => !Object.hasOwn(descriptors[k], 'value'))) {
    throw new TypeError('data properties required');
  }
  const {kind, version, taskId, limit, remaining} = value;
  if (kind !== 'work-quota' || version !== 1
      || !fieldsValid(taskId, limit, remaining)) throw new TypeError('invalid DTO');
  return {kind, version, taskId, limit, remaining};
}
function rehydrate(value) {
  const dto = readDTO(value);
  return new WorkQuota(dto.taskId, dto.limit, dto.remaining);
}
function errorName(action) {
  try { action(); return null; } catch (error) { return error.name; }
}
function record(id, decision, observed, expected) {
  assert.deepEqual(observed, expected, id);
  report.cases.push({id, decision, observed, expected, pass: true});
}
try {
  const original = makeSource();
  record('C01', 'BASELINE', {
    keys: Object.keys(original), instance: original instanceof WorkQuota,
    brand: WorkQuota.hasState(original), remaining: original.remaining()
  }, {keys: ['taskId', 'limit'], instance: true, brand: true, remaining: 2});

  const count = constructions;
  const cloned = structuredClone(original);
  record('C02', 'REHYDRATE', {
    fields: {...cloned}, plain: Object.getPrototypeOf(cloned) === Object.prototype,
    instance: cloned instanceof WorkQuota, brand: WorkQuota.hasState(cloned),
    method: typeof cloned.remaining, constructor_calls: constructions - count
  }, {fields: {taskId: 'owned-task', limit: 3}, plain: true,
    instance: false, brand: false, method: 'undefined', constructor_calls: 0});

  record('C03', 'REHYDRATE', {
    borrowed_error: errorName(() => WorkQuota.prototype.remaining.call(cloned)),
    source_remaining: original.remaining()
  }, {borrowed_error: 'TypeError', source_remaining: 2});

  const patched = structuredClone(original);
  Object.setPrototypeOf(patched, WorkQuota.prototype);
  record('C04', 'REJECT_PROTOTYPE_PATCH', {
    instance: patched instanceof WorkQuota, method: typeof patched.remaining,
    brand: WorkQuota.hasState(patched), error: errorName(() => patched.remaining())
  }, {instance: true, method: 'function', brand: false, error: 'TypeError'});

  record('C05', 'REJECT_FUNCTION_PAYLOAD', {
    error: errorName(() => structuredClone({taskId: 'owned-task', run: () => 1}))
  }, {error: 'DataCloneError'});

  const date = new Date(1704067200000);
  const dateCopy = structuredClone(date);
  record('C06', 'BUILTIN_CONTROL', {
    date: dateCopy instanceof Date, distinct: dateCopy !== date,
    milliseconds: dateCopy.getTime()
  }, {date: true, distinct: true, milliseconds: 1704067200000});

  const naive = new WorkQuota(cloned.taskId, cloned.limit);
  record('C07', 'REJECT_STATE_RESET', {
    instance: naive instanceof WorkQuota, brand: WorkQuota.hasState(naive),
    remaining: naive.remaining(), intended_remaining: 2
  }, {instance: true, brand: true, remaining: 3, intended_remaining: 2});

  const dto = structuredClone(original.toDTO());
  const beforeRepair = constructions;
  const restored = rehydrate(dto);
  const beforeReservation = restored.remaining();
  const afterReservation = restored.reserveOne();
  record('C08', 'ACCEPT_REHYDRATED', {
    dto, instance: restored instanceof WorkQuota, brand: WorkQuota.hasState(restored),
    distinct: restored !== original, before: beforeReservation, after: afterReservation,
    source_remaining: original.remaining(), constructor_calls: constructions - beforeRepair
  }, {dto: {kind: 'work-quota', version: 1, taskId: 'owned-task', limit: 3, remaining: 2},
    instance: true, brand: true, distinct: true, before: 2, after: 1,
    source_remaining: 2, constructor_calls: 1});
  report.admissions.push({case_id: 'C08', mode: 'REHYDRATED', remaining: 1});

  const invalids = [
    ['C09', 'unsupported version', d => { d.version = 2; }],
    ['C10', 'missing state', d => { delete d.remaining; }],
    ['C11', 'state exceeds limit', d => { d.remaining = 4; }],
    ['C12', 'unexpected field', d => { d.debug = true; }],
    ['C13', 'non-finite state', d => { d.remaining = NaN; }]
  ];
  for (const [id, reason, change] of invalids) {
    const candidate = structuredClone(original.toDTO());
    change(candidate);
    const beforeConstruct = constructions;
    const beforeAdmit = report.admissions.length;
    const error = errorName(() => {
      const accepted = rehydrate(candidate);
      report.admissions.push({case_id: id, remaining: accepted.remaining()});
    });
    record(id, 'REJECT_DTO', {reason, error,
      constructor_calls: constructions - beforeConstruct,
      admissions: report.admissions.length - beforeAdmit
    }, {reason, error: 'TypeError', constructor_calls: 0, admissions: 0});
  }

  const beforeData = constructions;
  const dataOnly = readDTO(structuredClone(original.toDTO()));
  record('C14', 'ACCEPT_DATA_ONLY', {
    plain: Object.getPrototypeOf(dataOnly) === Object.prototype,
    instance: dataOnly instanceof WorkQuota, remaining: dataOnly.remaining,
    constructor_calls: constructions - beforeData
  }, {plain: true, instance: false, remaining: 2, constructor_calls: 0});
  report.admissions.push({case_id: 'C14', mode: 'DATA_ONLY', remaining: 2});
  assert.deepEqual(report.cases.map(x => x.id),
    Array.from({length: 14}, (_, i) => C${String(i + 1).padStart(2, '0')}));
  assert.deepEqual(report.admissions.map(x => x.case_id), ['C08', 'C14']);
  report.result = 'PASS_14_CASES';
} catch (error) {
  report.failure = {name: error.name, message: error.message};
  process.exitCode = 1;
} finally {
  report.finished_utc = new Date().toISOString();
  const resultPath = join(runDir, 'results.json');
  writeFileSync(resultPath, JSON.stringify(report, null, 2) + '\n', {flag: 'wx'});
  console.log(JSON.stringify({result: report.result, result_path: resultPath,
    source_sha256: report.source_sha256}));
}

A useful playbook is: run the exact source; capture stdout, process status, and the generated report path; verify the source digest before interpreting behavior; read all fourteen case records rather than only the final string; reconcile runtime metadata with the environment you intended to qualify; and retain failed evidence unchanged. Do not “repair” a report by deleting an unexpected case or rerunning until a preferred answer appears.

Bind the observations to the exact source

The overall PASS_14_CASES is only a manifest-level result. Qualification still depends on C01 through C14 appearing in order, each with its expected observation, and on the admissions array containing only C08 and C14. The source digest binds those observations to the driver above; the runtime fields bind them to the executed process. A passing negative control means the expected defect or rejection was observed. It does not make that rejected design acceptable.

Compare a class instance with its data-shaped clone

C01 establishes the baseline. The original is a WorkQuota, has own enumerable keys taskId and limit, satisfies the class's private-state predicate, and reports remaining() === 2. That 2 matters because makeSource() created capacity 3 and immediately consumed one unit.

C02 then clones that instance. The copied object contains {taskId: 'owned-task', limit: 3} but is a plain object, is not a WorkQuota, has no remaining method, does not satisfy WorkQuota.hasState, and causes zero constructor calls.

Observation

C01 original

C02 clone

Own enumerable keys

taskId, limit

taskId, limit

Public values

'owned-task', 3

'owned-task', 3

Prototype/class

WorkQuota instance

plain Object

typeof remaining

function via prototype

undefined

WorkQuota.hasState(...)

true

false

Remaining capacity

2

unavailable

Constructor calls caused by clone

n/a

0

That result matches the mechanism in the HTML structured-clone algorithms. For the ordinary-object path, serialization walks enumerable own property keys, while deserialization of serialized type Object creates a new Object in the target realm; it does not invoke the source class constructor. The Node.js considerations for cloning classes and prototypes are unusually explicit here: JavaScript class instances are cloned as plain JavaScript objects and prototypes are not preserved.

The wording must stay specimen-specific. C02 proves that these two enumerable public properties were copied in this case; it does not prove that “every public field of every object is preserved.” MDN similarly notes that prototype chains and class private elements are not duplicated.

This is also a different question from ArrayBuffer transfer and ownership. That workflow asks who retains storage after a worker message; this fixture has no transfer list, no detachable storage, no worker, and no message receipt. Here the boundary is semantic: does a copied record satisfy a consumer that requires stateful class behavior?

Borrowing a method does not create its required state

C03 removes one tempting explanation: perhaps the only missing ingredient is the function. The fixture borrows the real method directly from WorkQuota.prototype and invokes it with the clone as its receiver:

WorkQuota.prototype.remaining.call(cloned)

The result is TypeError, while the source remains at 2. The function is present; the receiver is the problem. remaining() evaluates this.#remaining, and the cloned plain object does not carry that private element.

This is the key object-model distinction for backend reviewers. A method is executable code. A stateful method also has a receiver contract. Supplying the code does not synthesize the receiver's required internal state. You can therefore have “the method” without having an object on which the method is valid.

The source staying at 2 is a useful negative control. The failed borrowed call did not somehow redirect to the original or mutate it. It simply failed private access on the clone.

There is a superficially similar but technically separate UI problem in the cloneNode listener problem: a DOM clone can look structurally correct while a JavaScript listener registration is absent. This article does not import that browser repair model. WorkQuota is not missing a listener registration; it is missing instance state required by the class's method.

Test the misleading prototype-patch repair

C04 is the most important negative control because it creates a convincing false positive. The fixture starts from a fresh clone, then runs:

Object.setPrototypeOf(patched, WorkQuota.prototype);

After that operation, two checks become reassuring: patched instanceof WorkQuota is true, and typeof patched.remaining is 'function'. A shallow review could stop there and declare the object “restored.” The next two observations show why that is wrong. WorkQuota.hasState(patched) remains false, and calling patched.remaining() throws TypeError.

Nothing contradictory happened. The prototype patch changed prototype-based lookup. It did not run the class's instance-element initialization and did not install #remaining on the already existing object. The ECMAScript private-access operations make the distinction precise: PrivateElementFind looks for the corresponding private element in the receiver's private-element state; PrivateGet throws TypeError when that element is absent. InitializeInstanceElements is a separate operation associated with instance initialization, not a side effect of changing an object's prototype.

That distinction explains the exact C04 sequence without overreaching. Prototype operations are not universally “forbidden,” and Object.setPrototypeOf() is not inherently defective. It is simply an incomplete repair for this stateful class because the acceptance contract includes a private element and its exact value.

The practical review rule is stronger than “avoid prototype patches.” It is: do not accept a repair because method lookup and instanceof recovered when the method depends on instance state that was never reconstructed. This is especially important in production debugging, where a patch that moves one failing predicate from red to green can conceal the next failure.

Separate instanceof from private-element presence

For this ordinary same-realm class, default instanceof answers a prototype-chain question. The private read answers whether the receiver carries the private element associated with this class definition. Those are different predicates, which is why C04 can produce instanceof === true and still fail #remaining access.

WorkQuota.hasState() makes that private-element presence test available to this fixture by evaluating #remaining in value from inside the class, where the private name is in scope. It is an owned diagnostic predicate for this class, not a universal JavaScript reflection API for enumerating or discovering arbitrary private fields. The case should therefore be described as “the fixture's private-state predicate fails,” not as generic private-field introspection.

Use controls that prevent an overbroad cloning rule

C05 and C06 keep the article from turning a precise result into the slogan “structuredClone strips classes.” C05 adds an own callable property:

structuredClone({taskId: 'owned-task', run: () => 1})

That operation throws DataCloneError. The HTML algorithm explicitly rejects callable values on this path, and MDN states the practical limitation that functions cannot be duplicated by the structured clone algorithm. This is materially different from C02: cloning the WorkQuota itself succeeded and silently produced a data-shaped object because its prototype methods were not own payload properties being serialized.

C06 supplies the opposite control. A Date authored as new Date(1704067200000) clones to a distinct object that is still a Date and has the same getTime() result. The HTML algorithm has a dedicated serialization branch for [[DateValue]] and a corresponding deserialization branch that creates a Date object with that value. This one control does not qualify every built-in type; it demonstrates that an overbroad “all prototypes are lost” rule is wrong.

The MDN structured clone algorithm guide should be read with care. It lists practical limitations, including omitted prototype chains and private elements, but its abbreviated object list is not a basis for claiming that every user-defined class instance must throw. The normative algorithm and Node's explicit class example explain the observed class-to-plain-object behavior used here.

The review principle is the same one behind why frozen properties do not freeze Map entries: choose a predicate that matches the contract actually promised instead of promoting one true runtime predicate into a broader guarantee. For WorkQuota, neither “clone succeeded” nor “type-like check passed” is enough.

Catch the state reset inside a fresh constructor

C07 demonstrates a different failure: a reconstruction can be a perfectly genuine WorkQuota and still represent the wrong state. The code does this:

const naive = new WorkQuota(cloned.taskId, cloned.limit);

Now naive instanceof WorkQuota is true, WorkQuota.hasState(naive) is true, and its methods can access the private element. Yet naive.remaining() is 3, not the approved consumed value 2.

The reason is visible in the constructor signature: remaining = limit. With only the clone's public taskId and limit, the constructor follows its valid initial-state rule. It has no evidence that one unit was previously consumed. This is not a cloning bug; it is an information-loss problem at the boundary followed by a default that is correct for initialization but wrong for restoration.

This control is valuable because it defeats a stronger set of superficial checks than C04. Prototype identity is correct. The private element exists. The method works. The constructor genuinely ran. Yet the object is semantically unacceptable because it rewrote history from 3→2 back to 3.

In backend code, defaults are particularly dangerous during rehydration because they often produce valid-looking values. A validation layer may happily accept 3 as within [0, limit]. What it cannot infer is whether 3 is the right historical state. That answer must come from an approved state record or another authoritative source.

A valid new instance can still represent the wrong history

Constructing an initial quota and restoring a partially consumed quota are different operations even when they share a constructor. For a new object, defaulting remaining to limit is coherent. For a reconstruction after one reservation, the same default erases the consumed-state history.

The missing value cannot be derived from taskId='owned-task' and limit=3. Many legitimate histories produce those same public fields: no reservations, one reservation, two reservations, or exhaustion. C07 therefore justifies REJECT_STATE_RESET, not a heuristic repair. When remaining=2 is unavailable from an approved source, the correct state is unknown and the decision is HOLD.

Export an explicit versioned state record

The repair begins before cloning: the state owner intentionally exports what a future stateful consumer needs. WorkQuota.toDTO() returns a five-field data transfer object (DTO):

Field

Meaning in this contract

kind

Record discriminator, exactly 'work-quota'

version

Schema version, exactly 1

taskId

Public task identity

limit

Original configured capacity

remaining

Current consumed-state value required for reconstruction

The critical change is remaining. It is not exposed accidentally by enumerating the class. The class explicitly authors a data boundary that includes the private value needed for reconstruction. That is the only reason a consumer can later distinguish the intended 2 from the constructor default 3.

This does not mean “serialize all private fields.” Private state is an implementation mechanism; the application contract decides which state, if any, is safe and necessary to expose as data. In this specimen, remaining is reconstruction state. Other classes may have private caches, capabilities, handles, or derived values that should never enter a DTO. The producer owns the export contract and must make that choice intentionally.

Versioning is equally important. version: 1 gives the reader a declared interpretation for the five fields rather than relying on the coincidence that property names still look familiar. If the producer later changes the state model, the consumer can reject an unsupported version before constructing anything.

Authorization sits outside this shape. A producer or surrounding service must decide whether a particular record is approved for reconstruction. The validator in the fixture only checks this tiny local schema and invariants. A validated DTO does not prove authenticity, authority, freshness, or uniqueness. It also does not prove that two consumers are allowed to act on identical copies. Those concerns require systems evidence that this fixture does not attempt to model.

Validate the record before constructing an object

readDTO() is the admission gate. It requires a non-null object whose prototype is exactly Object.prototype; exact string keys kind, limit, remaining, taskId, and version; own data properties rather than accessors for those keys; kind === 'work-quota'; version === 1; a constrained task ID; a positive safe-integer limit no greater than 100; and a safe-integer remaining from zero through limit.

That is intentionally a tiny primitive schema. The descriptor checks are not a tested defense against hostile getters elsewhere, Proxies, cross-realm objects, adversarial subclasses, or arbitrary object graphs. The fixture has already declared those inputs out of scope. Treating this validator as a generic security boundary would exceed the evidence.

The stronger property is ordering: validation finishes before new WorkQuota(...). Cases C09 through C13 record both constructor-call deltas and admission deltas, and each must remain zero on rejection.

Case

Invalid candidate

Expected result

Constructor delta

Admission delta

C09

version = 2

TypeError

0

0

C10

delete remaining

TypeError

0

0

C11

remaining = 4 with limit = 3

TypeError

0

0

C12

add debug = true

TypeError

0

0

C13

remaining = NaN

TypeError

0

0

C09 rejects semantic uncertainty: version 2 might assign different meaning or constraints to the same-looking fields. C10 rejects missing reconstruction state, which is precisely the information C07 proved cannot be recovered from public values. C11 rejects an impossible local invariant because remaining capacity cannot exceed the limit. C12 rejects schema drift rather than silently accepting undeclared data. C13 rejects NaN, which is a number by typeof but not a safe integer.

The decision for all five is REJECT_DTO. Importantly, rejection happens before construction and before the fixture appends an admission. This prevents invalid data from gaining a class wrapper and then being mistaken for a trusted domain object simply because a constructor returned.

Reject missing or unsupported state instead of defaulting it

The anti-pattern here is “be helpful.” On an unknown version, do not reinterpret it as version 1. On a missing remaining, do not substitute limit. On remaining > limit, do not clamp it. On an extra field, do not silently discard it if the declared contract requires exact keys. On NaN, do not coerce it into zero.

Those repairs manufacture state that the producer did not authorize. In particular, filling missing remaining with the constructor default re-creates C07's 2→3 history reset under a validation veneer. The safe outcome is not a guessed object; it is preserved failure evidence and HOLD until an approved complete record is available.

Rehydrate and verify the next transition

C08 is the positive stateful path. The original calls toDTO(), producing {kind: 'work-quota', version: 1, taskId: 'owned-task', limit: 3, remaining: 2}. The DTO is cloned as data, validated, and passed to new WorkQuota(dto.taskId, dto.limit, dto.remaining).

The acceptance evidence is deliberately redundant because each predicate catches a different class of mistake. The restored value is a WorkQuota; WorkQuota.hasState(restored) is true; it is a distinct object from the original; exactly one constructor call occurred during reconstruction; restored.remaining() begins at 2; one reserveOne() changes it to 1; and the original still reports 2.

That combination answers the article's central question. Before a consumer treats a structuredClone result as the original kind of stateful class instance, public values alone are insufficient. The consumer needs evidence for the intended class behavior and the state that behavior depends on: explicit complete state from an approved record, successful schema validation, a genuine reconstruction path, private-element presence for this class, the exact pre-transition value, the exact next transition, and isolation from the source.

C08 is therefore ACCEPT_REHYDRATED, but the wording should remain bounded. The state was reproduced into a fresh local object; the experiment does not prove that this consumer owns a globally spendable quota. If two processes both receive the same valid DTO, both can reproduce remaining=2. Nothing in this one-realm fixture arbitrates which one may spend shared production capacity.

The admissions array records {case_id: 'C08', mode: 'REHYDRATED', remaining: 1} after the tested reservation. That array is only a local model of acceptance events. It is not a durable commit log, queue acknowledgment, transactional outbox, or authorization ledger.

Accept a plain record when that is the actual contract

C14 demonstrates the equally valid alternative: do not reconstruct a class when the consumer only needs data. The fixture clones original.toDTO(), validates it through readDTO(), and returns the normalized plain record. The result has Object.prototype, is not a WorkQuota, carries remaining: 2, and causes zero constructor calls.

That is ACCEPT_DATA_ONLY, not a degraded ACCEPT_REHYDRATED. A record-oriented consumer may only need to inspect taskId, compare remaining, log a normalized state snapshot, or make a decision that does not invoke class behavior. For that contract, preserving a class prototype would add no value.

The decision boundary is simple: if the consumer calls class behavior whose correctness depends on class state, rehydrate from complete approved state; if it only consumes validated data, keep data as data. Do not force every structured clone through a constructor just to make instanceof green.

This also keeps ownership clear. readDTO() validates the declared shape and local invariants. rehydrate() is the explicit operation that crosses from data into class state. Reviewers can reason about those two boundaries separately rather than letting an implicit conversion blur them.

Recover from the right source and assign ownership

When a consumer discovers C02-, C04-, or C07-style evidence in a real backend path, recovery should begin from an approved source, not from the damaged representation. Preserve the rejected clone and its observations for debugging. Obtain a complete versioned DTO from the component that owns the state export, validate it, construct a fresh candidate, and rerun the acceptance predicates before replacing the application's reference.

A practical recovery sequence is:

  1. Quarantine the candidate that failed the contract; do not mutate it into appearing acceptable.

  2. Retain the source digest, runtime, process status, report, and failed case observations.

  3. Request or read the approved complete state record, including remaining.

  4. Validate the DTO before any constructor call.

  5. Rehydrate a fresh WorkQuota only if class behavior is required.

  6. Verify the exact initial state and next permitted local transition.

  7. Replace the consumer's reference only after that local acceptance passes; if authoritative state is unavailable, HOLD.

This fixture does not test durable publication, rollback of business effects, queue redelivery, database transactions, or repair after actual quota spending. Therefore “replace the reference” means a local application step, not a claim about transactional recovery. Broader backend testing and debugging practices are the appropriate place to connect such a fixture with surrounding service tests and operational diagnostics.

Ownership should be explicit. The producer owns the versioned export schema and the meaning of its state fields. The consumer owns its admission contract: whether it needs data only or stateful behavior, which versions it supports, and which next transition it will verify. QA or the reviewing team owns the fixture's reproducibility: source digest, runtime metadata, complete case manifest, process exit status, and retained report.

Reopen qualification when the contract changes

A new private field, a changed constructor default, a new DTO version, a different consumer method, or a runtime change can invalidate assumptions even when old tests still look familiar. Reopen qualification when any of those contract surfaces changes, and add evidence for the new state transition rather than relying on the old PASS_14_CASES string.

Keep two questions separate during that review. Data recovery asks whether the values required for reconstruction are available and validated. Authority asks whether this consumer is permitted to act on those values now. The fixture addresses the first locally; it does not establish freshness, exclusivity, or authority for shared production state.

Build stronger backend judgment through practical study

The reusable skill in this exercise is not memorizing that structuredClone drops a class prototype. It is learning to define the receiver's contract and collect evidence for each independent part: public data, prototype-based behavior, private-element presence, constructor execution, historical state, and the next state transition. That is the difference between repairing an object until it passes one convenient predicate and proving that it satisfies the bounded behavior the consumer actually needs.

The Backend Development program at Refonte Learning lists a three-month schedule at 10 to 12 hours per week and covers Node.js/Express, MongoDB and SQL, APIs, authentication, testing and debugging, Docker/cloud deployment, and capstone work. The page also describes practical projects and virtual internships, with internship availability qualified in its benefit language; successful completion is described as earning training and internship certificates. Those verified foundations align naturally with the habit this fixture exercises: make backend state boundaries explicit, test negative controls, and retain enough evidence to explain why an object is accepted, rejected, rehydrated, or held.