4 nonenumerable helper
GOAL
Bolt a `debugDump()` helper onto a settings object owned by the third-party `engine` so that for-in merge loops, Object.keys, JSON save files and spread-copies never notice it — while anyone who knows its name can still call it.
CONCEPT
Assigning `obj.helper = fn` creates the property with enumerable: true, so it walks straight into for-in, Object.keys, JSON.stringify and {...spread}. Object.defineProperty starts every flag at false; `enumerable: false` hides the property from all the enumeration protocols while leaving it perfectly readable and callable by name. (Class methods stay out of Object.keys for the very same reason — the class syntax defines them non-enumerably, as you saw in module 03, exercise 01.)
HINT
Object.defineProperty(settings, 'debugDump', { value: fn, enumerable: false, writable: true, configurable: true }).
MIRRORS
Game engines and UI libraries merge settings objects by walking their enumerable keys, and save systems serialise them with JSON.stringify. Hang one enumerable helper on such an object and it lands in the save file, confuses the defaults merge, and resurfaces in every cloned copy.
Run
node 04-nonenumerable-helper.cjsSource
// ---------------------------------------------------------------------
// 04-patching-foreign-code / 04-nonenumerable-helper.cjs
//
// GOAL: Bolt a `debugDump()` helper onto a settings object owned by the
// third-party `engine` so that for-in merge loops, Object.keys,
// JSON save files and spread-copies never notice it — while
// anyone who knows its name can still call it.
//
// CONCEPT: Assigning `obj.helper = fn` creates the property with
// enumerable: true, so it walks straight into for-in,
// Object.keys, JSON.stringify and {...spread}.
// Object.defineProperty starts every flag at false;
// `enumerable: false` hides the property from all the
// enumeration protocols while leaving it perfectly readable
// and callable by name. (Class methods stay out of Object.keys
// for the very same reason — the class syntax defines them
// non-enumerably, as you saw in module 03, exercise 01.)
//
// HINT: Object.defineProperty(settings, 'debugDump', { value: fn,
// enumerable: false, writable: true, configurable: true }).
//
// MIRRORS: Game engines and UI libraries merge settings objects by
// walking their enumerable keys, and save systems serialise
// them with JSON.stringify. Hang one enumerable helper on such
// an object and it lands in the save file, confuses the
// defaults merge, and resurfaces in every cloned copy.
//
// Run: node 04-nonenumerable-helper.cjs
// ---------------------------------------------------------------------
'use strict';
const assert = require('node:assert');
// -- Foreign settings object (shape is fixed by the save-file contract) ---
function makeSettings() {
return {
hero: 'knight',
levels: [3, 7, 12],
};
}
// -- TODO 1: attach the helper invisibly ------------------------------
// The naive version below "works" — and then leaks into every
// enumeration.
// Replace the assignment with a NON-ENUMERABLE definition. Keep it writable
// and configurable (you'll want to remove/replace it in tests).
function attachDebugHelper(settings) {
settings.debugDump = function () { // ← the leak
return `${this.hero} @ ${this.levels.join('/')}`;
};
return settings;
}
// -- Checks (do not edit) ---------------------------------------------
const settings = attachDebugHelper(makeSettings());
// It must WORK:
assert.strictEqual(typeof settings.debugDump, 'function',
'TODO: the helper must exist and be callable — non-enumerable does not ' +
'mean gone');
assert.strictEqual(settings.debugDump(), 'knight @ 3/7/12',
'TODO: the helper reads `this` normally — property flags change ' +
'visibility, never call semantics');
// It must be INVISIBLE to every enumeration protocol:
assert.deepStrictEqual(Object.keys(settings), ['hero', 'levels'],
'TODO: Object.keys must not see the helper — use Object.defineProperty ' +
'with enumerable: false (plain assignment hardcodes enumerable: true)');
const forInKeys = [];
for (const k in settings) forInKeys.push(k);
assert.deepStrictEqual(forInKeys, ['hero', 'levels'],
'TODO: for-in must not see it — this is the loop the engine\'s defaults ' +
'merge runs');
assert.strictEqual(JSON.stringify(settings),
'{"hero":"knight","levels":[3,7,12]}',
'TODO: JSON.stringify must not see it — this exact string is the save ' +
'file the engine expects');
const copy = { ...settings };
assert.ok(!('debugDump' in copy),
'TODO: spread only copies ENUMERABLE own props — a non-enumerable helper ' +
'stays behind instead of infecting every cloned settings object');
// And it must still be an OWN, discoverable, removable property:
const desc = Object.getOwnPropertyDescriptor(settings, 'debugDump');
assert.ok(desc && desc.enumerable === false,
'TODO: the descriptor tells the story: value present, enumerable: false');
assert.strictEqual(desc.writable, true,
'TODO: keep writable: true — tests may stub the helper');
assert.strictEqual(desc.configurable, true,
'TODO: keep configurable: true — so it can be deleted/redefined later');
console.log('OK — 04-nonenumerable-helper: present for you, absent for every loop.');