JavaScript: Fundamental (ES6), part 1, 137–150
The modern-syntax set: small, sharp utility functions.
Exercises 137 to 150 of 150
137 format naira with Intl.NumberFormat
fairBuild a naira formatter with an explicit locale, and inspect it rather than trusting the string: read resolvedOptions, take it apart with formatToParts, and check that the parts rejoin into the whole. Show that the locale, not the currency, decides whether you get the ₦ sign or the letters NGN, and that a formatted amount is no longer a number.
Stuck? Three hints, smallest first
- 1. Construct the formatter once and reuse it.
new Intl.NumberFormat(...)per row is the slow way to do this. - 2.
formatToPartsreturns{ type, value }objects —currency,integer,group,decimal,fraction— which is what to assert against when the exact string depends on the platform. - 3.
maximumFractionDigits: 0removes the kobo, and rounds rather than truncating.
const naira = new Intl.NumberFormat('en-NG', { style: 'currency', currency: 'NGN' });
const whole = new Intl.NumberFormat('en-NG', { style: 'currency', currency: 'NGN', maximumFractionDigits: 0 });
const american = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'NGN' });
const plain = new Intl.NumberFormat('en-US');
const amount = 1234567.5;
const formatted = naira.format(amount);
const parts = naira.formatToParts(amount);
const types = parts.map((p) => p.type);
const rejoined = parts.map((p) => p.value).join('');
const groups = formatted.split(',').length - 1;
const rounded = whole.format(amount);
const hasFraction = whole.formatToParts(amount).some((p) => p.type === 'fraction');
const symbols = [naira.formatToParts(1)[0].value, american.formatToParts(1)[0].value];
const settings = naira.resolvedOptions();
console.log('naira :', formatted, '| whole:', rounded);
console.log('parts :', types);
console.log('same currency, other locale:', american.format(amount));
Output
9 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 9 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('grouping in a plain formatter is the one string safe to pin down exactly', plain.format(1234567), '1,234,567');
test('the formatter reports back what it settled on', [settings.locale, settings.currency], ['en-NG', 'NGN']);
test('the parts describe the shape, whatever this platform prints', types, ['currency', 'integer', 'group', 'integer', 'group', 'integer', 'decimal', 'fraction']);
test('and they rejoin into exactly the formatted string', rejoined, formatted);
test('seven figures means two group separators', groups, 2);
test('dropping the fraction digits removes those parts and rounds up', [hasFraction, rounded.endsWith('568')], [false, true]);
test('the locale decides the symbol: en-NG gives the sign, en-US the code', symbols, ['₦', 'NGN']);
test('a formatted amount is text, not arithmetic', Number.isNaN(Number(formatted)), true);
test('so keep the money as whole kobo and format only at the edge', naira.format(150500 / 100).includes('1,505'), true);
Intl.NumberFormat knows the separators, the digit grouping and the symbol placement for a locale, which is a large amount of data you should not be hand-rolling. Build the formatter once and reuse it: construction is the expensive part, formatting is cheap.
The assertions here deliberately avoid pinning down the whole currency string. Output depends on the ICU data compiled into the platform, and it does change between versions — spacing around a symbol has moved more than once. So assert what you actually depend on: resolvedOptions for the settings, formatToParts for the shape, the group count for the separators. On this build the formatter prints ₦1,234,567.50, and it is fine to look at that in the log without freezing it into a test.
Two things catch people out. The locale, not the currency, chooses between ₦ and NGN — same currency: 'NGN', different output, because en-US has no local sign for it. And a formatted string is a string: it has commas and a symbol in it, so Number(...) gives NaN. Keep money as an integer count of kobo through every calculation and format once, at the very edge, where it is about to be read.
138 a receipt aligned with padStart and padEnd
fairPrint a fixed-width receipt: a centred shop name, a line per item with the amount flush right, rules made of repeated characters, and a zero-padded receipt number. Centre with padStart followed by padEnd. Then find the two ways padding lets you down — a label already wider than the column, and two strings that look identical but pad differently.
Stuck? Three hints, smallest first
- 1. Flush right in a fixed column is
label.padEnd(WIDTH - amount.length, '.') + amount. - 2. To centre, pad the front to half the leftover and then pad the end to the full width.
- 3.
padStartnever truncates: if the string is already long enough it comes back untouched, and your column overflows.
const WIDTH = 34;
const money = (kobo) => (kobo / 100).toFixed(2);
const row = (label, value) => label.padEnd(WIDTH - value.length, '.') + value;
const centred = (text) => text.padStart(Math.floor((WIDTH + text.length) / 2)).padEnd(WIDTH);
const qty = (n) => String(n).padStart(3);
const items = [
{ name: 'Garri, 5kg', count: 2, kobo: 450000 },
{ name: 'Palm oil, 1L', count: 1, kobo: 320000 },
{ name: 'Titus sardine', count: 3, kobo: 95000 },
];
const total = items.reduce((sum, i) => sum + i.count * i.kobo, 0);
const cash = 2000000;
const receipt = [
centred('BALOGUN STORES'),
centred(`Receipt ${String(7).padStart(6, '0')}`),
'='.repeat(WIDTH),
...items.map((i) => row(`${qty(i.count)} x ${i.name}`, money(i.count * i.kobo))),
'-'.repeat(WIDTH),
row('TOTAL', money(total)),
row('CASH', money(cash)),
row('CHANGE', money(cash - total)),
'='.repeat(WIDTH),
];
const widths = [...new Set(receipt.map((line) => line.length))];
const overflowing = row('Imported basmati rice, 50kg sack', money(total));
const precomposed = 'Adé';
const decomposed = 'Adé';
const dots = [precomposed, decomposed, decomposed.normalize('NFC')]
.map((s) => s.padStart(6, '.').split('.').length - 1);
console.log(receipt.join('\n'));
console.log('overflow:', overflowing.length, 'characters, not', WIDTH);
console.log('dots :', dots);
Output
9 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 9 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('every line comes out exactly one width', widths, [34]);
test('the shop name sits centred, ten spaces each side', [receipt[0].indexOf('B'), receipt[0].length], [10, 34]);
test('the receipt number is zero padded to six', receipt[1].trim(), 'Receipt 000007');
test('an item row has the count left, the name next and the amount flush right', receipt[3], ' 2 x Garri, 5kg...........9000.00');
test('and the money column lines up because every amount has two decimals', [money(total), money(cash - total)], ['15050.00', '4950.00']);
test('a pad character can be more than one character, repeated then cut', ['5'.padStart(4, 'ab'), 'x'.padEnd(5, '-=')], ['aba5', 'x-=-=']);
test('padding never truncates, so an over-long label breaks the column', overflowing.length > WIDTH, true);
test('two strings that look the same pad differently: one has a combining mark', dots, [3, 2, 3]);
test('which normalise fixes, and only because both are three code units after it', [precomposed.length, decomposed.length, decomposed.normalize('NFC').length], [3, 4, 3]);
padStart and padEnd are what monospaced output is made of: a receipt, a console table, an invoice for a thermal printer. The pair used together is the centring idiom — pad the front to half the total, then pad the end to the whole width — and the fill argument can be any string, repeated and then cut to length, which is where the dotted leader in each row comes from.
Neither method truncates. That is the first failure: a label wider than its column is returned unchanged and the row silently runs long, taking the alignment of everything below it with it. If a label can be long, slice it before you pad it.
The second failure is the interesting one. Padding counts UTF-16 code units, not the marks a reader sees. Adé written with a precomposed é is three units; written as e plus a combining acute it is four, so it receives one fewer dot and the column bends by a character. Emoji and many scripts make this worse, since a single visible glyph can be two units or more. normalize('NFC') fixes the combining-mark case, which is worth doing on any name that arrives from a form; for genuinely wide characters, no amount of padding will align a proportional column and the answer is a real table.
139 paging through results with an async generator
hardWrap a cursor-paged listing in an async function* that yields one page at a time, then a second one that flattens it to individual rows. Consume it with for await...of, count how many page requests each consumer causes, and show that stopping after four rows fetched two pages rather than all three. Then check what for await does with a yielded promise.
Stuck? Three hints, smallest first
- 1. The page generator is a
whileloop over the cursor:awaitthe page,yieldits rows, take the next cursor, stop when it is null. - 2. To flatten, iterate the first generator with
for awaitandyieldeach row inside it. - 3. Nothing is fetched until someone pulls, which is why breaking out of the loop early stops the requests — and runs the generator’s
finally.
const ALL = ['Ada', 'Bola', 'Chidi', 'Dele', 'Efe', 'Funke', 'Gbenga'];
const PAGE_SIZE = 3;
let requests = 0;
const fetchPage = async (cursor) => {
requests += 1;
const rows = ALL.slice(cursor, cursor + PAGE_SIZE);
const nextCursor = cursor + PAGE_SIZE < ALL.length ? cursor + PAGE_SIZE : null;
return { rows, nextCursor };
};
async function* pages() {
let cursor = 0;
while (cursor !== null) {
const page = await fetchPage(cursor);
yield page.rows;
cursor = page.nextCursor;
}
}
async function* rows() {
for await (const page of pages()) {
for (const row of page) yield row;
}
}
const takeAsync = async (source, count) => {
const out = [];
if (count <= 0) return out;
for await (const value of source) {
out.push(value);
if (out.length >= count) break;
}
return out;
};
const everyone = [];
for await (const name of rows()) everyone.push(name);
const requestsForEveryone = requests;
requests = 0;
const firstFour = await takeAsync(rows(), 4);
const requestsForFour = requests;
requests = 0;
const none = await takeAsync(rows(), 0);
const requestsForNone = requests;
async function* mixed() {
yield 'plain';
yield Promise.resolve('a promise, unwrapped');
}
const unwrapped = [];
for await (const value of mixed()) unwrapped.push(value);
const overAnArray = [];
for await (const value of [Promise.resolve('a'), 'b']) overAnArray.push(value);
let cleaned = false;
async function* watched() {
try {
yield 'one';
yield 'two';
} finally {
cleaned = true;
}
}
const justOne = await takeAsync(watched(), 1);
const lastPage = await fetchPage(6);
console.log('everyone :', everyone, '| requests:', requestsForEveryone);
console.log('firstFour:', firstFour, '| requests:', requestsForFour);
console.log('cleanup :', cleaned, '| none:', none, requestsForNone);
Output
8 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 8 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('every row arrives, in order, across the pages', everyone, ALL);
test('seven rows at three a page took three requests', requestsForEveryone, 3);
test('stopping after four rows only needed two of them', [firstFour, requestsForFour], [['Ada', 'Bola', 'Chidi', 'Dele'], 2]);
test('asking for none never touches the source at all', [none, requestsForNone], [[], 0]);
test('for await unwraps a yielded promise for you', unwrapped, ['plain', 'a promise, unwrapped']);
test('and it does the same over a plain array holding one', overAnArray, ['a', 'b']);
test('breaking out early still runs the generator\'s finally', [justOne, cleaned], [['one'], true]);
test('the last page reports a null cursor, which is what ends the loop', lastPage, { rows: ['Gbenga'], nextCursor: null });
Cursor paging is the shape almost every list API has, and it leaks into calling code as a while loop with a mutable cursor, repeated at every call site. An async function* puts that loop in one place and hands out a sequence, so the consumer writes for await (const row of rows()) and never sees a cursor. The two-generator split is worth copying: one yields pages, one flattens to rows, and a caller takes whichever granularity it needs.
The request counts are the reason to bother. Because a generator only runs when pulled, the consumer's stopping condition controls the network: four rows, two pages, third never requested. A function that returned an array would have fetched everything before the caller could say it had enough. And a break is not just a local exit — it closes the async iterator, which resumes the generator with a return completion and runs its finally, so a connection or a transaction gets released.
Two smaller facts. for await awaits each value, so a yielded promise arrives resolved, and it works over an ordinary array of promises too — handy, though for independent work Promise.all is the right tool because for await is strictly sequential. Note this whole exercise is deterministic: fetchPage is async but never touches a timer or the network, which is what makes the request counts assertable.
140 one order, end to end
hardPut the section together. Validate an order against a frozen tariff table collecting every problem at once, price it with a free-delivery threshold, format the money by hand with a grouping regular expression, and lay out a fixed-width receipt. Return a tagged result so a rejected order carries errors and never carries a total.
Stuck? Three hints, smallest first
- 1. For the checks, build an array of either a message or
falseandfilter(Boolean). Line-level checks come from amapspread into the same array. - 2. The result is a union:
{ ok: false, errors }or{ ok: true, ...priced }. Nothing in between, so no caller can read half a total. - 3.
\B(?=(\d{3})+(?!\d))matches every position that needs a comma: inside the string, with a multiple of three digits to its right and no digit after them.
const TARIFF = Object.freeze({ Ikeja: 120000, Yaba: 90000, Ketu: 150000 });
const FREE_ABOVE = 1500000;
const WIDTH = 30;
const money = (kobo) =>
`₦${(kobo / 100).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',')}`;
const problemsWith = (order) => [
!order.customer?.trim() && 'customer is required',
!Object.hasOwn(TARIFF, order.zone) && `no delivery to ${order.zone}`,
!(order.lines?.length > 0) && 'an order needs at least one line',
...(order.lines ?? []).map((line, i) => line.qty > 0 ? false : `line ${i} needs a quantity`),
].filter(Boolean);
const priceUp = (order) => {
const lines = order.lines.map((line) => ({ ...line, kobo: line.qty * line.unitKobo }));
const goods = lines.reduce((sum, line) => sum + line.kobo, 0);
const delivery = goods >= FREE_ABOVE ? 0 : TARIFF[order.zone];
return { lines, goods, delivery, total: goods + delivery };
};
const row = (label, value) => label.padEnd(WIDTH - value.length, ' ') + value;
const receiptFor = (priced) => [
...priced.lines.map((line) => row(`${line.qty} x ${line.item}`, money(line.kobo))),
row('Delivery', money(priced.delivery)),
row('TOTAL', money(priced.total)),
];
const place = (order) => {
const problems = problemsWith(order);
return problems.length
? { ok: false, status: 'rejected', errors: problems }
: { ok: true, status: 'accepted', ...priceUp(order) };
};
const smallOrder = {
customer: 'Ngozi Okafor',
zone: 'Yaba',
lines: [
{ item: 'Garri, 5kg', qty: 2, unitKobo: 450000 },
{ item: 'Palm oil, 1L', qty: 1, unitKobo: 320000 },
],
};
const bulkOrder = {
customer: 'Musa Bello',
zone: 'Ketu',
lines: [{ item: 'Rice, 50kg', qty: 1, unitKobo: 7500000 }],
};
const brokenOrder = { customer: ' ', zone: 'Abuja', lines: [{ item: 'Ice', qty: 0, unitKobo: 100 }] };
const small = place(smallOrder);
const bulk = place(bulkOrder);
const rejected = place(brokenOrder);
const receipt = receiptFor(small);
let frozenWrite = 'no error';
try { TARIFF.Yaba = 1; } catch (e) { frozenWrite = e.constructor.name; }
console.log(receipt.join('\n'));
console.log('bulk :', bulk.status, money(bulk.total), '| delivery:', money(bulk.delivery));
console.log('rejected:', rejected.errors);
Output
9 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 9 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('a good order is accepted and priced, delivery included', [small.status, small.goods, small.delivery, small.total], ['accepted', 1220000, 90000, 1310000]);
test('over the threshold the delivery is waived', [bulk.delivery, bulk.total], [0, 7500000]);
test('the money helper groups thousands and always shows kobo', [money(1310000), money(0), money(95000)], ['₦13,100.00', '₦0.00', '₦950.00']);
test('every receipt line is exactly one width', [...new Set(receipt.map((l) => l.length))], [30]);
test('with the amount flush right against the label', receipt[0], '2 x Garri, 5kg ₦9,000.00');
test('the total row closes it out', receipt.at(-1), 'TOTAL ₦13,100.00');
test('a broken order reports all three problems in one pass', rejected.errors, ['customer is required', 'no delivery to Abuja', 'line 0 needs a quantity']);
test('and carries no total at all, so nothing can read half an answer', [rejected.ok, Object.hasOwn(rejected, 'total')], [false, false]);
test('the tariff table is frozen, so a stray write throws instead of repricing', frozenWrite, 'TypeError');
Nothing here is new; the point is that the pieces fit. Validation collects rather than throwing, so a rejected order comes back with every problem at once. Pricing is a pure function of the order and the table, so it can be tested without a receipt. Formatting happens last, on integers that were kobo the whole way through. Each stage has one job and hands a plain object to the next.
The filter(Boolean) trick is worth keeping: an array of either a message or false, with line-level checks spread in from a map, gives you a flat list of problems with no push and no accumulator. Watch the one hazard — a legitimate empty-string message would be filtered out too, so keep the messages non-empty.
The tagged result is the design decision that matters most. { ok: false, errors } has no total property at all, so there is no path where a caller reads a price from a rejected order; the type of the answer tells you which fields exist. Combined with the frozen tariff table — where a stray write throws under strict mode instead of quietly repricing everything after it — the whole flow either produces a complete, correct order or produces a list of reasons why it did not.
141 the order the event loop really runs things in
hardIn one script, log a label synchronously, register a Promise.resolve().then, a queueMicrotask, an async function that awaits, and a setTimeout(fn, 0). Record the order they actually run in, not the order you wrote them. Then prove three follow-ups: queueMicrotask and .then share one queue, two promise chains advance one link each per turn, and a microtask queued from inside a microtask still beats the timer.
Stuck? Three hints, smallest first
- 1. Push labels into an array instead of printing them, and take a copy of that array before you await anything.
- 2. The whole microtask queue is drained before the loop looks at a single macrotask, and a
setTimeoutcallback is a macrotask. - 3. An
asyncfunction body up to its firstawaitis ordinary synchronous code. Theawaitis the part that queues a microtask.
const order = [];
const log = (label) => order.push(label);
let releaseTimer;
const timerRan = new Promise((resolve) => { releaseTimer = resolve; });
log('1 script top');
setTimeout(() => { log('7 setTimeout 0'); releaseTimer(); }, 0);
Promise.resolve().then(() => log('4 promise.then'));
queueMicrotask(() => log('5 queueMicrotask'));
const tally = async () => {
log('2 async body, before the first await');
await null;
log('6 async body, after the await');
};
tally();
log('3 script bottom');
const beforeAnythingAsync = [...order];
await timerRan;
const everything = [...order];
const timerCameLast = everything[everything.length - 1] === '7 setTimeout 0';
// One queue, not two: they interleave by registration order.
const sharedQueue = [];
queueMicrotask(() => sharedQueue.push('micro 1'));
Promise.resolve().then(() => sharedQueue.push('then 1'));
queueMicrotask(() => sharedQueue.push('micro 2'));
Promise.resolve().then(() => sharedQueue.push('then 2'));
await new Promise((resolve) => queueMicrotask(resolve));
// Each .then is one microtask, so two chains advance in lockstep.
const chain = [];
const chainA = Promise.resolve()
.then(() => { chain.push('A1'); })
.then(() => { chain.push('A2'); })
.then(() => { chain.push('A3'); });
const chainB = Promise.resolve()
.then(() => { chain.push('B1'); })
.then(() => { chain.push('B2'); })
.then(() => { chain.push('B3'); });
await Promise.all([chainA, chainB]);
// A microtask can add to the queue it is being drained from.
const nested = [];
let releaseSecond;
const secondTimer = new Promise((resolve) => { releaseSecond = resolve; });
setTimeout(() => { nested.push('timer'); releaseSecond(); }, 0);
Promise.resolve().then(() => {
nested.push('microtask');
Promise.resolve().then(() => nested.push('microtask queued from a microtask'));
});
await secondTimer;
console.log('sync only :', beforeAnythingAsync);
console.log('everything:', everything);
console.log('one queue :', sharedQueue);
console.log('two chains:', chain);
console.log('nested :', nested);
Output
8 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 8 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('while the script is still running, nothing asynchronous has happened', beforeAnythingAsync, ['1 script top', '2 async body, before the first await', '3 script bottom']);
test('an async body up to its first await is plain synchronous code', beforeAnythingAsync[1], '2 async body, before the first await');
test('the real order, in full', everything, ['1 script top', '2 async body, before the first await', '3 script bottom', '4 promise.then', '5 queueMicrotask', '6 async body, after the await', '7 setTimeout 0']);
test('so every microtask ran before the single macrotask', [timerCameLast, everything.length], [true, 7]);
test('queueMicrotask and .then feed one queue, drained in registration order', sharedQueue, ['micro 1', 'then 1', 'micro 2', 'then 2']);
test('which is why two chains advance one link each per turn', chain, ['A1', 'B1', 'A2', 'B2', 'A3', 'B3']);
test('a microtask queued from a microtask is drained in the same turn', nested, ['microtask', 'microtask queued from a microtask', 'timer']);
test('and the timer waited for both of them', nested.indexOf('timer'), 2);
One turn of the loop is: run the current job to completion, then drain the entire microtask queue, then take one macrotask. That single sentence predicts every line above. The labels are numbered in the order they run, and reading the source top to bottom gives 1, 7, 4, 5, 2, 6, 3 — which is why guessing does not work.
queueMicrotask and promise reactions are not separate lanes; they are one FIFO queue, so ordering between them is decided purely by when each was registered. A .then chain therefore cannot run three links in one go: each link is one microtask, and the queue is fair, so a second chain gets a turn between every link. That is the mechanism behind the A1, B1, A2, B2 pattern.
The last two assertions are the dangerous ones. A microtask may queue more microtasks, and they are drained in the same pass, so an unbounded chain of .then calls starves the macrotask queue for ever: timers never fire, rendering never happens, the tab appears frozen with no busy loop anywhere. setTimeout(fn, 0) is the escape hatch precisely because it is a macrotask, and that is also why 0 never means now.
142 this, in the seven places it changes meaning
hardTake one function and call it seven ways: as a method, detached, through call and apply and bind, as an arrow written in an object literal, and as a callback handed to map. Report what this was each time. Then show that binding twice does not rebind, that new ignores a bound this, and that a class field holding an arrow survives being detached where a method does not.
Stuck? Three hints, smallest first
- 1. For a plain function,
thisis decided at the CALL, by whatever sits to the left of the dot. There is nothing to the left of the dot indetached(). - 2. An arrow has no
thisof its own, so an arrow in an object literal captures the scope around the literal, not the object. - 3.
maptakes a second argument,thisArg. That, an arrow, orbindare the three ways to keep the method’sthisinside the callback.
const topLevelThis = typeof this; // a strict function scope, so: undefined
const stall = {
name: 'Balogun stall',
items: ['gari', 'rice'],
who() { return this.name; }, // 1. decided by the call site
whoArrow: () => (this === undefined ? '<no this>' : 'the stall'), // 2. captured, not the object
labelPlain() { // 3. a plain callback loses it
return this.items.map(function (item) {
return (this === undefined ? '<no this>' : this.name) + '/' + item;
});
},
labelArrow() { // 4. an arrow keeps it
return this.items.map((item) => this.name + '/' + item);
},
labelThisArg() { // 5. so does map's second argument
return this.items.map(function (item) { return this.name + '/' + item; }, this);
},
labelBound() { // 6. and so does bind
const fn = function (item) { return this.name + '/' + item; }.bind(this);
return this.items.map(fn);
},
};
const asMethod = stall.who();
const detached = stall.who; // 7. same function, no receiver
let detachedResult;
try { detachedResult = detached(); } catch (e) { detachedResult = e.constructor.name; }
const kiosk = { name: 'Yaba kiosk' };
const borrowed = [stall.who.call(kiosk), stall.who.apply(kiosk), stall.who.bind(kiosk)()];
const boundOnce = stall.who.bind(kiosk);
const boundTwice = boundOnce.bind(stall); // the second bind cannot win
const rebound = boundTwice();
function Stallholder(name) { this.name = name; }
const BoundHolder = Stallholder.bind({ name: 'ignored by new' });
const built = new BoundHolder('Adaora');
class Counter {
constructor() {
this.n = 0;
this.bumpField = () => { this.n += 1; return this.n; }; // an own arrow property
}
bumpMethod() { this.n += 1; return this.n; } // lives on the prototype
}
const counter = new Counter();
const looseField = counter.bumpField;
const looseMethod = counter.bumpMethod;
let fieldResult, methodResult;
try { fieldResult = looseField(); } catch (e) { fieldResult = e.constructor.name; }
try { methodResult = looseMethod(); } catch (e) { methodResult = e.constructor.name; }
console.log('top level :', topLevelThis, '| as a method:', asMethod, '| detached:', detachedResult);
console.log('borrowed :', borrowed, '| arrow in a literal:', stall.whoArrow());
console.log('callbacks :', stall.labelPlain(), stall.labelArrow());
console.log('bind twice:', rebound, '| new on a bound fn:', built.name);
console.log('detached :', fieldResult, methodResult, '| counter.n:', counter.n);
Output
9 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 9 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('at the top of a strict panel there is no this at all', topLevelThis, 'undefined');
test('called as a method, this is whatever sits left of the dot', asMethod, 'Balogun stall');
test('the same function with no receiver gets undefined, so the property read throws', detachedResult, 'TypeError');
test('call, apply and bind all aim it somewhere else', borrowed, ['Yaba kiosk', 'Yaba kiosk', 'Yaba kiosk']);
test('an arrow in an object literal captured the scope, not the object', stall.whoArrow(), '<no this>');
test('a plain callback in map loses this; arrow, thisArg and bind all keep it', [stall.labelPlain()[0], stall.labelArrow()[0], stall.labelThisArg()[0], stall.labelBound()[0]], ['<no this>/gari', 'Balogun stall/gari', 'Balogun stall/gari', 'Balogun stall/gari']);
test('binding an already bound function changes nothing', rebound, 'Yaba kiosk');
test('but new ignores the bound this while keeping the prototype', [built.name, built instanceof Stallholder], ['Adaora', true]);
test('a class field arrow survives detachment; a prototype method does not', [fieldResult, methodResult, counter.n], [1, 'TypeError', 1]);
this is not a property of a function. It is an argument, supplied afresh at every call, and the only thing that decides it for an ordinary function is the call expression: obj.fn() passes obj, fn() passes nothing, fn.call(x) passes x, new Fn() passes a brand new object. Detaching a method into a variable or a callback drops the receiver, which is the single most common source of "cannot read properties of undefined". Sloppy mode hid it by substituting the global object; strict mode leaves it undefined so the failure is loud.
Arrows opt out entirely. They have no this binding, so this inside one resolves lexically, exactly like any other variable. That makes them right for callbacks written inside a method and wrong for a method itself: whoArrow reaches past the object literal into the surrounding scope, because an object literal is not a scope. The same reasoning explains the class comparison at the end. bumpField is an arrow created inside the constructor, so it captured that instance and became an own property of it; bumpMethod is a shared prototype method that needs a receiver. That is the whole argument for class fields in event handlers.
bind is one-way: it returns an exotic function with the receiver already fixed, and a second bind only wraps the wrapper, so the first one wins for ever. new is the exception that overrides even bind, because construction allocates its own this before the call.
143 a prototype chain built by hand, and what instanceof reads
hardBuild a chain with Object.create instead of class, walk it with Object.getPrototypeOf, and show that writing an inherited property shadows it rather than changing the parent. Then take instanceof apart: make it say true for an object no constructor ever touched, make it say false for a genuine instance, override it completely with Symbol.hasInstance, and find the object it cannot answer for at all.
Stuck? Three hints, smallest first
- 1.
Object.create(proto)makes an empty object whose prototype isproto. Nothing is copied. - 2.
a instanceof Basks one question: isB.prototypeanywhere ona’s prototype chain? It never looks at howawas made. - 3. So reassigning
B.prototypeafter making an instance leaves that instance pointing at an object no longer named byB.
const vehicle = {
wheels: 4,
describe() { return this.kind + ' on ' + this.wheels + ' wheels'; },
};
const danfo = Object.create(vehicle);
danfo.kind = 'danfo';
const okada = Object.create(vehicle, {
kind: { value: 'okada', enumerable: true, writable: true },
wheels: { value: 2, enumerable: true, writable: true },
});
const asBuilt = [danfo.describe(), okada.describe()];
const nameOf = (o) =>
o === danfo ? 'danfo' : o === vehicle ? 'vehicle' : o === Object.prototype ? 'Object.prototype' : 'other';
const walk = [];
let cursor = danfo;
while (cursor !== null) { walk.push(nameOf(cursor)); cursor = Object.getPrototypeOf(cursor); }
const ownBefore = Object.keys(danfo);
const inherited = [danfo.wheels, Object.hasOwn(danfo, 'wheels')];
danfo.wheels = 6; // creates an OWN property
const afterWrite = [danfo.wheels, vehicle.wheels, okada.wheels, Object.hasOwn(danfo, 'wheels')];
function Vehicle() {}
Vehicle.prototype = vehicle; // instanceof reads the RIGHT side's .prototype
const neverConstructed = danfo instanceof Vehicle;
function Bus() {}
const firstBus = new Bus();
Bus.prototype = { rows: 10 }; // a different object entirely
const secondBus = new Bus();
const orphaned = [firstBus instanceof Bus, secondBus instanceof Bus];
class Wheeled {
static [Symbol.hasInstance](value) { return typeof value?.wheels === 'number'; }
}
const hijacked = [{ wheels: 3 } instanceof Wheeled, { wheels: 'plenty' } instanceof Wheeled];
const bare = Object.create(null);
bare.kind = 'bare';
const bareFacts = [typeof bare.toString, 'toString' in bare, Object.getPrototypeOf(bare), bare instanceof Object];
const fromTheOtherEnd = [vehicle.isPrototypeOf(danfo), Object.prototype.isPrototypeOf(bare)];
console.log('as built :', asBuilt);
console.log('chain :', walk, '| own keys:', ownBefore);
console.log('shadowing :', inherited, '->', afterWrite);
console.log('instanceof:', neverConstructed, orphaned, hijacked);
console.log('no chain :', bareFacts, '| isPrototypeOf:', fromTheOtherEnd);
Output
9 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 9 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('an inherited method reads the child own data, and a defineProperty override wins', asBuilt, ['danfo on 4 wheels', 'okada on 2 wheels']);
test('the chain is exactly the objects you linked, ending at null', walk, ['danfo', 'vehicle', 'Object.prototype']);
test('an inherited property is readable but not owned', [ownBefore, inherited], [['kind'], [4, false]]);
test('writing it creates an own property and leaves every sibling alone', afterWrite, [6, 4, 2, true]);
test('instanceof is true for an object no constructor ever touched', neverConstructed, true);
test('and false for a real instance, once .prototype has been reassigned', orphaned, [false, true]);
test('Symbol.hasInstance replaces the walk with whatever you like', hijacked, [true, false]);
test('Object.create(null) has no chain, so it is not even an instanceof Object', bareFacts, ['undefined', false, null, false]);
test('isPrototypeOf asks the same question from the other end', fromTheOtherEnd, [true, false]);
There is only one inheritance mechanism in JavaScript, and it is this: every object has a hidden link to another object, and a failed property lookup follows the link. class, new and Object.create are three spellings for wiring that link. Reading walks the chain; writing never does. danfo.wheels = 6 installs an own property that shadows the parent, which is why one instance changing a value cannot corrupt its siblings, and also why a shared mutable default on a prototype is a bug waiting for its first push.
instanceof is far dumber than its name suggests. It takes the object on the right, reads its .prototype property, and looks for that exact object on the left operand chain. It does not record what constructed anything. So pointing Vehicle.prototype at an object already in danfo chain makes danfo instanceof Vehicle true with no constructor call, and swapping Bus.prototype for a fresh object makes an object built by new Bus() stop being a Bus. The same reasoning is why instanceof fails across iframes and across two copies of a library: same class, two .prototype objects.
Two escape hatches follow. Symbol.hasInstance lets the right-hand side answer for itself, which is duck typing with the operator syntax. And Object.create(null) produces an object with no chain at all: no toString, no hasOwnProperty, and instanceof Object is false. That is exactly what you want for a lookup table keyed by untrusted strings, since there is no inherited __proto__ or constructor to collide with. Reach for Object.hasOwn and Object.getPrototypeOf rather than the methods such an object does not have.
144 a closure in a loop, var against let
fairBuild an array of functions inside a for loop, once with var as the counter and once with let, then call them all and report the two different result arrays. Reproduce the let behaviour with an IIFE, show what for...of does with each keyword, say what survives after the loop, and find out what happens to that iteration binding if the body itself changes the counter.
Stuck? Three hints, smallest first
- 1.
vargives the whole function ONE variable. Three closures over one variable can only ever report one value. - 2.
letin aforheader is special-cased: the loop creates a fresh binding per iteration and copies the last value into it. - 3. The pre-
letfix was to make a new scope by hand, so pass the counter into a function that returns the closure.
const withVar = [];
for (var i = 0; i < 3; i++) withVar.push(() => i);
const withLet = [];
for (let j = 0; j < 3; j++) withLet.push(() => j);
const withIife = [];
for (var k = 0; k < 3; k++) withIife.push(((frozen) => () => frozen)(k));
const withForEach = [];
[0, 1, 2].forEach(function (n) { withForEach.push(() => n); });
const called = [withVar.map((f) => f()), withLet.map((f) => f()), withIife.map((f) => f()), withForEach.map((f) => f())];
const afterVar = i;
let afterLet;
try { afterLet = j; } catch (e) { afterLet = e.constructor.name; }
const varOf = [];
for (var v of ['a', 'b', 'c']) varOf.push(() => v);
const constOf = [];
for (const w of ['a', 'b', 'c']) constOf.push(() => w);
// The body mutates the counter. Which binding did the closure capture?
const nudged = [];
const bodySaw = [];
for (let m = 0; m < 3; m++) { nudged.push(() => m); bodySaw.push(m); m += 10; }
console.log('var / let :', called[0], called[1]);
console.log('iife/each :', called[2], called[3]);
console.log('after :', afterVar, afterLet);
console.log('for...of :', varOf.map((f) => f()), constOf.map((f) => f()));
console.log('nudged :', nudged.map((f) => f()), '| body saw:', bodySaw);
Output
8 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 8 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('three closures over one var see the value that ended the loop', called[0], [3, 3, 3]);
test('three closures over a per-iteration let see three values', called[1], [0, 1, 2]);
test('an IIFE reproduces let by making a scope by hand', called[2], [0, 1, 2]);
test('forEach never had the bug: each call has its own parameter', called[3], [0, 1, 2]);
test('the var counter outlives the loop at 3; the let counter does not exist', [afterVar, afterLet], [3, 'ReferenceError']);
test('for...of is no exemption: var repeats the last item, const does not', [varOf.map((f) => f()), constOf.map((f) => f())], [['c', 'c', 'c'], ['a', 'b', 'c']]);
test('a body that adds 10 to the counter ends the loop after one pass', [nudged.length, bodySaw], [1, [0]]);
test('and that closure reports 10, not the 0 the body saw, because they share the binding', nudged.map((f) => f()), [10]);
A closure captures a binding, never a value. That one sentence is the whole exercise. var declares one function-scoped binding, so all three functions point at the same box, and by the time anyone calls them the box holds 3 — the value that failed the loop test. let in a for header gets special treatment in the spec: each iteration creates a fresh binding, the previous value is copied in, and the update expression runs against the new one. Three iterations, three boxes, three answers.
The last two assertions show the copying being real rather than a metaphor. The body sets m to 10, so m++ makes 11 in the next iteration copy, the test fails and the loop stops after one pass — the body only ever saw 0. Yet the captured closure reports 10, because it holds iteration one binding and the body mutated exactly that binding. A value snapshot would have said 0; a shared binding says 10.
Classically this bug appeared with setTimeout inside a loop, all callbacks reporting the final index. Pushing closures into an array shows the same thing with no asynchrony at all, which is the honest framing: nothing about it is about timing. Three fixes exist and two are still worth knowing — let, and an iterator method like forEach or map whose parameter is a fresh binding per call. The IIFE is the museum piece.
145 hoisting, and the temporal dead zone typeof cannot dodge
hardFrom the top of a function, probe every name declared below it with typeof inside try/catch: a var, a let, a const, a class, a function declaration, an arrow assigned to a const, and a name that is never declared anywhere. Report what each one gives. Then show that a let declared later in a block shadows an outer variable of the same name for the whole block, that default parameters have their own dead zone, and which of a var and a same-named function declaration wins.
Stuck? Three hints, smallest first
- 1.
vardeclarations are hoisted and initialised to undefined.let,constandclassare hoisted and left uninitialised. - 2.
typeofis only safe on a name that was never declared. On a name in its dead zone it throws like any other read. - 3. Scoping is decided before any code runs, so a
letanywhere in a block owns that name from the opening brace.
function survey() {
const seen = {};
seen.varBefore = typeof loose;
try { seen.letBefore = typeof strictish; } catch (e) { seen.letBefore = e.constructor.name; }
try { seen.constBefore = typeof fixed; } catch (e) { seen.constBefore = e.constructor.name; }
try { seen.classBefore = typeof Stall; } catch (e) { seen.classBefore = e.constructor.name; }
seen.fnBefore = typeof declared;
seen.calledEarly = declared();
try { seen.arrowBefore = typeof arrow; } catch (e) { seen.arrowBefore = e.constructor.name; }
seen.neverDeclared = typeof nowhereAtAll;
var loose = 'loose';
let strictish = 'strictish';
const fixed = 'fixed';
class Stall {}
function declared() { return 'callable above its own line'; }
const arrow = () => 'not callable above its line';
return seen;
}
const seen = survey();
const supplier = 'the outer supplier';
function shadowed() {
let got;
try { got = supplier; } catch (e) { got = e.constructor.name; }
let supplier = 'the inner supplier'; // owns the name for the whole function
return [got, supplier];
}
function defaults(a = b, b = 2) { return [a, b]; }
let defaultsBackwards;
try { defaultsBackwards = defaults(); } catch (e) { defaultsBackwards = e.constructor.name; }
const defaultsForwards = defaults(1);
function twins() { return 'first'; }
function twins() { return 'second'; }
const whichTwin = twins();
function varAgainstFunction() {
const before = typeof mixed;
var mixed = 1;
const after = typeof mixed;
function mixed() {}
return [before, after];
}
console.log('survey :', seen);
console.log('shadowed :', shadowed());
console.log('defaults :', defaultsBackwards, defaultsForwards);
console.log('twins :', whichTwin, '| var vs function:', varAgainstFunction());
Output
9 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 9 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('a var is hoisted AND initialised, so typeof reports undefined with no error', seen.varBefore, 'undefined');
test('let, const and class are hoisted but uninitialised, and reading one throws', [seen.letBefore, seen.constBefore, seen.classBefore], ['ReferenceError', 'ReferenceError', 'ReferenceError']);
test('so typeof is no longer a safe existence check', [seen.letBefore, seen.neverDeclared], ['ReferenceError', 'undefined']);
test('a function declaration is hoisted whole, and is callable above its own line', [seen.fnBefore, seen.calledEarly], ['function', 'callable above its own line']);
test('an arrow in a const is not: the const is what is hoisted, and it is in the dead zone', seen.arrowBefore, 'ReferenceError');
test('a let below owns the name for the whole block, so the read hits the dead zone', shadowed(), ['ReferenceError', 'the inner supplier']);
test('parameters initialise left to right in their own dead zone', [defaultsBackwards, defaultsForwards], ['ReferenceError', [1, 2]]);
test('two function declarations: the later one silently wins', whichTwin, 'second');
test('a var and a function of one name share a binding, and the function is what is hoisted', varAgainstFunction(), ['function', 'number']);
Hoisting is not code moving. Before a scope runs, the engine creates every binding it contains; the difference is only what each one starts as. A var starts as undefined, so reading it early is legal and useless. A function declaration starts as the finished function, which is why calling it above its definition works. let, const and class start uninitialised, and every read before the declaration line throws — that gap is the temporal dead zone, and it is temporal rather than positional: a function defined above the line but called below it reads the value fine.
The consequence people trip over is typeof. It used to be the one operator you could point at anything, and it still is for a name that was never declared, which is the check feature detection relies on. Point it at a let in its dead zone and it throws like a normal read. There is no syntactic guard left; the answer is to declare before use and stop probing.
The shadowing case is the one that reaches production. Adding let supplier at the bottom of a long function does not create a variable from that point down, it claims the name for the entire block, so a read forty lines above stops finding the outer value and starts throwing. Nothing about the failure points at the line you added. The last assertion is the fossil: a var and a function declaration of the same name are one binding, the function is installed during hoisting, and the assignment overwrites it on its own line, so typeof goes from function to number across two adjacent lines.
146 the == table, pair by pair
hardBuild a table of the comparisons that catch people out and assert every entry: null against undefined, false and 0; null >= 0 next to null > 0; empty and whitespace strings against 0; the three pairs that show == is not transitive; arrays against booleans, numbers and strings, including [] == ![]; and NaN and -0 under ==, === and Object.is. Then run the whole list through === and say how many survive.
Stuck? Three hints, smallest first
- 1.
==between different types converts to NUMBER, with one carve-out:nullandundefinedequal each other and nothing else, with no conversion at all. - 2. An object is converted by
valueOfthentoString, so an array becomes itsjoin(',')string first and only then a number. - 3.
>=is not>negated, and it does not share the==carve-out: it convertsnullto0.
const nullish = {
'null == undefined': null == undefined,
'null === undefined': null === undefined,
'null == false': null == false,
'null == 0': null == 0,
'null == empty string': null == '',
};
const relational = {
'null >= 0': null >= 0,
'null > 0': null > 0,
'null <= 0': null <= 0,
'undefined >= 0': undefined >= 0,
};
const emptyish = {
'empty string == 0': '' == 0,
'space == 0': ' ' == 0,
'newline == 0': '\n' == 0,
'zero string == 0': '0' == 0,
'empty string == zero string': '' == '0',
};
const notTransitive = ['0' == 0, 0 == '', '0' == ''];
const boolish = {
'zero string == false': '0' == false,
'empty string == false': '' == false,
'one string == true': '1' == true,
'2 == true': 2 == true,
'empty array == false': [] == false,
'empty array == not empty array': [] == ![],
};
const arrays = {
'[1] == 1': [1] == 1,
'empty array == empty string': [] == '',
'array of empty array == empty string': [[]] == '',
'[null] == empty string': [null] == '',
'[undefined] == empty string': [undefined] == '',
"[1,2] == '1,2'": [1, 2] == '1,2',
};
const objects = {
'plain object == its toString': ({}) == '[object Object]',
'two empty arrays': [] == [],
'two empty objects': ({}) === ({}),
};
const nanFacts = [NaN == NaN, NaN === NaN, Object.is(NaN, NaN), Number.isNaN(NaN)];
const zeroFacts = [0 === -0, 0 == -0, Object.is(0, -0), [0, -0].map((n) => 1 / n === Infinity)];
const coercions = [Number(''), Number(' '), Number(null), Number([]), Number([2]), String(Number(undefined))];
const everySurprise = [null == undefined, '' == 0, '0' == 0, [] == false, [1] == 1, ({}) == '[object Object]'];
const sameWithTripleEquals = [null === undefined, '' === 0, '0' === 0, [] === false, [1] === 1, ({}) === '[object Object]'];
console.log('nullish :', nullish);
console.log('relational:', relational);
console.log('emptyish :', emptyish, '| transitive?', notTransitive);
console.log('boolish :', boolish);
console.log('arrays :', arrays, '| objects:', objects);
console.log('NaN / -0 :', nanFacts, zeroFacts, '| Number():', coercions);
Output
9 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 9 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('null and undefined are loosely equal to each other and to nothing else', Object.values(nullish), [true, false, false, false, false]);
test('but the relational operators do not share that rule, so null is both >= 0 and not > 0', Object.values(relational), [true, false, true, false]);
test('any whitespace-only string becomes 0, and these are the conversions doing it', [Object.values(emptyish), coercions], [[true, true, true, true, false], [0, 0, 0, 0, 2, 'NaN']]);
test('so == is not transitive: two pairs equal, the third not', notTransitive, [true, true, false]);
test('a boolean is converted to a number too, which is why 2 is not true', Object.values(boolish), [true, true, true, false, true, true]);
test('an array stringifies before it numifies, so all six of these hold', Object.values(arrays), [true, true, true, true, true, true]);
test('an object equals its own toString, but two objects are compared by identity', Object.values(objects), [true, false, false]);
test('NaN alone is not equal to itself, and -0 is where === and Object.is part company', [nanFacts, zeroFacts], [[false, false, true, true], [true, true, false, [true, false]]]);
test('every surprise above disappears under ===', [everySurprise, sameWithTripleEquals], [[true, true, true, true, true, true], [false, false, false, false, false, false]]);
The algorithm is short enough to memorise, which is better than memorising the table. If the types match, == is ===. If one side is null and the other undefined, the answer is true and nothing is converted — that is the whole reason null == 0 is false while null >= 0 is true, since the relational operators have no such carve-out and quietly turn null into 0. Otherwise both sides are converted toward number: a boolean becomes 0 or 1, so 2 == true is false; a string is parsed, and a string of nothing but whitespace parses to 0; an object is asked for a primitive via valueOf then toString.
That last step is what makes arrays so strange. [] has no useful valueOf, so toString gives join(','), which is the empty string, which becomes 0. Hence [] == false, [] == 0 and [[]] == ''. [] == ![] is the same fact wearing a costume: ! binds tighter, ![] is false, and the comparison is [] == false. And because null and undefined stringify to nothing inside join, [null] == '' too.
Non-transitivity is the killer argument. '0' == 0 and 0 == '', yet '0' != '', because each comparison converts differently and no consistent notion of equality survives. Use ===, which as the final assertion shows collapses every row to false. Keep exactly one == in your vocabulary, x == null, as the terse test for null-or-undefined. For the two edges === gets wrong, use Number.isNaN rather than x !== x, and remember Object.is is not simply a better ===: it disagrees on NaN and on -0, and on nothing else.
147 the order object keys come out in
hardInsert keys into an object in a deliberately jumbled order: plain names, small integers, a negative number, a decimal, a zero-padded number, the largest possible array index and the one just past it, and a symbol. Then report the order given by Object.keys, Reflect.ownKeys, JSON.stringify, spread and for...in. Show what delete and re-insert does to a string key against an integer key, and what a Map does with the same sequence.
Stuck? Three hints, smallest first
- 1. There are three groups, always in this order: integer-like keys ascending by value, then string keys in insertion order, then symbols in insertion order.
- 2. A key counts as integer-like only if
String(Number(key)) === keyand it is below 2**32 - 1, so'-1','1.5'and'01'are ordinary strings. - 3.
for...inadds inherited enumerable string keys after the own ones, and spread is the only one of these that carries symbols across.
const tag = Symbol('tag');
const ledger = {};
ledger.zebra = 1;
ledger[2] = 'two';
ledger.apple = 2;
ledger[1] = 'one';
ledger['-1'] = 'minus one';
ledger['1.5'] = 'one and a half';
ledger['01'] = 'zero one';
ledger[tag] = 'a symbol value';
ledger[0] = 'zero';
ledger['4294967294'] = 'the largest array index';
ledger['4294967295'] = 'one past it';
const keys = Object.keys(ledger);
const ownKeys = Reflect.ownKeys(ledger).map(String);
const json = JSON.stringify(ledger);
const keyOrderInJson = (json.match(/"[^"]*":/g) || []).map((s) => s.slice(1, -2));
const spread = { ...ledger };
const spreadKeys = Object.keys(spread);
const symbolsCarried = [Object.getOwnPropertySymbols(ledger).length, Object.getOwnPropertySymbols(spread).length];
const literal = { b: 1, 2: 'x', a: 2, 1: 'y' };
const literalKeys = Object.keys(literal);
const parent = { inherited: true };
const child = Object.create(parent);
child.own2 = 1;
child[5] = 1;
child.own1 = 1;
const forIn = [];
for (const key in child) forIn.push(key);
const reinsert = { a: 1, b: 2, c: 3, 1: 'one', 2: 'two' };
delete reinsert.a;
reinsert.a = 9;
delete reinsert[1];
reinsert[1] = 'back';
const reinsertKeys = Object.keys(reinsert);
const asMap = new Map();
asMap.set('zebra', 1).set(2, 'two').set('apple', 2).set(1, 'one');
const mapKeys = [...asMap.keys()];
const mapKeyTypes = mapKeys.map((k) => typeof k);
const expected = ['0', '1', '2', '4294967294', 'zebra', 'apple', '-1', '1.5', '01', '4294967295'];
console.log('Object.keys :', keys);
console.log('Reflect.ownKeys:', ownKeys);
console.log('in the JSON :', keyOrderInJson);
console.log('spread :', spreadKeys, '| symbols:', symbolsCarried);
console.log('literal :', literalKeys, '| for...in:', forIn);
console.log('delete/re-add :', reinsertKeys);
console.log('a Map instead :', mapKeys, mapKeyTypes);
Output
9 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 9 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('integer-like keys first and ascending, then the string keys in insertion order', keys, expected);
test('a negative, a decimal, a padded number and 2**32-1 are all ordinary strings', [keys.indexOf('-1') > keys.indexOf('zebra'), keys.indexOf('4294967295'), keys.length], [true, 9, 10]);
test('an object literal is normalised exactly the same way', literalKeys, ['1', '2', 'b', 'a']);
test('JSON.stringify follows the same order and drops the symbol', [keyOrderInJson, json.includes('symbol')], [expected, false]);
test('spread keeps the order and is the one that copies symbols across', [spreadKeys, symbolsCarried], [expected, [1, 1]]);
test('Reflect.ownKeys appends symbols after every string key', [ownKeys.length, ownKeys[10]], [11, 'Symbol(tag)']);
test('for...in normalises own keys the same way, then walks up the prototype chain', forIn, ['5', 'own2', 'own1', 'inherited']);
test('delete and re-add sends a string key to the back, but an integer key returns to its slot', reinsertKeys, ['1', '2', 'b', 'c', 'a']);
test('a Map keeps genuine insertion order and does not stringify its keys', [mapKeys, mapKeyTypes], [['zebra', 2, 'apple', 1], ['string', 'number', 'string', 'number']]);
Key order was unspecified until ES2015 and is now pinned down exactly, which is worse news than it sounds: the specified order is not insertion order. Own property enumeration builds three groups. Integer-index keys first, sorted numerically ascending. Then all other string keys, in the order they were created. Then symbols, in creation order. Object.keys, Object.values, Object.entries, JSON.stringify, spread, Object.assign and for...in all follow it, so there is no method that will hand back the order you typed.
The definition of integer-index is narrow and worth knowing precisely: the key must be the canonical decimal form of a non-negative integer below 2**32 - 1. So '-1', '1.5' and '01' are ordinary strings, and so is '4294967295' — one past the largest array index — while '4294967294' jumps to the front and sorts numerically. That is why an object keyed by numeric ids silently reorders itself, and why { 10: a, 9: b } comes out 9 then 10.
delete makes it concrete. Removing and re-adding a string key moves it to the end of its group, so an innocuous update reorders your JSON, but an integer key drops back into its numeric slot as if nothing happened. When order carries meaning, do not store it in object keys: use a Map, which preserves true insertion order and keeps a number a number rather than coercing it to a string. If you must use an object, sort explicitly at the point of use and stop depending on the shape of the literal.
148 getters, setters, and what enumerable false hides
hardDefine one non-enumerable, non-writable data property and one accessor property with a getter and a setter on the same object. Then report what each of Object.keys, JSON.stringify, spread, Object.getOwnPropertyNames and in can see. Show that spread flattens a getter into a dead value, that the setter can write back through another property, that defineProperty defaults every flag to false where an assignment defaults them to true, and what writing to a non-writable or getter-only property does under strict mode.
Stuck? Three hints, smallest first
- 1. A descriptor is either a data descriptor (
value,writable) or an accessor one (get,set). Never both, and the two never mix. - 2.
JSON.stringify,Object.keysand spread all skip non-enumerable properties.Object.getOwnPropertyNamesdoes not, so this is not privacy. - 3. A getter is a function call, so serialising or spreading INVOKES it and stores whatever it returned at that moment.
let getterCalls = 0;
let setterCalls = 0;
const order = { item: 'gari', qty: 3 };
Object.defineProperty(order, 'unitKobo', {
value: 120000, enumerable: false, writable: false, configurable: false,
});
Object.defineProperty(order, 'totalNaira', {
get() { getterCalls += 1; return (this.qty * this.unitKobo) / 100; },
set(naira) { setterCalls += 1; this.qty = Math.round((naira * 100) / this.unitKobo); },
enumerable: true,
configurable: true,
});
const visible = Object.keys(order);
const everything = Object.getOwnPropertyNames(order);
const json = JSON.parse(JSON.stringify(order));
const callsAfterJson = getterCalls;
const snapshot = { ...order };
order.qty = 10;
const liveVersusSnapshot = [order.totalNaira, snapshot.totalNaira];
order.totalNaira = 7200; // the setter writes back through qty
const afterSetter = [setterCalls, order.qty, order.totalNaira];
let writeError;
try { order.unitKobo = 1; writeError = 'no throw'; } catch (e) { writeError = e.constructor.name; }
let deleteError;
try { delete order.unitKobo; deleteError = 'no throw'; } catch (e) { deleteError = e.constructor.name; }
let redefineError;
try { Object.defineProperty(order, 'unitKobo', { value: 2 }); redefineError = 'no throw'; }
catch (e) { redefineError = e.constructor.name; }
const defined = {};
Object.defineProperty(defined, 'x', { value: 1 });
const assigned = {};
assigned.x = 1;
const definedFlags = Object.getOwnPropertyDescriptor(defined, 'x');
const assignedFlags = Object.getOwnPropertyDescriptor(assigned, 'x');
let definedWrite;
try { defined.x = 2; definedWrite = 'no throw'; } catch (e) { definedWrite = e.constructor.name; }
const readOnly = {};
Object.defineProperty(readOnly, 'label', { get() { return 'fixed'; }, enumerable: true, configurable: true });
let getterOnlyWrite;
try { readOnly.label = 'other'; getterOnlyWrite = 'no throw'; } catch (e) { getterOnlyWrite = e.constructor.name; }
const accessor = Object.getOwnPropertyDescriptor(order, 'totalNaira');
const accessorShape = [Object.keys(accessor).sort(), typeof accessor.get, 'value' in accessor, 'writable' in accessor];
const reachability = ['unitKobo' in order, Object.hasOwn(order, 'unitKobo'), order.unitKobo];
console.log('visible :', visible, '| all own names:', everything);
console.log('json :', json, '| getter calls:', callsAfterJson);
console.log('snapshot :', snapshot, '| live vs snapshot:', liveVersusSnapshot);
console.log('setter :', afterSetter);
console.log('strict :', writeError, deleteError, redefineError, definedWrite, getterOnlyWrite);
console.log('flags :', definedFlags, assignedFlags);
console.log('accessor :', accessorShape, '| reachable:', reachability);
Output
9 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 9 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('enumerable false hides the key from Object.keys, JSON and spread alike', [visible, Object.keys(json), Object.keys(snapshot)], [['item', 'qty', 'totalNaira'], ['item', 'qty', 'totalNaira'], ['item', 'qty', 'totalNaira']]);
test('but getOwnPropertyNames and in still see it, so this is not privacy', [everything, reachability], [['item', 'qty', 'unitKobo', 'totalNaira'], [true, true, 120000]]);
test('a getter is invoked while serialising, so a computed field lands in the JSON', [json.totalNaira, callsAfterJson], [3600, 1]);
test('spread flattens the getter into a plain value that then stops tracking', liveVersusSnapshot, [12000, 3600]);
test('the setter intercepts assignment and can write back through another property', afterSetter, [1, 6, 7200]);
test('defineProperty defaults every flag to false; an assignment defaults them to true', [definedFlags, assignedFlags], [{ value: 1, writable: false, enumerable: false, configurable: false }, { value: 1, writable: true, enumerable: true, configurable: true }]);
test('so under strict mode writing, deleting or redefining it all throw', [writeError, deleteError, redefineError, definedWrite], ['TypeError', 'TypeError', 'TypeError', 'TypeError']);
test('and a getter with no setter throws on assignment rather than ignoring it', [getterOnlyWrite, readOnly.label], ['TypeError', 'fixed']);
test('an accessor descriptor carries get and set, and never value or writable', accessorShape, [['configurable', 'enumerable', 'get', 'set'], 'function', false, false]);
Every property is a descriptor, and there are two kinds. A data property has value and writable; an accessor property has get and set. They are mutually exclusive, which is why the accessor descriptor above has no value key at all rather than an undefined one. An accessor property looks exactly like a field from the outside and is a function call underneath, so it can validate, compute, log or refuse.
enumerable: false is presentation, not protection. It removes the key from Object.keys, Object.values, Object.entries, for...in, spread, Object.assign and JSON.stringify — which is genuinely useful for keeping an internal field out of an API response — while Object.getOwnPropertyNames, Object.getOwnPropertyDescriptors, Reflect.ownKeys and in report it happily. For real privacy use a #field or a closure.
The spread assertion is the trap worth carrying away. { ...order } does not copy the getter, it calls it once and stores the number, so the copy is a snapshot that silently stops tracking while the original keeps recomputing. The same is true of Object.assign and of JSON.stringify; only Object.defineProperties with Object.getOwnPropertyDescriptors clones the behaviour rather than the result. And note how much stricter defineProperty is than an assignment: omit the flags and you get a frozen, hidden, permanent property, which under "use strict" turns the next innocent write into a TypeError instead of the silent no-op sloppy mode gave you.
149 a generator as a coroutine that listens
hardWrite a generator that keeps a running total, receiving each amount as the value of its yield expression from next(v). Show that the argument to the very first next is discarded, that return() finishes it and runs its finally, and that every call after that is inert. Then throw an error into it at the suspended yield and catch it inside, delegate with yield* and prove next(v) reaches the inner generator, and show what for...of and spread do with the values you would have sent and with the generator return value.
Stuck? Three hints, smallest first
- 1. The first
next()only runs the body up to the firstyield. There is no suspendedyieldexpression for its argument to become. - 2. So the pairing is off by one:
next(v)supplies the value for theyieldthat the PREVIOUSnextstopped at. - 3.
return()andthrow()both resume the generator at that suspended point, which is why afinallyblock inside it runs.
const trace = [];
function* runningTotal() {
let total = 0;
try {
while (true) {
const amount = yield total; // next(v) makes v the value of this expression
trace.push('received ' + amount);
total += amount;
}
} finally {
trace.push('finally ran at ' + total);
}
}
const till = runningTotal();
const started = till.next('IGNORED'); // only starts the body
const afterFirst = till.next(500);
const afterSecond = till.next(250);
const closed = till.return('till shut');
const afterDone = till.next(999);
const tillTrace = [...trace];
const second = runningTotal();
const discarded = [second.next(1000).value, second.next(1).value];
second.return();
const caught = [];
function* resilient() {
let n = 0;
while (n < 4) {
try {
n += yield n;
} catch (e) {
caught.push(e.message);
n += 1;
}
}
return { n, caught: [...caught] };
}
const tough = resilient();
const t1 = tough.next().value;
const t2 = tough.throw(new Error('bad note')).value; // lands at the suspended yield
const t3 = tough.next(2).value;
const t4 = tough.next(1);
function* upTo(limit) {
for (let i = 1; i <= limit; i++) yield i;
return 'RETURNED';
}
const spread = [...upTo(4)];
const looped = [];
for (const value of upTo(3)) looped.push(value);
const byHand = [];
const manual = upTo(2);
byHand.push(manual.next().value, manual.next().value, manual.next().value, manual.next().done);
function* inner() { const a = yield 'i1'; const b = yield 'i2'; return a + b; }
function* outer() { const sum = yield* inner(); yield 'sum ' + sum; }
const delegating = outer();
const delegated = [delegating.next().value, delegating.next(10).value, delegating.next(5).value];
const once = upTo(2);
const drained = [[...once], [...once]];
const selfIterating = [once[Symbol.iterator]() === once, typeof once.next, typeof once.throw];
console.log('till :', [started, afterFirst, afterSecond]);
console.log('closed :', closed, afterDone, '| trace:', trace);
console.log('first arg :', discarded, '| throw in:', [t1, t2, t3, t4]);
console.log('for...of :', spread, looped, byHand);
console.log('yield* :', delegated, '| exhausted:', drained, selfIterating);
Output
9 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 9 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('the first next only starts the body, and its argument is thrown away', [started, discarded], [{ value: 0, done: false }, [0, 1]]);
test('after that next(v) is the value of the suspended yield, so state accumulates', [afterFirst, afterSecond], [{ value: 500, done: false }, { value: 750, done: false }]);
test('return() finishes it, reports the value you passed and runs the finally', [closed, tillTrace], [{ value: 'till shut', done: true }, ['received 500', 'received 250', 'finally ran at 750']]);
test('and every later next is inert: done stays true and no body runs again', [afterDone.done, afterDone.value === undefined, tillTrace.length], [true, true, 3]);
test('throw() lands at the suspended yield, is caught inside, and the generator carries on', [t1, t2, t3, t4.value.caught], [0, 1, 3, ['bad note']]);
test('the return value only ever arrives on the result whose done is true', [t4.done, t4.value.n], [true, 4]);
test('for...of and spread ignore next arguments and DROP the return value', [spread, looped, byHand], [[1, 2, 3, 4], [1, 2, 3], [1, 2, 'RETURNED', true]]);
test('yield* forwards next(v) inward and evaluates to the inner return value', delegated, ['i1', 'i2', 'sum 15']);
test('a generator is its own iterator and is single-use once exhausted', [drained, selfIterating], [[[1, 2], []], [true, 'function', 'function']]);
A generator is not only a lazy sequence. yield is an expression, and its value is whatever the next next(v) hands back, so the channel runs both ways: the generator sends a value out and receives one in, at the same suspension point. That is a coroutine, and it lets you keep evolving state in ordinary local variables rather than in an object with fields, which is why async/await was originally implemented on top of exactly this shape.
The off-by-one is the part everyone gets wrong. The first next() starts the body; there is no suspended yield waiting, so its argument is discarded with no warning at all — the assertion above shows next(1000) vanishing and the next next(1) proving the total was still 0. If a coroutine needs an initial value, take it as a parameter to the generator function, or spend one priming next() before you send anything.
return() and throw() complete the picture: both resume the generator at its suspension point, so finally blocks run and cleanup is honest, and a throw() caught inside lets the generator recover and keep going. Two consequences to remember. The generator return value is real but almost invisible: it arrives only on the result whose done is true, which is precisely the result for...of and spread discard — put data in yield, not in return. And a generator object is its own iterator, so it is single-use: iterate it twice and the second pass sees nothing, and breaking out of a for...of early calls return() for you.
150 deep recursion, replaced by an explicit stack
hardSum the integers to 200,000 recursively and watch it blow the call stack, then write it in tail position and watch that blow up too. Fix it with a loop. Then build a 50,000-deep tree, walk it recursively (it throws) and with an explicit stack array (it does not), showing that the stack version produces the same pre-order and that the push order is what decides sibling order. Finish with a trampoline for mutual recursion that no rewrite can flatten.
Stuck? Three hints, smallest first
- 1. Catch the failure rather than avoiding it:
try { deep(); } catch (e) { e.constructor.name }. - 2. ES6 specified proper tail calls and essentially no engine shipped them, so a tail-recursive form still consumes one frame per call.
- 3. An explicit stack is an array you
pushchildren onto andpopfrom.popis last-in-first-out, so push children in REVERSE to keep left-to-right pre-order.
const DEPTH = 200000;
const sumRecursive = (n) => (n === 0 ? 0 : n + sumRecursive(n - 1));
const sumTail = (n, acc = 0) => (n === 0 ? acc : sumTail(n - 1, acc + n));
const sumLoop = (n) => { let total = 0; for (let i = n; i > 0; i--) total += i; return total; };
let recursiveFailure, tailFailure;
try { sumRecursive(DEPTH); recursiveFailure = 'no throw'; } catch (e) { recursiveFailure = e.constructor.name; }
try { sumTail(DEPTH); tailFailure = 'no throw'; } catch (e) { tailFailure = e.constructor.name; }
const shallowStillFine = [sumRecursive(100), sumTail(100), sumLoop(100)];
const chainOf = (depth) => {
let node = { id: depth, kids: [] };
for (let i = depth - 1; i >= 1; i--) node = { id: i, kids: [node] };
return node;
};
const small = {
id: 1,
kids: [{ id: 2, kids: [{ id: 4, kids: [] }, { id: 5, kids: [] }] }, { id: 3, kids: [] }],
};
const walkRecursive = (node, out) => {
out.push(node.id);
for (const kid of node.kids) walkRecursive(kid, out);
return out;
};
const walkStack = (root) => {
const out = [];
const stack = [root];
let highWater = 0;
while (stack.length > 0) {
if (stack.length > highWater) highWater = stack.length;
const node = stack.pop();
out.push(node.id);
for (let i = node.kids.length - 1; i >= 0; i--) stack.push(node.kids[i]);
}
return { out, highWater };
};
const walkStackForwardPush = (root) => {
const out = [];
const stack = [root];
while (stack.length > 0) {
const node = stack.pop();
out.push(node.id);
for (const kid of node.kids) stack.push(kid);
}
return out;
};
const smallRecursive = walkRecursive(small, []);
const smallStack = walkStack(small).out;
const smallForwardPush = walkStackForwardPush(small);
const deep = chainOf(50000);
let deepRecursive;
try { deepRecursive = walkRecursive(deep, []).length; } catch (e) { deepRecursive = e.constructor.name; }
const deepStack = walkStack(deep);
const deepStackFacts = [deepStack.out.length, deepStack.out[0], deepStack.out[49999], deepStack.highWater];
const trampoline = (fn) => (...args) => {
let out = fn(...args);
while (typeof out === 'function') out = out();
return out;
};
const isEvenStep = (n) => (n === 0 ? true : () => isOddStep(n - 1));
const isOddStep = (n) => (n === 0 ? false : () => isEvenStep(n - 1));
const isEvenSafely = trampoline(isEvenStep);
function isEvenDirect(n) { return n === 0 ? true : isOddDirect(n - 1); }
function isOddDirect(n) { return n === 0 ? false : isEvenDirect(n - 1); }
let mutualFailure;
try { isEvenDirect(100000); mutualFailure = 'no throw'; } catch (e) { mutualFailure = e.constructor.name; }
console.log('recursive :', recursiveFailure, '| tail form:', tailFailure, '| shallow:', shallowStillFine);
console.log('loop :', sumLoop(DEPTH));
console.log('small tree:', smallRecursive, smallStack, smallForwardPush);
console.log('deep tree :', deepRecursive, '->', deepStackFacts);
console.log('trampoline:', isEvenSafely(100000), isEvenSafely(99999), '| direct:', mutualFailure);
Output
9 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 9 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('200,000 frames of plain recursion is a RangeError, not a wrong answer', recursiveFailure, 'RangeError');
test('writing it in tail position changes nothing: no engine here elides the frame', tailFailure, 'RangeError');
test('the same recursion at a shallow depth was never the problem', shallowStillFine, [5050, 5050, 5050]);
test('the loop returns the answer the recursion could not reach', sumLoop(DEPTH), 20000100000);
test('an explicit stack reproduces the recursive pre-order exactly', [smallStack, smallStack.join() === smallRecursive.join()], [[1, 2, 4, 5, 3], true]);
test('pushing children in source order reverses siblings, because pop is last-in-first-out', smallForwardPush, [1, 3, 2, 5, 4]);
test('50,000 deep, the recursive walk dies and the stack walk returns every node', [deepRecursive, deepStackFacts.slice(0, 3)], ['RangeError', [50000, 1, 50000]]);
test('and it never held more than one node, because the heap grows, not the call stack', deepStackFacts[3], 1);
test('a trampoline flattens mutual recursion that no single rewrite could', [isEvenSafely(100000), isEvenSafely(99999), mutualFailure], [true, false, 'RangeError']);
Recursion depth is a hard resource limit, not a style preference. Each call needs a frame, the stack is a fixed allocation of a megabyte or so, and the ceiling lands somewhere around ten thousand frames — engine-dependent, which is why the assertions here check for a RangeError rather than a number. The failure mode is the nasty part: it depends on the size of the input, so it passes every test written against a small fixture and throws the first time real data arrives.
The tail-call assertion is the one to remember. ES6 specified proper tail calls, and outside JavaScriptCore nobody shipped them, so rewriting n + f(n-1) as f(n-1, acc+n) buys you nothing at all in V8 or SpiderMonkey. Advice to "make it tail recursive" is advice from another language. What actually works is moving the pending work off the call stack and onto the heap, where the only limit is memory: a loop for linear recursion, an explicit array as a stack for tree recursion, or a trampoline where the recursion is mutual and no single rewrite reaches it.
The explicit stack is worth getting exactly right, and the two walk orders show why. Recursion visits children left to right; an array used as a stack pops the last thing pushed, so pushing children in source order visits them backwards — [1, 3, 2, 5, 4] instead of [1, 2, 4, 5, 3]. Push in reverse and the order matches the recursion node for node. The high-water assertion is the payoff: walking a 50,000-deep chain, the array never held more than one node, while the recursive version needed 50,000 live frames to do the same work. Swap pop for shift and the same five lines become a breadth-first walk, which is a queue rather than a stack, and something plain recursion cannot express at all.