js-dojo

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.cjs

Source

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

Solution