js-dojo

2 READ walks the chain, WRITE shadows

GOAL

Notice the asymmetry: reading a property climbs the [[Prototype]] chain until something answers; assigning a property (almost) always lands on the instance itself, leaving a SHADOW over the inherited value.

CONCEPT

`rogue.speed` finds the shared default sitting on the prototype. `knight.speed = 3` creates an own prop on `knight` — the prototype never notices. But `this.buffs.push(x)` is a READ (climb, find the shared array) followed by a MUTATION of whatever the read found — the number-one way one player's state bleeds into every player in the match.

HINT

Object.hasOwn(obj, key) reveals where a property physically lives. `delete` only removes OWN properties — removing a shadow re-exposes the inherited value that was hiding underneath.

MIRRORS

UI toolkits and game engines that keep default settings on a shared prototype and let each instance override them; and the classic production bug of one mutable defaults object/array reused across several instances, so buffing entity A silently buffs entity B.

Run

node 02-read-walks-write-shadows.cjs

Source

'use strict';
// ---------------------------------------------------------------------
// PROTO-DOJO · Module 1 / Exercise 2 — READ walks the chain, WRITE
// shadows
//
// GOAL:    Notice the asymmetry: reading a property climbs the
//          [[Prototype]] chain until something answers; assigning a
//          property (almost) always lands on the instance itself,
//          leaving a SHADOW over the inherited value.
// CONCEPT: `rogue.speed` finds the shared default sitting on the
//          prototype. `knight.speed = 3` creates an own prop on
//          `knight` — the prototype never notices. But
//          `this.buffs.push(x)` is a READ (climb, find the shared
//          array) followed by a MUTATION of whatever the read found —
//          the number-one way one player's state bleeds into every
//          player in the match.
// HINT:    Object.hasOwn(obj, key) reveals where a property physically
//          lives. `delete` only removes OWN properties — removing a
//          shadow re-exposes the inherited value that was hiding
//          underneath.
// MIRRORS: UI toolkits and game engines that keep default settings on a
//          shared prototype and let each instance override them; and
//          the classic production bug of one mutable defaults
//          object/array reused across several instances, so buffing
//          entity A silently buffs entity B.
//
// Run: node 02-read-walks-write-shadows.cjs
// ---------------------------------------------------------------------
const assert = require('node:assert');

function Player(name) {
  this.name = name;
}
Player.prototype.speed = 1;  // shared default, like the engine's defaults.speed
Player.prototype.buffs = []; // ⚠ shared MUTABLE default — trouble incoming
Player.prototype.addBuff = function (label) {
  // -- TODO 3 -- `this.buffs` READS up the chain, finds the PROTOTYPE's
  // array, and mutates it — every player in the match gets the buff.
  // Give `this` its own array on first write (shadow it), without
  // changing any caller and without touching the constructor.
  this.buffs.push(label);
};

const knight = new Player('knight');
const rogue = new Player('rogue');

knight.speed = 3; // WRITE: shadows — own prop on `knight`, prototype untouched

// -- TODO 1: predict — replace every -1 / '???' with the actual value
// ---
const afterWrite = {
  knightSpeed: -1,          // knight.speed ?
  rogueSpeed: -1,           // rogue.speed ?
  knightHasOwnSpeed: '???', // Object.hasOwn(knight, 'speed') ? (true/false)
  rogueHasOwnSpeed: '???',  // Object.hasOwn(rogue, 'speed') ?
  protoSpeed: -1,           // Player.prototype.speed ?
};

assert.strictEqual(afterWrite.knightSpeed, knight.speed,
  'TODO 1: WRITE shadows — the own property on knight wins over the inherited default');
assert.strictEqual(afterWrite.rogueSpeed, rogue.speed,
  'TODO 1: rogue has no own speed — READ walks up to the prototype');
assert.strictEqual(afterWrite.knightHasOwnSpeed, Object.hasOwn(knight, 'speed'),
  'TODO 1: where did the write physically land?');
assert.strictEqual(afterWrite.rogueHasOwnSpeed, Object.hasOwn(rogue, 'speed'),
  'TODO 1: reading NEVER creates an own property');
assert.strictEqual(afterWrite.protoSpeed, Player.prototype.speed,
  'TODO 1: assigning to knight.speed must not touch the shared default');

delete knight.speed; // remove the shadow

// -- TODO 2 -- with the shadow deleted, what does knight.speed read as now? ---
const afterDelete = -1;
assert.strictEqual(afterDelete, knight.speed,
  'TODO 2: delete removed the OWN prop — the READ falls through to the prototype again');

// -- the shared-array trap --------------------------------------------
knight.addBuff('haste potion');

assert.deepStrictEqual(
  rogue.buffs,
  [],
  'TODO 3: buffing the knight contaminated the rogue — addBuff mutated the ' +
    "PROTOTYPE's shared array. Shadow first (give `this` its own array), then push."
);
assert.deepStrictEqual(knight.buffs, ['haste potion'], 'knight keeps its own buff');
assert.deepStrictEqual(Player.prototype.buffs, [], 'the shared default array must stay pristine');
assert.ok(Object.hasOwn(knight, 'buffs'), 'after the first addBuff, knight should OWN its buffs array');
assert.ok(!Object.hasOwn(rogue, 'buffs'), 'rogue never got buffed — it should still inherit the empty default');

console.log('PASS — 02-read-walks-write-shadows: reads climb, writes shadow, shared mutables bite');

Solution