js-dojo

1 desugar the suit

GOAL

Convince yourself that `class` is nothing but prototypes in formal wear: rewrite `PlayerClass` by hand as `PlayerDesugared` (a plain constructor function plus some prototype surgery) until ONE shared contract-check passes for both of them.

CONCEPT

A class declaration is a bundle of separately-writable parts: a constructor function, methods installed as NON-ENUMERABLE properties of Ctor.prototype, accessors as getters on that same prototype, statics as own properties of the constructor, an automatic `prototype.constructor` back-link, and a "refuse to run without new" guard. None of it is magic — game engines and UI toolkits shipped for years in precisely this hand-rolled shape (`var Player = function () {...}; extend(Player.prototype, {...})`).

HINT

- Method & getter: `Object.defineProperties(PlayerDesugared.prototype, {...})`. defineProperty defaults to `enumerable: false`; plain assignment (`proto.m = fn`) does NOT — it creates enumerable properties. - The "called without new" guard: test `new.target === undefined`. - Statics are just properties hung on the function object itself.

MIRRORS

Open the source of any engine or widget library that predates ES2015: `var Entity = function () {...}; extend(Entity.prototype, { spawn, update, render, ... })`. The `class` you write today is that same machine with a nicer coat on.

Run

node 01-desugar-the-suit.cjs

Source

// ---------------------------------------------------------------------
// 03-classes-desugared / 01-desugar-the-suit.cjs
//
// GOAL: Convince yourself that `class` is nothing but prototypes in
//       formal wear: rewrite `PlayerClass` by hand as `PlayerDesugared`
//       (a plain constructor function plus some prototype surgery)
//       until ONE shared contract-check passes for both of them.
//
// CONCEPT: A class declaration is a bundle of separately-writable
//          parts: a constructor function, methods installed as
//          NON-ENUMERABLE properties of Ctor.prototype, accessors as
//          getters on that same prototype, statics as own properties of
//          the constructor, an automatic `prototype.constructor`
//          back-link, and a "refuse to run without new" guard. None of
//          it is magic — game engines and UI toolkits shipped for years
//          in precisely this hand-rolled shape (`var Player = function
//          () {...}; extend(Player.prototype, {...})`).
//
// HINT: - Method & getter:
//         `Object.defineProperties(PlayerDesugared.prototype, {...})`.
//         defineProperty defaults to `enumerable: false`; plain
//         assignment (`proto.m = fn`) does NOT — it creates enumerable
//         properties.
//       - The "called without new" guard: test `new.target ===
//         undefined`.
//       - Statics are just properties hung on the function object
//         itself.
//
// MIRRORS: Open the source of any engine or widget library that
//          predates ES2015: `var Entity = function () {...};
//          extend(Entity.prototype, { spawn, update, render, ... })`.
//          The `class` you write today is that same machine with a
//          nicer coat on.
//
// Run: node 01-desugar-the-suit.cjs
// ---------------------------------------------------------------------
'use strict';
const assert = require('node:assert');

// -- Reference implementation (do not edit) ---------------------------
class PlayerClass {
  constructor(name, hits) {
    this.name = name;
    this.hits = hits.slice();
  }
  loadHits(hits) {
    this.hits = hits.slice();
    return this;
  }
  get hitCount() {
    return this.hits.length;
  }
  static fromEvents(name, events) {
    return new this(name, events.map((e) => e.damage));
  }
}

// -- YOUR DESUGARED VERSION -------------------------------------------
// TODO 1: make the constructor copy `name` and `hits` onto the instance,
//         and throw a TypeError when called WITHOUT `new` (hint: new.target).
function PlayerDesugared(name, hits) {
  // ...
}

// TODO 2: install `loadHits` on PlayerDesugared.prototype as a NON-ENUMERABLE
//         method (class methods never show up in Object.keys / for-in).

// TODO 3: install the `hitCount` GETTER on the prototype (also
// non-enumerable).

// TODO 4: install the static `fromEvents` on the constructor function itself.
//         (Note the reference class says `new this(...)` — keep that spirit.)

// -- Shared contract check (do not edit) ------------------------------
function verifyPlayerContract(Ctor, label) {
  const p = new Ctor('knight', [12, 7, 30]);

  assert.strictEqual(p.name, 'knight',
    `${label}: TODO 1 — the constructor must copy \`name\` onto the instance`);
  assert.deepStrictEqual(p.hits, [12, 7, 30],
    `${label}: TODO 1 — the constructor must copy \`hits\` onto the instance`);
  assert.ok(p instanceof Ctor,
    `${label}: instances must satisfy instanceof`);
  assert.strictEqual(Object.getPrototypeOf(p), Ctor.prototype,
    `${label}: instanceof works because the instance's [[Prototype]] IS Ctor.prototype`);

  assert.strictEqual(typeof p.loadHits, 'function',
    `${label}: TODO 2 — loadHits should be reachable through the prototype chain`);
  assert.ok(!Object.prototype.hasOwnProperty.call(p, 'loadHits'),
    `${label}: TODO 2 — loadHits must live on the PROTOTYPE, not on each instance`);
  const q = new Ctor('rogue', []);
  assert.strictEqual(p.loadHits, q.loadHits,
    `${label}: TODO 2 — one function object shared by every instance`);
  p.loadHits([5, 9]);
  assert.deepStrictEqual(p.hits, [5, 9],
    `${label}: TODO 2 — loadHits must operate on \`this\``);

  assert.strictEqual(p.hitCount, 2,
    `${label}: TODO 3 — hitCount is a getter derived from this.hits`);
  assert.ok(!Object.prototype.hasOwnProperty.call(p, 'hitCount'),
    `${label}: TODO 3 — the getter lives on the prototype, not the instance`);

  assert.strictEqual(Ctor.prototype.constructor, Ctor,
    `${label}: prototype.constructor must point back at the constructor ` +
    `(if you replaced the whole prototype object, you broke this back-link)`);
  assert.deepStrictEqual(Object.keys(Ctor.prototype), [],
    `${label}: class methods are NON-ENUMERABLE — plain assignment ` +
    `(proto.m = fn) makes them enumerable; use Object.defineProperty`);

  assert.throws(() => Ctor('mage', []), TypeError,
    `${label}: TODO 1 — calling without \`new\` must throw a TypeError, ` +
    `like class constructors do (check new.target)`);

  assert.strictEqual(typeof Ctor.fromEvents, 'function',
    `${label}: TODO 4 — statics are plain properties of the constructor function`);
  const f = Ctor.fromEvents('paladin', [{ at: 40, damage: 18 }, { at: 55, damage: 25 }]);
  assert.ok(f instanceof Ctor,
    `${label}: TODO 4 — fromEvents should construct via \`new this(...)\``);
  assert.deepStrictEqual(f.hits, [18, 25],
    `${label}: TODO 4 — fromEvents maps incoming events to their damage`);
}

verifyPlayerContract(PlayerClass, 'PlayerClass (reference)');
verifyPlayerContract(PlayerDesugared, 'PlayerDesugared (yours)');

console.log('OK — 01-desugar-the-suit: the suit came off and nothing changed.');

Solution