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.cjsSource
'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');