5 typeof guard misspelled api
GOAL
Recreate — then properly defend against — the shipped bug where a hook a plugin had bolted onto the engine was consumed under a MISSPELLED name (`onLevelComplete` vs `onLevelCompleted`): the types were happy, the property was undefined at runtime, and a soft `if (handler)` guard turned a hard bug into hits that quietly never landed.
CONCEPT
Bolted-on APIs sit outside what the compiler can verify: a merged declaration or an `any`-typed name passes the type checker whether or not the runtime property is really there. Reading a missing property does not throw — it yields `undefined` — so the only honest runtime contract is an explicit typeof guard that fails LOUDLY, ideally listing near-miss names found on the prototype chain (that is what shrinks a day of debugging to a ten-second fix).
HINT
getAugmented: read obj[name]; when typeof is not 'function', build an Error whose message carries the missing name AND nearMisses(obj, name) — then route the consumer's call THROUGH getAugmented using the correct spelling.
MIRRORS
Every extensible runtime has this trap: an engine plugin installs a hook on a shared prototype, the app declares that hook's type by hand, and one day a caller reaches for `game.onLevelComplete` — one letter short of the real `onLevelCompleted`. The compiler is satisfied, the value is undefined, an `if (handler)` skips the call, and the scoreboard simply stops moving — no error, no stack trace, no hits recorded.
Run
node 05-typeof-guard-misspelled-api.cjsSource
// ---------------------------------------------------------------------
// 04-patching-foreign-code / 05-typeof-guard-misspelled-api.cjs
//
// GOAL: Recreate — then properly defend against — the shipped bug where
// a hook a plugin had bolted onto the engine was consumed under a
// MISSPELLED name (`onLevelComplete` vs `onLevelCompleted`): the
// types were happy, the property was undefined at runtime, and a
// soft `if (handler)` guard turned a hard bug into hits that
// quietly never landed.
//
// CONCEPT: Bolted-on APIs sit outside what the compiler can verify: a
// merged declaration or an `any`-typed name passes the type
// checker whether or not the runtime property is really there.
// Reading a missing property does not throw — it yields
// `undefined` — so the only honest runtime contract is an
// explicit typeof guard that fails LOUDLY, ideally listing
// near-miss names found on the prototype chain (that is what
// shrinks a day of debugging to a ten-second fix).
//
// HINT: getAugmented: read obj[name]; when typeof is not 'function',
// build an Error whose message carries the missing name AND
// nearMisses(obj, name) — then route the consumer's call THROUGH
// getAugmented using the correct spelling.
//
// MIRRORS: Every extensible runtime has this trap: an engine plugin
// installs a hook on a shared prototype, the app declares that
// hook's type by hand, and one day a caller reaches for
// `game.onLevelComplete` — one letter short of the real
// `onLevelCompleted`. The compiler is satisfied, the value is
// undefined, an `if (handler)` skips the call, and the
// scoreboard simply stops moving — no error, no stack trace,
// no hits recorded.
// Run: node 05-typeof-guard-misspelled-api.cjs
// ---------------------------------------------------------------------
'use strict';
const assert = require('node:assert');
// -- Third-party engine + plugin module (do not edit) -----------------
class Game {
constructor(id, damageLog) {
this.id = id;
this.players = [{ hits: damageLog.slice() }]; // players[0] is the hero
}
}
// The plugin — note the REAL name carries that awkward trailing '-ed':
Game.prototype.onLevelCompleted = function (hits) {
const log = this.players[0].hits;
hits.forEach((h) => log.push(h.damage));
this.lastFlush = hits.length;
return log.length;
};
// A game type the plugin module was never loaded for:
class VanillaGame {
constructor(id, damageLog) {
this.id = id;
this.players = [{ hits: damageLog.slice() }];
}
}
// Near-miss finder (do not edit) — walks the prototype chain and
// returns
// function-valued names within edit-distance 2 of the requested one.
function nearMisses(obj, name) {
const names = new Set();
for (let o = obj; o && o !== Object.prototype; o = Object.getPrototypeOf(o)) {
for (const k of Object.getOwnPropertyNames(o)) {
if (typeof obj[k] === 'function') names.add(k);
}
}
const dist = (a, b) => {
const m = Array.from({ length: a.length + 1 }, (_, i) => [i]);
for (let j = 1; j <= b.length; j++) m[0][j] = j;
for (let i = 1; i <= a.length; i++) {
for (let j = 1; j <= b.length; j++) {
m[i][j] = Math.min(m[i - 1][j] + 1, m[i][j - 1] + 1,
m[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
}
}
return m[a.length][b.length];
};
return [...names].filter((k) => k !== name && dist(k, name) <= 2);
}
// -- TODO 1: the loud typeof guard ------------------------------------
// Return obj[name] if it is a function. Otherwise THROW an Error whose
// message contains BOTH the missing name and any near-miss suggestions
// (use the provided nearMisses helper). Never return undefined.
function getAugmented(obj, name) {
return obj[name]; // ← the soft path: undefined leaks out
}
// -- TODO 2: fix the consumer -----------------------------------------
// This is the shipped bug, as written: misspelled name + soft guard = silent
// no-op. Route the call through getAugmented with the CORRECT spelling so a
// missing plugin hook can never again fail silently.
function flushHits(game, hits) {
const handler = game.onLevelComplete; // ← one letter short; the types were happy
if (handler) return handler.call(game, hits);
return game.players[0].hits.length; // "nothing was pending" — a lie
}
// -- Checks (do not edit) ---------------------------------------------
// The trap is real: the misspelled name passes the type checker but is undefined here.
assert.strictEqual(typeof Game.prototype.onLevelComplete, 'undefined',
'sanity: the misspelled name does not exist at runtime');
assert.strictEqual(typeof Game.prototype.onLevelCompleted, 'function',
'sanity: the plugin installed the real (awkwardly named) hook');
const arena = new Game('match-1', [12, 8]);
// 1) The guard must be LOUD on a missing name… (word boundary: the
// misspelling is a prefix of the real name, so the message must contain
// it as a key of its own, not just inside the hint)
assert.throws(
() => getAugmented(arena, 'onLevelComplete'),
(err) => err instanceof Error &&
/\bonLevelComplete\b/.test(err.message) &&
err.message.includes('onLevelCompleted'),
'TODO 1: getAugmented must THROW for a missing plugin hook — and the ' +
'message must name the missing key AND suggest the near-miss ' +
'(onLevelCompleted). Returning undefined is exactly how the real bug shipped');
// …and transparent on a present one:
assert.strictEqual(getAugmented(arena, 'onLevelCompleted'),
Game.prototype.onLevelCompleted,
'TODO 1: when the hook exists, hand back the function itself, untouched');
// 2) The consumer actually flushes now:
const newLen = flushHits(arena, [{ damage: 15 }, { damage: 7 }]);
assert.strictEqual(newLen, 4,
'TODO 2: flushHits must reach the REAL hook (correct spelling, via ' +
'getAugmented) — 2 old + 2 new hits = 4');
assert.deepStrictEqual(arena.players[0].hits, [12, 8, 15, 7],
'TODO 2: the hits must land in the hero\'s log — silent-skip means frozen ' +
'scoreboards in prod and zero errors in the crash reporter');
assert.strictEqual(arena.lastFlush, 2,
'TODO 2: the hook ran with the game as `this`');
// 3) And when the plugin module genuinely is not loaded, the consumer
// fails LOUDLY instead of pretending nothing was pending:
const bare = new VanillaGame('match-2', [30]);
assert.throws(
() => flushHits(bare, [{ damage: 5 }]),
/onLevelCompleted/,
'TODO 2: a game without the plugin must make flushHits THROW ' +
'(naming the missing hook) — that error in staging is the alarm the ' +
'original silent skip never raised');
assert.deepStrictEqual(bare.players[0].hits, [30],
'sanity: the vanilla game was not half-mutated on the way to the throw');
console.log('OK — 05-typeof-guard-misspelled-api: undefined is not a handler; fail loud, fail early.');