LEVEL 4 · LESSON 10 OF 17

Iteration and symbols Core

Symbols

Use symbols as keys that never clash, plug your objects into the language with well-known symbols, and build a Money type that refuses to add naira to dollars.

  • 45 min to read and try
  • You need: Types in depth, Objects in depth, Prototypes in depth and Iterables and iterators
  • You build: A Money type that formats itself in text, refuses silent arithmetic and mixed currencies, labels itself in logs, and a redaction protocol that keeps card numbers out of logs
Test yourself

BY THE END OF THIS LESSON YOU CAN

  • Explain why symbols exist and how symbol keys behave with Object.keys, JSON, spread and Reflect.ownKeys
  • Choose between Symbol() and Symbol.for(), and explain what breaks when a package is loaded twice
  • Control conversion with Symbol.toPrimitive and predict the hint for any operation
  • Use Symbol.toStringTag, Symbol.iterator, Symbol.asyncIterator, Symbol.hasInstance and Symbol.dispose where they fit
  • Design and test a custom protocol keyed by a symbol

An invoice that added dollars to naira

A shop sells to customers in Nigeria and abroad. Amounts are stored as whole minor units (kobo, cents) with a currency code, a good habit from Mathematical reasoning. Someone gave the Money class a valueOf method, so that comparisons like price > 0 would "just work", as you saw in Types in depth:

problem.js
class Money {
  constructor(minor, currency) {
    this.minor = minor;
    this.currency = currency;
  }
  valueOf() {
    return this.minor;
  }
}

const subtotal = new Money(1500000, "NGN");
const shipping = new Money(2500, "USD");

const total = subtotal + shipping;
console.log("total:", total);
console.log("Receipt: " + subtotal);
console.log(`Receipt: ${subtotal}`);
console.log(subtotal > shipping);
Output of node problem.js and of the browser terminal
total: 1502500
Receipt: 1500000
Receipt: [object Object]
true

Every line is wrong, and none of them threw an error:

  1. ₦15,000 plus $25 became the plain number 1502500. The currencies were thrown away and the sum is meaningless, yet it would happily be charged.
  2. "Receipt: " + subtotal printed raw kobo.
  3. The template literal printed [object Object], because it asked for text and valueOf is not consulted first for text.
  4. subtotal > shipping compared kobo with cents.

What you want is precise control: formatted text in receipts, an error on accidental arithmetic, and a readable label in logs. JavaScript lets objects control exactly these things through hooks: methods the language itself calls at particular moments. Those hooks are not stored under names like "toPrimitive", but under symbols. This lesson explains why, shows the hooks you will actually use, and ends with a Money type that cannot be misused this way.

Why symbols: keys that cannot clash

Types in depth introduced the symbol: a primitive value that is equal only to itself. Symbol("id") === Symbol("id") is false; the text is only a description for people reading logs. Symbols can be property keys, just like strings.

Now imagine JavaScript had added the conversion hook as a normal method called toPrimitive. Millions of existing objects on the web might already have a property with that name, doing something else. The day browsers shipped the feature, those objects would suddenly behave differently. The language needed keys that no existing code could possibly be using. A new symbol is such a key: nobody can type it, so nobody can have used it by accident.

The same need appears in your own code. A small auditing helper wants to attach "when did we last see this object" to orders, without any chance of overwriting a field the order already has:

no-clash.js
const seenAt = Symbol("seenAt");

function markSeen(record, when) {
  record[seenAt] = when;
  return record;
}

const order = { id: "ORD-7", seenAt: "customer field, not ours", totalKobo: 450000 };
markSeen(order, "2026-09-24");

console.log(order.seenAt);
console.log(order[seenAt]);
console.log(seenAt.description, String(seenAt));
Output of node no-clash.js and of the browser terminal
customer field, not ours
2026-09-24
seenAt Symbol(seenAt)

The order's own seenAt string property and the helper's seenAt symbol property live side by side. Only code that holds the symbol value can read or write the helper's property.

Who sees symbol keys

Symbol-keyed properties are skipped by the tools that list "ordinary" data, and kept by the tools that copy objects. Knowing which is which prevents surprises:

who-sees.js
const internalId = Symbol("internalId");
const order = { id: "ORD-7", totalKobo: 450000, [internalId]: 99812 };

console.log(Object.keys(order));
console.log(JSON.stringify(order));
for (const key in order) console.log("for...in:", key);

console.log(Object.getOwnPropertySymbols(order));
console.log(Reflect.ownKeys(order));

const copy = { ...order };
console.log(copy[internalId], Object.assign({}, order)[internalId]);
console.log(structuredClone(order)[internalId]);
Output of node who-sees.js and of the browser terminal
[ 'id', 'totalKobo' ]
{"id":"ORD-7","totalKobo":450000}
for...in: id
for...in: totalKobo
[ Symbol(internalId) ]
[ 'id', 'totalKobo', Symbol(internalId) ]
99812 99812
undefined
Skips symbol keysIncludes symbol keys
Object.keys, Object.values, Object.entries, for...in, JSON.stringify, structuredCloneObject.getOwnPropertySymbols, Reflect.ownKeys, spread { ...obj }, Object.assign

Two consequences. Symbol keys do not travel: they disappear when an object is sent as JSON or cloned, which is right for internal metadata. And symbol keys are not private: anyone can list them with Object.getOwnPropertySymbols. They prevent accidental clashes, not deliberate access. For real privacy, use a class's #private fields or a closure (Closures in depth).

Symbol() and Symbol.for(): local and shared symbols

Symbol("x") creates a brand-new symbol every call. To share it, you must pass the value around, usually by exporting it from a module. Symbol.for("x") works differently: it looks the name up in a global symbol registry that the whole JavaScript runtime shares, creating the symbol the first time and returning the same one every time after. Symbol.keyFor(sym) tells you a registered symbol's name:

registry.js
const local1 = Symbol("shop.plugin");
const local2 = Symbol("shop.plugin");
const shared1 = Symbol.for("shop.plugin");
const shared2 = Symbol.for("shop.plugin");

console.log(local1 === local2, shared1 === shared2, local1 === shared1);
console.log(Symbol.keyFor(shared1), Symbol.keyFor(local1));
Output of node registry.js and of the browser terminal
false true false
shop.plugin undefined

When the difference bites: the same package, loaded twice

A package can end up in node_modules twice, in two versions: your app depends on payments@2, and a plugin you installed depends on payments@1. Each copy is a separate module with its own top-level variables. If the package marks its objects with a local Symbol(), an object made by one copy is not recognised by the other. Here the two copies are simulated by calling a factory twice:

two-copies.js
function loadPaymentsPackage() {
  const localBrand = Symbol("payments.Charge");
  const sharedBrand = Symbol.for("payments.Charge");
  return {
    createCharge: (kobo) => ({ kobo, [localBrand]: true, [sharedBrand]: true }),
    isChargeLocal: (value) => value?.[localBrand] === true,
    isChargeShared: (value) => value?.[sharedBrand] === true,
  };
}

const paymentsV2 = loadPaymentsPackage();
const paymentsV1 = loadPaymentsPackage();

const charge = paymentsV1.createCharge(450000);
console.log("local brand check:", paymentsV2.isChargeLocal(charge));
console.log("shared brand check:", paymentsV2.isChargeShared(charge));
Output of node two-copies.js and of the browser terminal
local brand check: false
shared brand check: true

The check built on a local symbol failed across copies; the one built on Symbol.for passed. That is the rule of thumb:

  • Use Symbol() for keys that belong to one module and should never be reachable from elsewhere.
  • Use Symbol.for("package.name") for a protocol that separately loaded code must agree on. Namespace the name ("payments.Charge", not "charge"), because the registry is shared by everything in the process. Node itself uses Symbol.for("nodejs.util.inspect.custom") this way.

Well-known symbols: the language's hooks

A well-known symbol is a symbol the language defines and stores as a property of Symbol, such as Symbol.iterator. When JavaScript needs to do something to an object, it checks whether the object has a method under the matching well-known symbol, and calls it if so. Here are the ones worth knowing:

SymbolCalled whenTypical use
Symbol.iteratorfor...of, spread, destructuringMake a collection iterable (Iterables and iterators)
Symbol.asyncIteratorfor await...ofStream pages, events or file chunks (Generators)
Symbol.toPrimitiveThe object must become a primitive: +, *, >, template literals, String(), Number()Money, durations, versions
Symbol.toStringTagObject.prototype.toStringA clear type label in logs and debugging
Symbol.hasInstancex instanceof CRare: customise instanceof
Symbol.dispose, Symbol.asyncDisposeLeaving a block that declared the object with using / await usingClose files, connections, locks
Symbol.species, Symbol.isConcatSpreadable, Symbol.match/replace/search/split, Symbol.unscopablesSubclassing built-ins, concat, string methods, withLibrary internals; you will rarely touch them

You have already implemented Symbol.iterator and Symbol.asyncIterator in the previous two lessons. The rest of this lesson covers the others you will meet in real code, starting with the one that fixes the invoice.

Symbol.toPrimitive: controlling conversion

When an operation needs a primitive and has an object, JavaScript first looks for a [Symbol.toPrimitive] method. If there is one, it is called with a hint, a string that says what kind of value the operation would like, and valueOf and toString are not consulted at all. The method must return a primitive (or throw). There are three hints:

  • "number": the operation wants a number: unary +, -, *, /, <, >, Number(), Math.max().
  • "string": the operation wants text: template literals, String(), using the object as a property key, join.
  • "default": the operation could go either way: binary + (add or join?) and ==.

A probe object that records every hint it receives shows which operation asks for what:

hints.js
const hints = [];
const probe = {
  [Symbol.toPrimitive](hint) {
    hints.push(hint);
    return 1;
  },
};

+probe;
probe * 2;
probe > 0;
`${probe}`;
String(probe);
probe + 1;
probe == 1;
({ [probe]: true });

console.log(hints.join(" "));
Output of node hints.js and of the browser terminal
number number number string string default default string

Now the problem is solvable, because each case can be handled on purpose. A good money type:

  • formats itself for the "string" hint, so receipts and logs read ₦15,000.00;
  • throws for "default" and "number", so subtotal + shipping and price * 2 fail loudly instead of producing a plausible wrong number;
  • offers explicit methods, plus, times, compare, that check currencies.
money-primitive.js
class Money {
  constructor(minor, currency) {
    this.minor = minor;
    this.currency = currency;
  }

  [Symbol.toPrimitive](hint) {
    if (hint === "string") {
      return new Intl.NumberFormat("en-NG", { style: "currency", currency: this.currency }).format(this.minor / 100);
    }
    throw new TypeError(`Money cannot be used as a ${hint} value; use plus(), times() or compare()`);
  }
}

const subtotal = new Money(1500000, "NGN");
const shipping = new Money(2500, "USD");

console.log(`Receipt: ${subtotal} + ${shipping}`);
console.log(String(subtotal));

for (const attempt of [() => subtotal + shipping, () => subtotal * 2, () => subtotal > shipping, () => "Total: " + subtotal]) {
  try {
    attempt();
  } catch (error) {
    console.log(`${error.name}: ${error.message}`);
  }
}
Output of node money-primitive.js and of the browser terminal
Receipt: ₦15,000.00 + US$25.00
₦15,000.00
TypeError: Money cannot be used as a default value; use plus(), times() or compare()
TypeError: Money cannot be used as a number value; use plus(), times() or compare()
TypeError: Money cannot be used as a number value; use plus(), times() or compare()
TypeError: Money cannot be used as a default value; use plus(), times() or compare()

Every silent bug from the first section is now an error with a message that says what to do instead. Note the last one: "Total: " + subtotal uses binary +, so the hint is "default", not "string", even though one side is text; JavaScript converts both operands before it decides between adding and joining. Write `Total: ${subtotal}` for text.

Why not just valueOf and toString?

valueOf cannot tell why it was called: a + b, a * 2 and a > b all look the same to it. So it can only allow all arithmetic or break comparisons too. Symbol.toPrimitive receives the hint, so it can allow text and refuse arithmetic. It also takes priority: when it exists, valueOf and toString are ignored for conversions.

A built-in example: Date

Date is the one built-in with its own Symbol.toPrimitive. It treats "default" like "string", which is why adding to a date joins text while subtracting dates gives milliseconds:

date-hint.js
const paidAt = new Date(Date.UTC(2026, 8, 24, 9, 30));
const dueAt = new Date(Date.UTC(2026, 8, 24, 10, 0));

console.log(typeof (paidAt + 1), typeof (paidAt - 1));
console.log((dueAt - paidAt) / 60000, "minutes early");
console.log(typeof paidAt[Symbol.toPrimitive]);
Output of node date-hint.js and of the browser terminal
string number
30 minutes early
function

Symbol.toStringTag: a label for your type

Object.prototype.toString produces the [object Object] text you keep meeting. It builds that label from the object's [Symbol.toStringTag] property if there is one. Built-ins already define it, which is why it is a reliable way to ask "what kind of built-in is this?". Your own classes can define it too, usually as a getter on the class:

tag.js
const tagOf = (value) => Object.prototype.toString.call(value);

console.log(tagOf([]), tagOf(new Map()), tagOf(null), tagOf(Promise.resolve()));

class Invoice {
  constructor(id) {
    this.id = id;
  }
  get [Symbol.toStringTag]() {
    return "Invoice";
  }
}

const invoice = new Invoice("INV-2026-00098");
console.log(tagOf(invoice));
console.log(tagOf({ id: "INV-1" }));
Output of node tag.js and of the browser terminal
[object Array] [object Map] [object Null] [object Promise]
[object Invoice]
[object Object]

It is a label, not a guarantee: any object can claim any tag, including "Array". Use it for readable logs and debugging, not for security or validation decisions. To check that a value really is an array, use Array.isArray; for your own classes, see the brand checks below.

Symbol.hasInstance and brand checks

x instanceof C normally walks x's prototype chain looking for C.prototype (Prototypes in depth). If C has a static [Symbol.hasInstance] method, instanceof calls that instead. That lets you describe an interface, "anything with a charge method", as something you can test with instanceof:

has-instance.js
class Payable {
  static [Symbol.hasInstance](value) {
    return typeof value?.charge === "function" && typeof value?.refund === "function";
  }
}

const card = { charge() {}, refund() {} };
const voucher = { charge() {} };

console.log(card instanceof Payable, voucher instanceof Payable, null instanceof Payable);
Output of node has-instance.js and of the browser terminal
true false false

It works, but it makes instanceof mean something different from what every reader expects, so most codebases avoid it. A plain function isPayable(value) says the same thing without surprising anyone.

Symbols are not a safe brand

A brand check answers "was this object really created by my class?". A symbol key looks like a good brand, but spread copies symbol keys, so a plain object can inherit the brand by accident, or on purpose. A #private field cannot be copied or forged, and #field in value checks for it without throwing:

brand.js
const moneyBrand = Symbol("Money");

class Money {
  #minor;
  constructor(minor) {
    this.#minor = minor;
    this[moneyBrand] = true;
  }
  static isMoney(value) {
    return typeof value === "object" && value !== null && #minor in value;
  }
}

const real = new Money(500);
const fake = { ...real, minor: 99999999 };

console.log("symbol brand:", real[moneyBrand], fake[moneyBrand]);
console.log("private brand:", Money.isMoney(real), Money.isMoney(fake));
Output of node brand.js and of the browser terminal
symbol brand: true true
private brand: true false

Use symbols to avoid clashes; use private fields to guarantee identity.

Symbol.dispose and using

In Generators, closing a cursor needed a try/finally. A newer language feature, explicit resource management, turns that pattern into a declaration. An object with a [Symbol.dispose]() method can be declared with using instead of const; when the block ends, however it ends, JavaScript calls the method. Several using declarations are disposed in reverse order, the way you would close nested resources by hand:

using.jsNode.js only
function openLock(seat) {
  console.log(`  lock ${seat}`);
  return {
    seat,
    [Symbol.dispose]() {
      console.log(`  unlock ${seat}`);
    },
  };
}

function bookSeats(first, second, fail) {
  using a = openLock(first);
  using b = openLock(second);
  if (fail) throw new Error(`payment failed for ${a.seat}+${b.seat}`);
  console.log("  booked");
}

console.log("success:");
bookSeats("12A", "12B", false);

console.log("failure:");
try {
  bookSeats("14A", "14B", true);
} catch (error) {
  console.log(" ", error.message);
}
Output of node using.js
success:
  lock 12A
  lock 12B
  booked
  unlock 12B
  unlock 12A
failure:
  lock 14A
  lock 14B
  unlock 14B
  unlock 14A
  payment failed for 14A+14B

using and Symbol.dispose work in Node.js 24 and current Chrome, but not yet in every browser (nor in this site's browser terminal, which is why the example above is marked Node.js only), and TypeScript needs a recent lib setting to know them. The idea to take away is the design: resource types advertise "this is how I am closed" through a well-known symbol, and the language calls it at the right moment. Symbol.asyncDispose with await using is the asynchronous twin, for closing database connections.

Custom protocols

The well-known symbols are protocols: agreements of the form "if your object has a method under this key, I will call it". You can define your own, for the same reason the language does: a symbol key cannot collide with the data your users put in their objects.

A real need: logs must never contain card numbers or passwords. A logger cannot know every type in the application, so it defines a protocol. Any object that implements [redact]() is asked for a safe version of itself before it is logged:

redact.js
export const redact = Symbol.for("shop.log.redact");

export function safeForLog(value, depth = 0) {
  if (depth > 5) return "[too deep]";
  if (value === null || typeof value !== "object") return value;
  if (typeof value[redact] === "function") return safeForLog(value[redact](), depth + 1);
  if (Array.isArray(value)) return value.map((item) => safeForLog(item, depth + 1));
  return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, safeForLog(item, depth + 1)]));
}
use-redact.js
import { redact, safeForLog } from "./redact.js";

class Card {
  constructor(number, holder) {
    this.number = number;
    this.holder = holder;
  }
  [redact]() {
    return { holder: this.holder, last4: this.number.slice(-4) };
  }
}

const payment = {
  orderId: "ORD-7",
  card: new Card("4111111111111111", "Ada Obi"),
  redact: "a field that happens to be called redact",
  attempts: [{ at: 1, card: new Card("5500000000000004", "Ada Obi") }],
};

console.log(JSON.stringify(safeForLog(payment), null, 2));
Output of node use-redact.js and of the browser terminal
{
  "orderId": "ORD-7",
  "card": {
    "holder": "Ada Obi",
    "last4": "1111"
  },
  "redact": "a field that happens to be called redact",
  "attempts": [
    {
      "at": 1,
      "card": {
        "holder": "Ada Obi",
        "last4": "0004"
      }
    }
  ]
}

The protocol has the properties a good protocol needs:

  • No clashes. The payment's own string field named redact was logged as ordinary data, because it is not the symbol.
  • Opt-in. The logger does not need a list of types; each type decides how it is shown.
  • Works across copies. It uses Symbol.for with a namespaced name, so a Card class from another package, or another copy of this one, can implement it without importing the logger.
  • Bounded. The depth limit stops a [redact]() that returns itself, or a circular structure, from recursing forever (Recursion).

Node's console.log has a protocol of exactly this shape: an object with a method under Symbol.for("nodejs.util.inspect.custom") decides how it is printed. It only affects Node's printing, so this example is Node-only:

inspect.jsNode.js only
const inspect = Symbol.for("nodejs.util.inspect.custom");

class Card {
  constructor(number) {
    this.number = number;
  }
  [inspect]() {
    return `Card(**** ${this.number.slice(-4)})`;
  }
}

console.log(new Card("4111111111111111"));
console.log({ paidWith: new Card("5500000000000004") });
Output of node inspect.js
Card(**** 1111)
{ paidWith: Card(**** 0004) }

WATCH OUT

A display hook only changes what one tool prints. JSON.stringify, error trackers and database logs still see the raw fields. Never rely on a hook for secrecy; keep secrets out of objects that get logged at all, or pass everything through one function like safeForLog.

When symbols trip you up

traps.js
const tag = Symbol("tag");

try {
  console.log("key: " + tag);
} catch (error) {
  console.log(`${error.name}: ${error.message}`);
}
console.log(`key: ${tag.description}`, `key: ${String(tag)}`);

try {
  new Symbol("tag");
} catch (error) {
  console.log(`${error.name}: ${error.message}`);
}

const product = { sku: "RICE-5", [tag]: "promo" };
const fromApi = JSON.parse(JSON.stringify(product));
console.log(fromApi[tag], Object.keys(fromApi));

const noDescription = Symbol();
console.log(noDescription.description, noDescription.toString());
Output of node traps.js and of the browser terminal
TypeError: Cannot convert a Symbol value to a string
key: tag key: Symbol(tag)
TypeError: Symbol is not a constructor
undefined [ 'sku' ]
undefined Symbol()
TrapWhyDo instead
"text" + sym throwsSymbols refuse implicit conversion to textsym.description or String(sym)
new Symbol() throwsSymbols are primitives, not objectsCall Symbol() without new
Symbol data vanished after an API round tripJSON and structuredClone drop symbol keysKeep only local metadata in symbol keys
A brand check passes on a copySpread and Object.assign copy symbol keys#private field and #field in value
A check fails between two copies of a packageEach copy called Symbol() separatelySymbol.for("package.name") for shared protocols
"Total: " + money gives the wrong hintBinary + passes "default", not "string"Template literals for text

Before you build: the rules of Money

REASON IT OUT

Decide what Money allows

You will now build the Money type properly. Decide before reading the code:

  • Which conversions should work, which should throw, and what should each throw say?
  • What should ngn.plus(usd) do? What about ngn.times(1.075) for VAT, which produces a fraction of a kobo?
  • What amounts should the constructor refuse?
  • Should a Money be changeable after it is created? What goes wrong if it is?
  • How should it look in JSON.stringify, which does not use Symbol.toPrimitive?
Show the reasoning
  • Conversions: "string" formats with Intl.NumberFormat. "number" and "default" throw a TypeError that names the methods to use. Money should never silently become a bare number.
  • Arithmetic: plus and minus require the same currency and throw otherwise (converting needs an exchange rate, which is a separate, explicit step). times rounds to a whole minor unit, and the rounding rule is written in one place.
  • Constructor: only safe integers of minor units, and a three-letter currency code. 1.5 kobo, NaN and "500" are refused where they enter.
  • Immutable: yes. Operations return new Money objects, and the instance is frozen. If a shared price object could be changed, a discount applied to one cart would change every cart holding that price (shared references).
  • JSON: JSON.stringify calls a toJSON() method if there is one. Return { minor, currency }, the exact stored data, never the formatted text, which cannot be parsed back reliably.

Build: a Money type that cannot be misused

The logging protocol symbol gets its own tiny module, so that both Money and the logger can import it:

redact-symbol.js
export const redact = Symbol.for("shop.log.redact");

Then the type itself:

money.js
import { redact } from "./redact-symbol.js";

export class Money {
  #minor;
  #currency;

  constructor(minor, currency) {
    if (!Number.isSafeInteger(minor)) throw new RangeError(`minor units must be a safe integer, got ${String(minor)}`);
    if (!/^[A-Z]{3}$/.test(currency)) throw new RangeError(`currency must be a 3-letter code, got ${String(currency)}`);
    this.#minor = minor;
    this.#currency = currency;
    Object.freeze(this);
  }

  get minor() { return this.#minor; }
  get currency() { return this.#currency; }

  static isMoney(value) {
    return typeof value === "object" && value !== null && #minor in value;
  }

  #sameCurrency(other, action) {
    if (!Money.isMoney(other)) throw new TypeError(`cannot ${action} Money and ${typeof other}`);
    if (other.#currency !== this.#currency) {
      throw new TypeError(`cannot ${action} ${this.#currency} and ${other.#currency}; convert first`);
    }
  }

  plus(other) {
    this.#sameCurrency(other, "add");
    return new Money(this.#minor + other.#minor, this.#currency);
  }
  minus(other) {
    this.#sameCurrency(other, "subtract");
    return new Money(this.#minor - other.#minor, this.#currency);
  }
  times(factor) {
    return new Money(Math.round(this.#minor * factor), this.#currency);
  }
  compare(other) {
    this.#sameCurrency(other, "compare");
    return Math.sign(this.#minor - other.#minor);
  }

  [Symbol.toPrimitive](hint) {
    if (hint === "string") {
      return new Intl.NumberFormat("en-NG", { style: "currency", currency: this.#currency }).format(this.#minor / 100);
    }
    throw new TypeError(`Money cannot be used as a ${hint} value; use plus(), minus(), times() or compare()`);
  }
  get [Symbol.toStringTag]() { return "Money"; }
  toJSON() { return { minor: this.#minor, currency: this.#currency }; }
  [redact]() { return `${this}`; }
}

Every rule from the reasoning step is in there: validation in the constructor, private fields so the numbers cannot be changed or forged, Object.freeze so no new fields can be added, currency checks in one private helper, and each hook doing one job. Money also joins the logging protocol from the previous section, so a logger that knows nothing about money prints it formatted. Now use it:

main.js
import { Money } from "./money.js";

const rice = new Money(850000, "NGN");
const oil = new Money(320000, "NGN");
const subtotal = rice.times(2).plus(oil);
const vat = subtotal.times(0.075);
const total = subtotal.plus(vat);

console.log(`Subtotal ${subtotal}, VAT ${vat}, total ${total}`);
console.log(Object.prototype.toString.call(total), JSON.stringify({ total }));
console.log(total.compare(new Money(2000000, "NGN")));

const shipping = new Money(2500, "USD");
for (const mistake of [() => total.plus(shipping), () => total + shipping, () => total.plus(500), () => new Money(10.5, "NGN")]) {
  try {
    mistake();
  } catch (error) {
    console.log(`${error.name}: ${error.message}`);
  }
}
Output of node main.js and of the browser terminal
Subtotal ₦20,200.00, VAT ₦1,515.00, total ₦21,715.00
[object Money] {"total":{"minor":2171500,"currency":"NGN"}}
1
TypeError: cannot add NGN and USD; convert first
TypeError: Money cannot be used as a default value; use plus(), minus(), times() or compare()
TypeError: cannot add Money and number
RangeError: minor units must be a safe integer, got 10.5

Testing Money

The tests check the rules, including the ones that must throw, and the boundaries where rounding happens:

money.test.js
import { Money } from "./money.js";
import { redact } from "./redact-symbol.js";

function check(label, run, expected) {
  let actual;
  try {
    actual = run();
  } catch (error) {
    actual = `${error.name}`;
  }
  console.log(`${actual === expected ? "PASS" : "FAIL"} ${label} -> ${actual}`);
}

const ngn = (minor) => new Money(minor, "NGN");

check("formats in text", () => `${ngn(150050)}`, "₦1,500.50");
check("adds same currency", () => `${ngn(100).plus(ngn(250))}`, "₦3.50");
check("rounds half a kobo up", () => ngn(1).times(0.5).minor, 1);
check("rounds a VAT fraction", () => ngn(333).times(0.075).minor, 25);
check("refuses mixed currencies", () => ngn(1).plus(new Money(1, "USD")), "TypeError");
check("refuses +", () => ngn(1) + ngn(1), "TypeError");
check("refuses >", () => ngn(2) > ngn(1), "TypeError");
check("refuses fractional kobo", () => ngn(0.5), "RangeError");
check("refuses a lowercase code", () => new Money(1, "ngn"), "RangeError");
check("refuses a string amount", () => new Money("500", "NGN"), "RangeError");
check("cannot be changed", () => { "use strict"; const m = ngn(1); m.discount = 1; }, "TypeError");
check("spread copy is not Money", () => Money.isMoney({ ...ngn(1) }), false);
check("JSON keeps exact data", () => JSON.stringify(ngn(150050)), '{"minor":150050,"currency":"NGN"}');
check("log protocol", () => ngn(150050)[redact](), "₦1,500.50");
Output of node money.test.js and of the browser terminal
PASS formats in text -> ₦1,500.50
PASS adds same currency -> ₦3.50
PASS rounds half a kobo up -> 1
PASS rounds a VAT fraction -> 25
PASS refuses mixed currencies -> TypeError
PASS refuses + -> TypeError
PASS refuses > -> TypeError
PASS refuses fractional kobo -> RangeError
PASS refuses a lowercase code -> RangeError
PASS refuses a string amount -> RangeError
PASS cannot be changed -> TypeError
PASS spread copy is not Money -> false
PASS JSON keeps exact data -> {"minor":150050,"currency":"NGN"}
PASS log protocol -> ₦1,500.50

In production

  • Rounding is a business rule. Math.round rounds halves up. Tax authorities and payment providers sometimes require other rules (banker's rounding, always down for discounts). Keep the rule in one method, and test the halves; Numbers in depth goes further.
  • Locale belongs to the viewer. The "string" hint above always uses en-NG. A shop with customers in several countries formats at the edge, with an explicit money.format(locale) method, and keeps ${money} for logs.
  • Symbols do not cross the network. Hooks and symbol keys exist only in memory. Data sent to an API or stored in a database goes through toJSON and must be turned back into Money by explicit parsing and validation on the other side.
  • Namespace every registry symbol. Symbol.for("redact") could be claimed by any library in the process; Symbol.for("shop.log.redact") will not be.

Practice

TRY IT YOURSELF

A Duration that prints and subtracts

Write a Duration class holding whole seconds. In text it should read like 1h 05m 09s. Unlike money, durations may be used as numbers (seconds) for comparisons and arithmetic, but a binary + with a string must still produce readable text. Use Symbol.toPrimitive to make "number" return seconds and both "string" and "default" return the text.

Show a solution
duration.js
class Duration {
  constructor(seconds) {
    if (!Number.isInteger(seconds) || seconds < 0) throw new RangeError("seconds must be a whole number >= 0");
    this.seconds = seconds;
  }
  [Symbol.toPrimitive](hint) {
    if (hint === "number") return this.seconds;
    const h = Math.floor(this.seconds / 3600);
    const m = Math.floor((this.seconds % 3600) / 60);
    const s = this.seconds % 60;
    return `${h}h ${String(m).padStart(2, "0")}m ${String(s).padStart(2, "0")}s`;
  }
}

const delivery = new Duration(3909);
const pickup = new Duration(1200);

console.log(`Delivery takes ${delivery}`);
console.log("Pickup: " + pickup);
console.log(delivery > pickup, delivery - pickup, Math.max(delivery, pickup));
Output of node duration.js and of the browser terminal
Delivery takes 1h 05m 09s
Pickup: 0h 20m 00s
true 2709 3909

This is the same technique with a different policy. For durations, "a number of seconds" is a meaningful number, so the "number" hint is allowed. For money, a bare number loses the currency, so it is not. Symbol.toPrimitive lets each type choose.

TRY IT YOURSELF

A plugin marker across packages

A shop platform loads plugins from separate packages. A plugin is any object marked with the registry symbol Symbol.for("shop.plugin") whose value is { name, version }. Write loadPlugins(candidates) that returns the names and versions of the marked objects and ignores the rest, including an object with a string key "shop.plugin".

Show a solution
plugins.js
const PLUGIN = Symbol.for("shop.plugin");

function loadPlugins(candidates) {
  return candidates
    .filter((value) => typeof value === "object" && value !== null && typeof value[PLUGIN] === "object")
    .map((plugin) => `${plugin[PLUGIN].name}@${plugin[PLUGIN].version}`);
}

const paystackPlugin = { [Symbol.for("shop.plugin")]: { name: "paystack", version: "2.1.0" }, pay() {} };
const smsPlugin = { [Symbol.for("shop.plugin")]: { name: "sms", version: "1.0.3" } };
const lookalike = { "shop.plugin": { name: "fake", version: "0.0.0" } };

console.log(loadPlugins([paystackPlugin, lookalike, null, smsPlugin, "text"]));
Output of node plugins.js and of the browser terminal
[ 'paystack@2.1.0', 'sms@1.0.3' ]

Each plugin wrote Symbol.for("shop.plugin") itself, without importing anything from the platform, and still produced the same key, because the registry is shared. The string key "shop.plugin" is a different key entirely, so the lookalike is ignored.

TRY IT YOURSELF

An iterable, async-iterable order log

Write an OrderLog class with add(event). Make it iterable with Symbol.iterator (events in order) and async-iterable with Symbol.asyncIterator (the same events, one per tick, as if streamed from a server). Give it the tag OrderLog.

Show a solution
order-log.js
class OrderLog {
  #events = [];
  add(event) {
    this.#events.push(event);
    return this;
  }
  *[Symbol.iterator]() {
    yield* this.#events;
  }
  async *[Symbol.asyncIterator]() {
    for (const event of this.#events) {
      await null;
      yield event;
    }
  }
  get [Symbol.toStringTag]() {
    return "OrderLog";
  }
}

const log = new OrderLog().add("created").add("paid").add("shipped");
console.log([...log].join(" -> "));
console.log(Object.prototype.toString.call(log));

for await (const event of log) console.log("stream:", event);
Output of node order-log.js and of the browser terminal
created -> paid -> shipped
[object OrderLog]
stream: created
stream: paid
stream: shipped

Three well-known symbols, three protocols, one small class: for...of, spread, for await...of and Object.prototype.toString all work because the class put methods under the keys each of them looks for.

Recap

  • A symbol is a unique primitive. As a property key it cannot clash with any string key or any other symbol, which is why the language uses symbols for its hooks.
  • Symbol keys are skipped by Object.keys, for...in, JSON and structuredClone, and copied by spread and Object.assign. They are hidden from accidents, not private.
  • Symbol() is local to the code that created it; Symbol.for(name) is shared through the global registry. Use the registry, with a namespaced name, for protocols that separately loaded code must agree on.
  • Symbol.toPrimitive(hint) controls conversion with the hints "number", "string" and "default" (binary + and ==). Use it to allow text and refuse silent arithmetic.
  • Symbol.toStringTag labels objects, Symbol.hasInstance customises instanceof, Symbol.dispose works with using, and Symbol.iterator/Symbol.asyncIterator drive iteration.
  • For your own protocols, key the method by a symbol; for identity checks, use #private fields.

Next: Functional JavaScript, where functions themselves become the building blocks of a checkout pipeline.

Test yourself

Five questions, picked at random from this lesson's question bank. Some ask you to choose an answer, some to predict what code prints, and some to write code and run it in the terminal. Get 4 of 5 right to pass. If you don't, read the explanations and try again: you get 5 different questions.