JavaScript: Fundamental (ES6), part 1, 1–40
The modern-syntax set: small, sharp utility functions.
Exercises 1 to 40 of 150
1 let, const and var in a block
easyDeclare the same name three ways inside a function, shadow each one inside an if block, and report which of the three the block actually changed.
Stuck? Three hints, smallest first
- 1.
varis scoped to the whole function.letandconstare scoped to the nearest pair of braces. - 2. So a second
var xinside the block is the same variable; a secondlet xis a new one. - 3. Reading a name that was never declared throws, but
typeofon it does not.
function shadow() {
var loose = 'outer';
let scoped = 'outer';
const fixed = 'outer';
if (true) {
var loose = 'inner'; // the SAME variable, reassigned
let scoped = 'inner'; // a NEW variable, dead at the closing brace
const fixed = 'inner';
}
return { loose, scoped, fixed };
}
function afterTheBlock() {
if (true) {
var kept = 'still here';
let dropped = 'gone at the brace';
}
let seen;
try { seen = dropped; } catch (e) { seen = e.constructor.name; }
return { kept, seen };
}
const rebind = (() => {
const rate = 1;
try { rate = 2; return 'allowed'; } catch (e) { return e.constructor.name; }
})();
console.log('shadow() :', shadow());
console.log('afterTheBlock() :', afterTheBlock());
console.log('reassigning const:', rebind);
console.log('typeof a name nobody declared:', typeof neverDeclared, '<- no error');
Output
7 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 7 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('var ignores the block and is simply reassigned', shadow().loose, 'inner');
test('a let inside the block is a different variable', shadow().scoped, 'outer');
test('and so is a const', shadow().fixed, 'outer');
test('a var declared in a block outlives it', afterTheBlock().kept, 'still here');
test('a let does not', afterTheBlock().seen, 'ReferenceError');
test('reassigning a const throws', rebind, 'TypeError');
test('typeof a name that was never declared is safe', typeof neverDeclared, 'undefined');
The one to internalise is the second assertion. var has exactly one scope — the enclosing function — so writing var loose twice does not create two variables, it creates one and assigns to it twice. Every if, for and bare block you write with var is leaking into the function.
typeof neverDeclared returning 'undefined' instead of throwing is the one exception to "reading an undeclared name is an error". It is occasionally useful for feature detection, and it is also why a typo in a typeof check fails silently.
Answers JS Fundamental 1 in the original set.
2 The temporal dead zone
fairRead a let before the line that declares it, then read a var the same way, and record what each one does. Then check what happens when a let shadows an outer name of the same spelling.
Stuck? Three hints, smallest first
- 1. A
letorconstbinding exists from the top of its block but cannot be read until its line runs. That gap is the temporal dead zone. - 2.
varis initialised toundefinedat the top of the function instead, which is why it reads asundefinedrather than throwing. - 3. Shadowing happens at parse time, not at the line. The inner name covers the whole block, including the part above its declaration.
function readLetTooEarly() {
const seen = [];
try { seen.push(price); } catch (e) { seen.push(e.constructor.name); }
let price = 500;
seen.push(price);
return seen;
}
function readVarTooEarly() {
const seen = [];
seen.push(typeof price);
var price = 500;
seen.push(typeof price);
return seen;
}
function typeofInTheDeadZone() {
let result;
try { result = typeof amount; } catch (e) { result = e.constructor.name; }
let amount = 1;
return result;
}
const outerRate = 'outer';
function shadowedInTheDeadZone() {
let seen;
try { seen = outerRate; } catch (e) { seen = e.constructor.name; }
let outerRate = 'inner';
return seen;
}
function callsBeforeDefined() {
return double(4);
function double(n) { return n * 2; }
}
console.log('let read too early :', readLetTooEarly());
console.log('var read too early :', readVarTooEarly());
console.log('typeof in the gap :', typeofInTheDeadZone(), '<- typeof does not save you here');
console.log('shadowed outer name:', shadowedInTheDeadZone());
console.log('function hoisting :', callsBeforeDefined());
Output
7 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 7 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('reading a let above its line throws', readLetTooEarly()[0], 'ReferenceError');
test('below the line it reads normally', readLetTooEarly()[1], 500);
test('a var reads as undefined instead of throwing', readVarTooEarly(), ['undefined', 'number']);
test('even typeof throws inside the dead zone', typeofInTheDeadZone(), 'ReferenceError');
test('shadowing hides the outer value for the whole block', shadowedInTheDeadZone(), 'ReferenceError');
test('the outer binding itself is untouched', outerRate, 'outer');
test('a function declaration is hoisted whole, so it can be called above itself', callsBeforeDefined(), 8);
The dead zone is not "the variable does not exist yet". The binding exists from the top of the block — that is precisely why the fifth assertion gives a ReferenceError instead of quietly reading 'outer'. Once you write let outerRate anywhere in a block, the outer outerRate is unreachable throughout that block.
That is a feature. With var the same code would have read undefined and carried on, and the bug would surface somewhere else entirely. Function declarations remain fully hoisted, which is the one place calling above the definition is still fine.
Answers JS Fundamental 2 in the original set.
3 Which redeclarations the parser refuses
fairWrite parses(src) that reports whether a snippet is syntactically legal, then use it to compare redeclaring with var, with let, and across two separate blocks.
Stuck? Three hints, smallest first
- 1.
new Function(src)parsessrcwithout running it, so a syntax error surfaces as a thrownSyntaxError. - 2. Return the error’s constructor name so the answer is a comparable string.
- 3. One of these is legal syntax that fails only when it runs. Look for it.
const parses = (src) => {
try { new Function(src); return 'parsed'; } catch (e) { return e.constructor.name; }
};
const cases = [
'var a = 1; var a = 2;',
'let a = 1; let a = 2;',
'let a = 1; var a = 2;',
'{ let a = 1; } { let a = 2; }',
'const a;',
'const a = 1; a = 2;',
];
for (const src of cases) console.log(src.padEnd(30), '->', parses(src));
Output
7 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 7 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('var may be declared twice in one scope', parses('var a = 1; var a = 2;'), 'parsed');
test('let may not', parses('let a = 1; let a = 2;'), 'SyntaxError');
test('nor may a let and a var of the same name collide', parses('let a = 1; var a = 2;'), 'SyntaxError');
test('the same name in two separate blocks is fine', parses('{ let a = 1; } { let a = 2; }'), 'parsed');
test('a const must be given a value on its own line', parses('const a;'), 'SyntaxError');
test('but reassigning one parses, and fails only when it runs', parses('const a = 1; a = 2;'), 'parsed');
test('a parameter may shadow an outer let', parses('let a = 1; function f(a) { return a; }'), 'parsed');
The distinction worth keeping is between an error the parser catches and one the runtime catches. let a; let a; never runs at all — the whole script fails to load. const a = 1; a = 2; loads fine and throws a TypeError when execution reaches the assignment, which means it can hide behind an if and only bite in production.
new Function is the honest way to demonstrate a syntax error without taking the page down with it. It compiles but does not call, so nothing in the snippet executes.
Answers JS Fundamental 3 in the original set.
4 Why var in a loop closure gives the wrong answer
fairBuild three functions in a loop that each return the counter, once with var and once with let. Explain the difference, then show the fix people used before let existed.
Stuck? Three hints, smallest first
- 1. A
varcounter is one variable for the whole loop, so all three closures read the same slot. - 2.
letin aforheader creates a fresh binding per iteration, copied at the top of each turn. - 3. The old fix was to pass the value into an immediately-invoked function, which gives each iteration its own parameter.
function withVar() {
const out = [];
for (var i = 0; i < 3; i++) out.push(() => i);
return out.map((f) => f());
}
function withLet() {
const out = [];
for (let i = 0; i < 3; i++) out.push(() => i);
return out.map((f) => f());
}
function withIife() {
const out = [];
for (var i = 0; i < 3; i++) {
((n) => out.push(() => n))(i);
}
return out.map((f) => f());
}
function overAnIterable() {
const out = [];
for (const stop of ['Yaba', 'Mushin']) out.push(() => stop);
return out.map((f) => f());
}
function varAfterTheLoop() {
for (var i = 0; i < 3; i++);
return i;
}
function letAfterTheLoop() {
for (let i = 0; i < 3; i++);
try { return i; } catch (e) { return e.constructor.name; }
}
const distinct = (() => {
const fns = [];
for (var j = 0; j < 2; j++) fns.push(() => j);
return fns[0] === fns[1];
})();
console.log('var :', withVar(), '<- the loop finished, i is 3, all three read it');
console.log('let :', withLet());
console.log('iife:', withIife());
console.log('of :', overAnIterable());
console.log('i after a var loop:', varAfterTheLoop(), ' after a let loop:', letAfterTheLoop());
Output
7 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 7 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('var captures one shared binding, so every closure reads the final value', withVar(), [3, 3, 3]);
test('let makes a fresh binding each turn', withLet(), [0, 1, 2]);
test('the pre-let fix was an IIFE per iteration', withIife(), [0, 1, 2]);
test('for...of with const behaves like let', overAnIterable(), ['Yaba', 'Mushin']);
test('a var counter is still readable after the loop', varAfterTheLoop(), 3);
test('a let counter is not', letAfterTheLoop(), 'ReferenceError');
test('the closures are distinct functions either way, they only share a variable', distinct, false);
Three closures, one variable. The loop runs to completion before any of them is called, i is 3 by then, and all three report 3 — the last assertion makes it clear the functions themselves are separate objects, so the problem is the variable, not the function.
let in a for header is special-cased in the specification: a new binding is created for each iteration and seeded with the previous value. That is why [0, 1, 2] falls out with no extra work, and why the IIFE wrapper is now only of historical interest.
Answers JS Fundamental 4 in the original set.
5 const with a mutable object
easyShow that a const object can be modified while the binding cannot be reassigned. Then use Object.freeze to stop the modification too, and report what happens under strict mode.
Stuck? Three hints, smallest first
- 1.
constis a promise about the binding: this name will keep pointing at this value. - 2.
Object.freezeis a promise about the value: nobody may add, remove or change a property. - 3. A panel here runs in strict mode, so a write to a frozen property throws rather than being ignored.
const account = { owner: 'Adaeze', balanceNgn: 25000 };
account.balanceNgn += 5000; // the value is not const
const rebind = (() => {
try { account = { owner: 'someone else' }; return 'allowed'; }
catch (e) { return e.constructor.name; }
})();
const branches = ['Ikeja'];
branches.push('Yaba');
const card = Object.freeze({ tier: 'gold' });
const frozenWrite = (() => {
try { card.tier = 'silver'; return 'allowed'; }
catch (e) { return e.constructor.name; }
})();
const constInAClassicLoop = (() => {
try { for (const i = 0; i < 3; i++); return 'ran'; }
catch (e) { return e.constructor.name; }
})();
console.log('balance after +5000 :', account.balanceNgn);
console.log('rebinding the const :', rebind);
console.log('pushing to a const array:', branches);
console.log('writing to a frozen object:', frozenWrite, '<- strict mode, so it throws');
console.log('const in a for(;;) header :', constInAClassicLoop);
Output
7 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 7 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('a const object can still be changed', account.balanceNgn, 30000);
test('but the binding cannot be pointed somewhere else', rebind, 'TypeError');
test('a const array can be pushed to', branches, ['Ikeja', 'Yaba']);
test('const freezes nothing', Object.isFrozen(account), false);
test('Object.freeze is what stops a write, and in strict mode it throws', frozenWrite, 'TypeError');
test('so the frozen value is unchanged', card.tier, 'gold');
test('const in a classic for header dies on the first increment', constInAClassicLoop, 'TypeError');
const is about the arrow, not the box. account.balanceNgn += 5000 never touches the binding, so it is legal; account = {} does, so it is not. Reach for const by default anyway — the guarantee that a name still means what it meant twenty lines ago is worth more than the ability to reassign.
The strict-mode detail matters. Outside strict mode the write to card.tier would fail silently, which is far worse than throwing. Every panel in this course is strict, and so is every ES module, so the throwing version is the one you will actually meet.
Answers JS Fundamental 5 in the original set.
6 Arrow functions and the concise body
easyWrite the same small function five ways: with and without parentheses round a single parameter, with no parameter, with a concise body and with a block body. Then show what a block body returns when you forget return.
Stuck? Three hints, smallest first
- 1. One parameter may drop its parentheses; zero parameters may not.
- 2. A concise body is an expression, and its value is returned for you.
- 3. The moment you write
{, you are writing statements, and statements return nothing on their own.
const double = (n) => n * 2;
const square = n => n * n; // parentheses optional for one parameter
const greet = () => 'welcome'; // but required when there are none
const sum = (a, b) => a + b;
const withBlock = (n) => { const t = n * 2; return t + 1; };
const forgotReturn = (n) => { n * 2; }; // a statement, so nothing comes back
for (const f of [double, square, greet, sum, withBlock, forgotReturn]) {
console.log((f.name + '(2, 3)').padEnd(20), '->', f(2, 3));
}
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('a concise body returns its expression', double(21), 42);
test('one parameter needs no parentheses', square(5), 25);
test('no parameters still need a pair', greet(), 'welcome');
test('two parameters, one expression', sum(3, 4), 7);
test('a block body needs the word return', withBlock(2), 5);
test('without it the arrow gives undefined', forgotReturn(2), undefined);
test('length is the declared parameter count', sum.length, 2);
test('and the name is inferred from the variable it was assigned to', double.name, 'double');
The concise body is the reason arrows read well in a map or a filter: no function, no return, no braces. The cost is that adding one debugging line forces you to add braces and a return, and forgetting the second is the single most common arrow bug — the sixth assertion is what it looks like.
The inferred name is worth knowing about: an arrow is anonymous, but assigning it to a const gives it that name, which is what you see in a stack trace.
Answers JS Fundamental 6 in the original set.
7 Returning an object literal from an arrow
easyWrite an arrow that returns { name }. Then explain why the version without parentheses returns undefined rather than failing, and find the version that is a genuine syntax error.
Stuck? Three hints, smallest first
- 1. After
=>, a{starts a function body. The parser has no way to know you meant an object. - 2. Wrap the object in parentheses so it is read as an expression:
() => ({ ... }). - 3.
{ n: n }as a body happens to be legal —n:is read as a label. Two keys is not.
const brokenToObject = (name) => { name }; // a body containing one statement
const toObject = (name) => ({ name }); // parentheses: an expression
const toPair = (key, value) => ({ [key]: value });
const rows = ['Ikeja', 'Yaba'].map((city) => ({ city, state: 'Lagos' }));
const labelled = ((n) => { n: n })(5); // n: is a label, not a key
const parses = (src) => {
try { new Function(src); return 'parsed'; } catch (e) { return e.constructor.name; }
};
console.log('without parentheses:', brokenToObject('Ada'));
console.log('with parentheses :', toObject('Ada'));
console.log('a labelled body :', labelled, '<- legal, and useless');
console.log('two keys in a body :', parses('const f = (n) => { a: 1, b: 2 };'));
console.log('mapped rows :', rows);
Output
7 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 7 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('braces after an arrow are a function body, not an object', brokenToObject('Ada'), undefined);
test('parentheses make it an object literal', toObject('Ada'), { name: 'Ada' });
test('a one-key body parses, because n: is read as a label', labelled, undefined);
test('two keys do not parse at all', parses('const f = (n) => { a: 1, b: 2 };'), 'SyntaxError');
test('a computed key needs the parentheses too', toPair('tier', 'gold'), { tier: 'gold' });
test('which is what makes mapping to objects readable', rows, [{ city: 'Ikeja', state: 'Lagos' }, { city: 'Yaba', state: 'Lagos' }]);
test('shorthand and longhand build the same object', same(toObject('Ada'), { name: 'Ada' }), true);
The trap is that the broken version is not an error. (name) => { name } is a function whose body evaluates name and throws the result away, so you get undefined and no complaint. In a map that produces an array of undefined, and the failure surfaces several layers later.
The third assertion explains why the parser cannot help you: { n: n } really is valid as a body, with n: as a statement label. Only when you write a second key does it become ambiguous enough to reject.
Answers JS Fundamental 7 in the original set.
8 An arrow has no this of its own
fairInside a method, map over an array twice: once with an arrow callback and once with a plain function callback. Report what this is in each, then fix the plain one two ways.
Stuck? Three hints, smallest first
- 1. An arrow does not create a
this. It reads the one from the scope where it was written. - 2. A plain callback is called with no receiver, so under strict mode its
thisisundefined. - 3.
maptakes a second argument that becomesthisfor the callback.binddoes the same job.
const shop = {
name: 'Balogun Market',
stalls: ['A1', 'A2'],
withArrow() {
return this.stalls.map((s) => this.name + ' ' + s);
},
withPlainCallback() {
return this.stalls.map(function () {
return this === undefined ? 'lost this' : 'kept this';
});
},
withThisArg() {
return this.stalls.map(function (s) { return this.name + ' ' + s; }, this);
},
withBind() {
return this.stalls.map(function (s) { return this.name + ' ' + s; }.bind(this));
},
};
function outerThis() {
const arrow = () => this.tag;
return arrow.call({ tag: 'ignored' }); // call cannot touch an arrow
}
const plain = function () { return this.tag; };
const stall = {
code: 'A1',
viaMethod() { return this.code; },
viaArrow: () => (this && this.code) ?? 'not A1',
};
console.log('arrow callback :', shop.withArrow());
console.log('plain callback :', shop.withPlainCallback());
console.log('map(fn, this) :', shop.withThisArg());
console.log('fn.bind(this) :', shop.withBind());
console.log('arrow ignores call :', outerThis.call({ tag: 'outer' }));
console.log('arrow as a method :', stall.viaArrow(), ' method shorthand:', stall.viaMethod());
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('an arrow callback keeps the surrounding this', shop.withArrow(), ['Balogun Market A1', 'Balogun Market A2']);
test('a plain callback loses it, and under strict mode that means undefined', shop.withPlainCallback(), ['lost this', 'lost this']);
test("map's second argument puts it back", shop.withThisArg(), ['Balogun Market A1', 'Balogun Market A2']);
test('so does bind', shop.withBind(), ['Balogun Market A1', 'Balogun Market A2']);
test("call cannot change an arrow's this", outerThis.call({ tag: 'outer' }), 'outer');
test('but it does change a plain function', plain.call({ tag: 'given' }), 'given');
test('an arrow written as a method never sees the object', stall.viaArrow(), 'not A1');
test('while a method shorthand does', stall.viaMethod(), 'A1');
An arrow closes over this the same way it closes over any other variable, which is why it fixed the const self = this era. The flip side is the last two assertions: an arrow as a property of an object literal is written outside any method, so its this is whatever the surrounding scope had — never the object.
So the rule is short. Callback inside a method: arrow. Method on an object or a class that needs this: method shorthand. Never an arrow for the method itself.
Answers JS Fundamental 8 in the original set.
9 Rest parameters instead of arguments
fairSum any number of arguments twice: once with arguments and once with a rest parameter. Then show what arguments means inside an arrow nested in a normal function.
Stuck? Three hints, smallest first
- 1.
argumentsis array-like: it has alengthand indexes, but none of the array methods. - 2. A rest parameter is a real array, so
reduceworks on it directly. - 3. An arrow has no
arguments. Reading it inside one finds the enclosing function’s.
function classic() {
return Array.prototype.slice.call(arguments).reduce((a, b) => a + b, 0);
}
const modern = (...ns) => ns.reduce((a, b) => a + b, 0);
function grab() { return arguments; }
const grabRest = (...xs) => xs;
function outerArguments() {
const peek = () => arguments.length; // the outer function's, not its own
return peek();
}
function bothKinds() {
const peek = (...own) => ({ own: own.length, outer: arguments.length });
return peek('x');
}
function withDefault(a, b = 2) { return arguments.length; }
console.log('classic(1, 2, 3) :', classic(1, 2, 3));
console.log('modern(1, 2, 3) :', modern(1, 2, 3));
console.log('arguments is an array?', Array.isArray(grab(1, 2)), ' rest is?', Array.isArray(grabRest(1, 2)));
console.log('arrow inside a function sees', outerArguments(1, 2, 3), 'arguments');
console.log('both at once :', bothKinds(1, 2, 3));
console.log('withDefault(1) :', withDefault(1), '<- counts what was passed');
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('a plain function has an arguments object', classic(1, 2, 3), 6);
test('a rest parameter does the same job with less ceremony', modern(1, 2, 3), 6);
test('arguments is array-like, not an array', Array.isArray(grab(1, 2)), false);
test('a rest parameter is a real array', Array.isArray(grabRest(1, 2)), true);
test('arguments is iterable, so spread converts it', [...grab(1, 2)], [1, 2]);
test('an arrow has no arguments, so it reads the enclosing one', outerArguments(1, 2, 3), 3);
test('its own rest parameter is separate from that', bothKinds(1, 2, 3), { own: 1, outer: 3 });
test('arguments counts what was passed, not what was declared', withDefault(1), 1);
Array.prototype.slice.call(arguments) was boilerplate in every utility function written before 2015. A rest parameter replaces it with something that is already an array, is named, and shows up in the signature where a reader will look.
The nesting assertion is the subtle one. An arrow inside a function does not shadow arguments, so outerArguments reports three — occasionally handy, more often a surprise. If you want the arrow's own list, give it a rest parameter and the two never collide.
Answers JS Fundamental 9 in the original set.
10 An arrow cannot be newed
fairTry to use new on an arrow, on an old-style constructor function, on a class called without new, and on a method shorthand. Report which combinations the language allows.
Stuck? Three hints, smallest first
- 1. An arrow has no
prototypeproperty, so there is nothing fornewto attach an instance to. - 2. A
classis the mirror image: it insists onnewand throws without it. - 3. Method shorthand (
{ make() {} }) is not a constructor either, though{ make: function () {} }is.
const Point = (x, y) => ({ x, y });
const newedArrow = (() => {
try { return new Point(1, 2); } catch (e) { return e.constructor.name; }
})();
function Legacy(x) { this.x = x; }
class Money { constructor(amount) { this.amount = amount; } }
const classWithoutNew = (() => {
try { return Money(500); } catch (e) { return e.constructor.name; }
})();
const tools = { make() { return 'made'; }, older: function () { return 'made'; } };
const newedMethod = (() => {
try { return new tools.make(); } catch (e) { return e.constructor.name; }
})();
const newedOlder = (() => {
try { new tools.older(); return 'allowed'; } catch (e) { return e.constructor.name; }
})();
console.log('new on an arrow :', newedArrow);
console.log('arrow has a prototype? :', 'prototype' in Point);
console.log('a function declaration? :', typeof Legacy.prototype);
console.log('calling a class plainly :', classWithoutNew);
console.log('new on method shorthand :', newedMethod, ' on a function property:', newedOlder);
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('new on an arrow throws', newedArrow, 'TypeError');
test('because an arrow has no prototype property', 'prototype' in Point, false);
test('a function declaration does have one', typeof Legacy.prototype, 'object');
test('called plainly, the arrow is a perfectly good factory', Point(1, 2), { x: 1, y: 2 });
test('the old constructor pattern still works', new Legacy(3).x, 3);
test('a class refuses to be called without new', classWithoutNew, 'TypeError');
test('method shorthand is not a constructor', newedMethod, 'TypeError');
test('but an old-style function property still is', newedOlder, 'allowed');
test('an arrow does keep length and an inferred name', [Point.length, Point.name], [2, 'Point']);
Three different things go under the name "function", and new is the test that separates them. An arrow is a value you call. A function declaration is callable and constructible, which is why the pre-class world used it for both. A class is constructible only.
The last pair is the one that catches people mid-refactor: rewriting { make: function () {} } as { make() {} } looks like pure tidying, and it silently removes the ability to new it. Same for =>. If something is meant to be constructed, say class.
Answers JS Fundamental 10 in the original set.
11 Template literals: interpolation
easyBuild a sentence from a name and an amount with a template literal. Then check what a template does with an object, an array, null and undefined.
Stuck? Three hints, smallest first
- 1. A hole is
${...}inside backticks, and its value is converted to a string. - 2. The conversion is the same one
String()does, which for a plain object gives[object Object]. - 3. The result of a template is always a string, whatever went into the holes.
const buyer = 'Chinedu';
const owed = 4500;
const line = `${buyer} owes NGN ${owed}`;
console.log('line :', line);
console.log('a number :', `${5}`, typeof `${5}`);
console.log('a plain object :', `${{}}`, '<- String() on an object');
console.log('an array :', `${[1, 2]}`, '<- joined with commas');
console.log('null, undefined:', `${null} and ${undefined}`);
console.log('quotes inside :', `He said "yes"`);
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('a hole is replaced by its value', line, 'Chinedu owes NGN 4500');
test('a number becomes text', `${5}`, '5');
test('a plain object goes through its toString', `${{}}`, '[object Object]');
test('an array joins with commas', `${[1, 2]}`, '1,2');
test('null and undefined print their own names', `${null} and ${undefined}`, 'null and undefined');
test('double quotes need no escaping inside backticks', `He said "yes"`, 'He said "yes"');
test('the result is always a string', typeof `${5}`, 'string');
test('a template with no holes is just a string', `plain`, 'plain');
A hole calls the same string conversion as String(x), which is why an object gives [object Object] and an array gives its join(','). If you want a readable object in a log or a message, ask for JSON.stringify explicitly; a template will not do it for you.
The practical win is quoting. A template can contain both ' and " untouched, so building an HTML attribute or a shell-ish string stops being a game of escapes.
Answers JS Fundamental 11 in the original set.
12 Expressions inside a hole
easyPut real work inside the holes: arithmetic, a method call, a ternary and a nested map. Then prove the holes are evaluated once each, left to right.
Stuck? Three hints, smallest first
- 1. A hole takes any expression, not just a variable name.
- 2. To prove the order, put a function in each hole that records that it ran.
- 3. A template inside a hole is fine, and is how you build a list without string concatenation.
const cart = [
{ item: 'rice', qty: 2, priceNgn: 3200 },
{ item: 'beans', qty: 1, priceNgn: 1800 },
];
const total = cart.reduce((n, row) => n + row.qty * row.priceNgn, 0);
const seen = [];
const mark = (x) => { seen.push(x); return x; };
const built = `${mark('a')}-${mark('b')}`;
console.log('total :', `total ${total}`);
console.log('a method call :', `${cart[0].item.toUpperCase()}`);
console.log('a ternary :', `${cart.length === 1 ? 'line' : 'lines'}`);
console.log('a nested map :', `[${cart.map((r) => `${r.item} x${r.qty}`).join(', ')}]`);
console.log('holes ran in order:', seen, '->', built);
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('arithmetic works inside a hole', `total ${total}`, 'total 8200');
test('so does a method call', `${cart[0].item.toUpperCase()}`, 'RICE');
test('and a ternary', `${cart.length === 1 ? 'line' : 'lines'}`, 'lines');
test('and a template inside a template', `[${cart.map((r) => `${r.item} x${r.qty}`).join(', ')}]`, '[rice x2, beans x1]');
test('holes are evaluated left to right, once each', seen, ['a', 'b']);
test('and glued together in that order', built, 'a-b');
test('an empty hole contributes nothing', `a${''}b`, 'ab');
test('a hole holds an expression, not just a name', `${1 + 2 * 3}`, '7');
Because a hole is an ordinary expression slot, a template is often the clearest way to render a list: map to strings, join with a separator, drop the result into one hole. Reaching for += in a loop instead is longer and easier to get wrong.
The evaluation-order assertion matters as soon as a hole has a side effect — a counter, a logger, something that pops off a queue. Templates evaluate strictly left to right, once per hole, so the order you read is the order that runs.
Answers JS Fundamental 12 in the original set.
13 Multi-line templates and a dedent helper
fairWrite a three-line receipt as one template literal. Then notice that source indentation ends up inside the string, and write dedent to strip the common leading whitespace.
Stuck? Three hints, smallest first
- 1. A newline inside backticks is a real newline in the string. No
\nneeded. - 2. That means the indentation of your source code is part of the value.
- 3. For dedent: drop the leading and trailing blank lines, find the smallest indent across the non-blank lines, then slice that many characters off each.
const receipt = `Ikeja branch
Total: 8200
Thank you`;
const lines = receipt.split('\n');
const indented = () => {
return `
one
two
`;
};
const dedent = (text) => {
const rows = text.replace(/^\n/, '').replace(/\n\s*$/, '').split('\n');
const widths = rows.filter((r) => r.trim() !== '').map((r) => r.length - r.trimStart().length);
const pad = Math.min(...widths);
return rows.map((r) => r.slice(pad)).join('\n');
};
console.log('lines :', lines);
console.log('raw indented :', JSON.stringify(indented()));
console.log('dedented :', JSON.stringify(dedent(indented())));
Output
7 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 7 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('a template keeps its newlines', lines.length, 3);
test('the first line', lines[0], 'Ikeja branch');
test('the last line', lines[2], 'Thank you');
test('the newline is one real character', receipt.includes('\n'), true);
test('source indentation lands in the string', indented().split('\n')[1], ' one');
test('which is why a dedent helper exists', dedent(indented()), 'one\ntwo');
test('a single-line template has no newline at all', `plain`.includes('\n'), false);
Multi-line templates are what killed 'line one\n' + 'line two\n', and they brought one problem with them: whatever indentation makes the source readable is also in the value. For a log line nobody cares; for generated code or a test fixture it matters, so a four-line dedent is worth keeping around.
Math.min(...widths) is the whole trick. The common indent is the smallest one, and blank lines have to be excluded or they would drag it to zero.
Answers JS Fundamental 13 in the original set.
14 Nesting one template inside another
fairRender a row per stall, where an unpaid stall shows the amount owed. Do it with one template nested inside a ternary inside another template, and no string concatenation.
Stuck? Three hints, smallest first
- 1. A hole may contain a ternary, and either branch may be its own template.
- 2.
mapto an array of rows, thenjointhem with a newline inside a single hole. - 3. Nesting is only an expression inside an expression — there is no depth limit and no special syntax.
const stalls = [
{ code: 'A1', owner: 'Ngozi', rentNgn: 12000, paid: true },
{ code: 'A2', owner: 'Emeka', rentNgn: 9000, paid: false },
];
const row = (s) => `${s.code}: ${s.owner} ${s.paid ? 'paid' : `owes ${s.rentNgn}`}`;
const table = `Stalls\n${stalls.map(row).join('\n')}`;
console.log(table);
console.log('---');
console.log('a hole in a template in a hole:', `${`${1 + 1}`}`);
Output
7 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 7 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('the else branch is its own template', row(stalls[1]), 'A2: Emeka owes 9000');
test('the true branch needs no template', row(stalls[0]), 'A1: Ngozi paid');
test('the whole table is three lines', table.split('\n').length, 3);
test('the heading survives', table.split('\n')[0], 'Stalls');
test('rows keep their array order', table.split('\n')[1], 'A1: Ngozi paid');
test('nesting is just an expression, so map and join behave normally', stalls.map(row).length, 2);
test('and a template in a hole in a template in a hole is fine too', `${`${1 + 1}`}`, '2');
Nesting has no special rules, which is the point: a hole is an expression slot, and a template is an expression. Two levels reads well; three usually means the inner piece deserves a name — row here — and the outer template gets shorter as a result.
Reach for a nested template rather than + when a branch has its own text around it. s.paid ? 'paid' : 'owes ' + s.rentNgn works, but the version above keeps the quotes and the spacing in one place.
Answers JS Fundamental 14 in the original set.
15 A tagged template
hardWrite a tag function that wraps every interpolated value in square brackets. Report how many text pieces arrive for how many values, and check whether the pieces object is the same on a second call from the same place in the source.
Stuck? Three hints, smallest first
- 1. A tag is called as
tag`text ${value} text`: the text pieces arrive as the first argument, the values as a rest parameter. - 2. There is always exactly one more piece than there are values, even when that means an empty string at one end.
- 3. The pieces array also carries
.raw, and it is cached per call site rather than rebuilt on each call.
function bracket(strings, ...values) {
return strings.reduce(
(out, part, i) => out + part + (i < values.length ? '[' + values[i] + ']' : ''),
''
);
}
const tagged = bracket`Total ${8200} for ${2} items`;
let captured;
const grab = (strings, ...values) => {
captured = { parts: [...strings], values, raw: typeof strings.raw };
return 'ignored';
};
grab`a${1}b${2}c`;
const siteOf = () => {
let seen;
const tag = (strings) => { seen = strings; return 0; };
tag`same site`;
return seen;
};
const adjacent = ((strings) => strings.length)`${1}${2}`;
const asObject = (strings, ...v) => ({ parts: strings.length, v });
console.log('bracket tag :', tagged);
console.log('captured :', captured);
console.log('adjacent holes:', adjacent, '<- three pieces, two of them empty');
console.log('same call site:', siteOf() === siteOf());
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('the tag receives text and values separately', tagged, 'Total [8200] for [2] items');
test('there is always one more piece than value', captured.parts.length - captured.values.length, 1);
test('the pieces are the text between the holes', captured.parts, ['a', 'b', 'c']);
test('and the values are what the holes evaluated to', captured.values, [1, 2]);
test('adjacent holes leave an empty piece between them', adjacent, 3);
test('the pieces array also carries a raw copy', captured.raw, 'object');
test('it is cached per call site, so the same site hands back the same object', siteOf() === siteOf(), true);
test('a tag may return anything, not only a string', asObject`a${1}`, { parts: 2, v: [1] });
A tag intercepts the pieces before they are glued, which is the only way to do anything trustworthy with interpolated values: escape them for HTML, parameterise them into SQL, localise the text around them. Once a plain template has run there is no way left to tell which characters came from a hole.
The caching assertion is the part people miss. The pieces array is created once per call site and reused, so it is a legitimate cache key — a tag can memoise its compiled output against that object without hashing the strings.
Answers JS Fundamental 15 in the original set.
16 Array destructuring by position
easyPull values out of an array by position: two names, a skipped slot, more names than there are values. Then try the same pattern on a string, on a Set, and on an object that only looks like an array.
Stuck? Three hints, smallest first
- 1. An array pattern matches by position, so
const [a, b] = listtakes index 0 and index 1. - 2. A bare comma skips a position. Two commas skip two.
- 3. The pattern uses the iterator protocol, not indexes — so anything iterable works, and anything not iterable throws.
const attempt = (fn) => {
try { return fn(); } catch (e) { return e.constructor.name; }
};
const basket = ['rice', 'beans', 'yam'];
const [first, second] = basket;
const [, middle] = basket;
const [one, two, three] = ['rice', 'beans']; // three names, two values
const [letterA, letterB] = 'hi'; // a string is iterable
const [smallest, next] = new Set([10, 20, 30]); // so is a Set
const arrayLike = { 0: 'zero', length: 1 }; // looks like an array, is not iterable
const fromArrayLike = attempt(() => {
const [only] = arrayLike;
return only;
});
// The pattern pulls only as many values as it has slots.
const pulled = [];
function* counted() {
for (const n of [1, 2, 3, 4, 5]) { pulled.push(n); yield n; }
}
const [firstPull, secondPull] = counted();
console.log('two names :', first, second);
console.log('skipped slot :', middle);
console.log('spare name :', three, '<- no error, just undefined');
console.log('from a string :', letterA, letterB);
console.log('from a Set :', smallest, next);
console.log('from array-like:', fromArrayLike);
console.log('generator pulls:', pulled, '-> got', firstPull, secondPull);
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('positions map left to right', [first, second], ['rice', 'beans']);
test('a bare comma skips a position', middle, 'beans');
test('a name with nothing to match is undefined', three === undefined, true);
test('the earlier names still fill normally', [one, two], ['rice', 'beans']);
test('a string destructures into characters', [letterA, letterB], ['h', 'i']);
test('a Set destructures in insertion order', [smallest, next], [10, 20]);
test('an array-like object is not iterable, so the pattern throws', fromArrayLike, 'TypeError');
test('only as many values are pulled as the pattern asks for', pulled, [1, 2]);
The last two assertions are the same fact from two sides. An array pattern does not read x[0] and x[1]; it asks for an iterator and takes values off it. That is why { 0: 'zero', length: 1 } throws while a Set, a string or a generator works, and why only two of five values were ever produced.
So destructuring is safe in front of an infinite generator, and it is the wrong tool for a DOM NodeList in an old browser or any other array-like without Symbol.iterator. For those, Array.from first.
Answers JS Fundamental 16 in the original set.
17 Swapping without a temporary
fairSwap two variables in one line, rotate three, and swap two slots of an array in place. Then assign into existing names with an object pattern, and find out what the two syntax traps around that are.
Stuck? Three hints, smallest first
- 1.
[a, b] = [b, a]. The right-hand array is built first, so nothing is lost. - 2. A pattern target need not be a plain name:
[xs[0], xs[2]] = [xs[2], xs[0]]is legal. - 3. A statement starting with
{is read as a block, and a statement starting with[can be swallowed by the line above it if that line has no semicolon.
const parses = (src) => {
try { new Function(src); return 'parsed'; } catch (e) { return e.constructor.name; }
};
const runStrict = (src) => {
try { return new Function('"use strict";\n' + src)(); } catch (e) { return e.constructor.name; }
};
let a = 1, b = 2;
[a, b] = [b, a];
let x = 'A', y = 'B', z = 'C';
[x, y, z] = [z, x, y]; // rotate right, one statement
const stalls = ['A1', 'A2', 'A3'];
[stalls[0], stalls[2]] = [stalls[2], stalls[0]];
let owner, rentNgn;
({ owner, rentNgn } = { owner: 'Ngozi', rentNgn: 12000, extra: 'ignored' });
let m, n;
[m, n] = 'xy'; // any iterable on the right
const asBlock = parses('let o; { o } = { o: 1 };');
const noSemicolon = runStrict('let p = 1, q = 2;\nconst total = 5\n[p, q] = [q, p]\nreturn [p, q];');
const withSemicolon = runStrict('let p = 1, q = 2;\nconst total = 5;\n[p, q] = [q, p];\nreturn [p, q];');
console.log('swap :', [a, b]);
console.log('rotate :', [x, y, z]);
console.log('in place :', stalls);
console.log('into names :', owner, rentNgn);
console.log('unparenthesised object pattern:', asBlock);
console.log('line above missing a semicolon:', noSemicolon, '<- ran, then failed');
console.log('same code with the semicolon :', withSemicolon);
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('one statement swaps two names', [a, b], [2, 1]);
test('and three rotate just as easily', [x, y, z], ['C', 'A', 'B']);
test('a target may be an array slot rather than a name', stalls, ['A3', 'A2', 'A1']);
test('an object pattern in parentheses assigns to existing names', [owner, rentNgn], ['Ngozi', 12000]);
test('keys the pattern did not name are simply ignored', typeof extra, 'undefined');
test('without the parentheses the braces are read as a block', asBlock, 'SyntaxError');
test('the right-hand side may be any iterable', [m, n], ['x', 'y']);
test('a previous line with no semicolon swallows the pattern', noSemicolon, 'TypeError');
test('with the semicolon the swap runs', withSemicolon, [2, 1]);
The swap works because the right-hand side is evaluated completely before any target is written, so [a, b] = [b, a] needs no temporary. Same for the three-way rotate, and same when the targets are array slots.
Both traps are about how a statement starts. { owner } = obj begins with a brace, so the parser sees a block and refuses; the parentheses force it to read an expression. The second is worse, because it is not a syntax error: const total = 5 with no semicolon runs straight into the next line as 5[p, q] = [q, p], which parses, and then throws at runtime. This is the concrete reason the "semicolons are optional" style asks you to start such lines with one.
Answers JS Fundamental 17 in the original set.
18 Defaults in an array pattern
fairGive array pattern positions defaults, then work out exactly which incoming values trigger them: a missing slot, an explicit undefined, null, 0. Also check whether a default expression runs when it is not needed.
Stuck? Three hints, smallest first
- 1. A default fires when the matched value is
undefined— and only then. - 2.
nullis a value, so it is kept. So are0,''andfalse. - 3. Defaults are evaluated lazily and left to right, so a later one can refer to an earlier binding.
let builds = 0;
const build = () => { builds++; return 'built'; };
const [missing = 'fallback'] = [];
const [explicit = 'fallback'] = [undefined];
const [nulled = 'fallback'] = [null];
const [zero = 'fallback'] = [0];
const [blank = 'fallback'] = [''];
const [width = 2, height = width * 3] = []; // a later default reads an earlier name
const [, secondOnly = 'filled'] = ['first']; // the skipped slot is unaffected
const [supplied = build()] = ['given']; // build() must not run
const [absent = build()] = []; // this one must
console.log('missing slot :', missing);
console.log('explicit undef :', explicit);
console.log('null :', nulled, '<- kept');
console.log('zero and blank :', zero, JSON.stringify(blank), '<- also kept');
console.log('chained default:', width, height);
console.log('builds counted :', builds, '-> supplied:', supplied, 'absent:', absent);
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 default fills a position that has no value', missing, 'fallback');
test('an explicit undefined fires it too', explicit, 'fallback');
test('null does not, because null is a value', nulled, null);
test('nor does zero', zero, 0);
test('nor an empty string', blank, '');
test('a later default may read an earlier binding', [width, height], [2, 6]);
test('a skipped position takes its own default', secondOnly, 'filled');
test('a default expression is skipped when a value arrived', supplied, 'given');
test('so it ran exactly once, for the position that needed it', builds, 1);
undefined triggers a default; null does not. That single rule is behind a whole class of bug: an API that returns null for "no value" defeats every default you write, and opts.retries = 0 is a real setting that || would throw away. Defaults respect 0, '' and false; || does not. Exercise 40 has the operator for the null case.
Laziness is worth knowing when the default is expensive or has a side effect. const [id = nextId()] = args does not burn an id when one was passed.
Answers JS Fundamental 18 in the original set.
19 A rest element in an array pattern
fairSplit a list into a head and a tail with ..., confirm the tail is a genuinely new array, and find the three things the parser will not let a rest element do.
Stuck? Three hints, smallest first
- 1.
const [head, ...tail] = xs. The tail is a real array, empty if nothing is left. - 2. A rest element must be the last thing in the pattern — no default, no trailing comma.
- 3. The rest position may itself hold a pattern:
[a, ...[b, c]].
const parses = (src) => {
try { new Function(src); return 'parsed'; } catch (e) { return e.constructor.name; }
};
const [head, ...tail] = [1, 2, 3];
const [only, ...none] = ['solo'];
const [...letters] = 'Yaba';
const source = [1, 2, 3];
const [, ...copiedTail] = source;
copiedTail.push(4); // the source must not notice
const [firstOfSet, ...restOfSet] = new Set(['a', 'b', 'c']);
const [p, ...[q, r]] = [1, 2, 3]; // a pattern in the rest position
const notLast = parses('const [...r, last] = [1, 2];');
const withDefault = parses('const [...r = []] = [];');
const trailingComma = parses('const [a, ...b,] = [1, 2];');
console.log('head and tail :', head, tail);
console.log('nothing left :', none, 'length', none.length);
console.log('from a string :', letters);
console.log('source intact :', source, 'tail now', copiedTail);
console.log('nested rest :', p, q, r);
console.log('rest not last :', notLast, '| with a default:', withDefault, '| trailing comma:', trailingComma);
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('rest collects everything after the named positions', tail, [2, 3]);
test('and is an empty array when there is nothing left', none, []);
test('a string spreads into its characters', letters, ['Y', 'a', 'b', 'a']);
test('the rest array is new, so pushing to it leaves the source alone', source.length, 3);
test('any iterable works, so a Set splits the same way', [firstOfSet, restOfSet], ['a', ['b', 'c']]);
test('the rest position may hold another pattern', [p, q, r], [1, 2, 3]);
test('a rest element must come last', notLast, 'SyntaxError');
test('it may not carry a default', withDefault, 'SyntaxError');
test('and it may not be followed by a comma', trailingComma, 'SyntaxError');
The rest element is where destructuring earns its keep on a list of unknown length: const [command, ...args] = input in one line, with args already an array rather than an arguments object or a slice(1) call.
The three refusals all follow from one rule: rest means "the remainder", so nothing can come after it and it can never be absent — the worst case is [], which is why a default would be meaningless. The trailing comma is the one that catches people, because every other list in JavaScript tolerates one.
Answers JS Fundamental 19 in the original set.
20 Object destructuring and renaming
easyPull properties out of an object by name, rename two of them on the way out, ask for one that is not there, and pull an awkward key that is not a valid identifier.
Stuck? Three hints, smallest first
- 1. An object pattern matches by key, so the order you write the names in is irrelevant.
- 2.
{ from: to }renames: the key on the left, the new variable on the right. - 3. A key with a hyphen or a space has to be quoted, and then it must be renamed — there is no legal variable of that spelling.
const stall = { code: 'A1', owner: 'Ngozi', rentNgn: 12000 };
const { owner, code } = stall; // order does not matter
const { code: id, rentNgn: rent } = stall; // renamed on the way out
const { missing } = stall;
const { code: alias1, code: alias2 } = stall; // the same key twice
const { length } = 'Ikeja'; // a string is boxed first
const ledger = { 'rent-2024': 5000, 'due date': '2024-03-01' };
const { 'rent-2024': rent2024, 'due date': dueDate } = ledger;
console.log('by name :', owner, code);
console.log('renamed :', id, rent);
console.log('absent key :', missing);
console.log('twice :', alias1, alias2);
console.log('from a string :', length);
console.log('quoted keys :', rent2024, dueDate);
console.log('source intact :', stall);
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('properties are matched by key, not by position', [owner, code], ['Ngozi', 'A1']);
test('a colon renames the binding', [id, rent], ['A1', 12000]);
test('a key that is not there gives undefined rather than an error', missing === undefined, true);
test('the same key may be taken twice under two names', [alias1, alias2], ['A1', 'A1']);
test('a primitive is boxed, so its properties destructure', length, 5);
test('an awkward key is quoted and renamed', [rent2024, dueDate], [5000, '2024-03-01']);
test('destructuring reads, so the source keeps every key', Object.keys(stall), ['code', 'owner', 'rentNgn']);
test('including the ones that were renamed', 'code' in stall, true);
Renaming reads backwards the first few times: { rentNgn: rent } takes the property rentNgn and calls it rent. The key is always on the left, the new name always on the right — the same order as in an object literal, which is what the pattern is mirroring.
Asking for a key that does not exist gives undefined instead of throwing, which is convenient and also why a typo in a pattern is silent. The one thing that does throw is the object itself being null or undefined; exercise 25 covers that.
Answers JS Fundamental 20 in the original set.
21 Defaults in an object pattern
fairGive object pattern keys defaults and check which values trigger them. Then combine renaming with a default, chain one default off another, and put a default on an intermediate object so a missing nesting does not throw.
Stuck? Three hints, smallest first
- 1. Same rule as the array form: the default fires on
undefinedonly. - 2. Renaming and defaulting combine as
{ key: newName = fallback }. - 3. A default may sit on any part of the pattern, including the object in the middle of a nesting.
const opts = { retries: 0, label: null, area: undefined };
const { retries = 3 } = opts;
const { label = 'unnamed' } = opts;
const { area = 'Yaba' } = opts;
const { branch = 'Ikeja' } = opts; // key absent entirely
const { rentNgn: rent = 0 } = {}; // rename and default together
const { width = 2, height = width * 3 } = {}; // chained
const { limits: { daily } = { daily: 50000 } } = {}; // the nesting is missing
const { limits: { daily: dailyPresent } = { daily: 0 } } = { limits: { daily: 999 } };
console.log('zero kept :', retries);
console.log('null kept :', label);
console.log('undefined value:', area, '<- default fired');
console.log('absent key :', branch);
console.log('rename+default :', rent);
console.log('chained :', width, height);
console.log('nested default :', daily, '| ignored when present:', dailyPresent);
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('a default fills a key that is not there', branch, 'Ikeja');
test('and a key whose value is undefined', area, 'Yaba');
test('but never one that is null', label, null);
test('nor a zero, which is a perfectly good setting', retries, 0);
test('rename and default combine as key: name = fallback', rent, 0);
test('a later default may read an earlier binding', [width, height], [2, 6]);
test('a default on the middle object survives a missing nesting', daily, 50000);
test('and is ignored when the nesting is there', dailyPresent, 999);
{ rentNgn: rent = 0 } packs three decisions into eight characters: which key to read, what to call it, and what to use when it is absent. Read it left to right and it stays clear; write it the other way round ({ rent = 0: rentNgn }) and it is a syntax error.
The default on the intermediate object is the useful trick here. { limits: { daily } = {} } turns a nested read that would have thrown into one that quietly yields undefined — the same job optional chaining does for a dotted read.
Answers JS Fundamental 21 in the original set.
22 Nested destructuring
hardFrom one order object, reach two levels down for a buyer's area, into an array of lines for the first item, and rename at depth. Then confirm what nesting does not bind, and what happens when a middle object is missing.
Stuck? Three hints, smallest first
- 1. A pattern nests wherever the data nests:
{ buyer: { address: { area } } }. - 2. Where the data has an array, the pattern has an array, and a bare comma still skips an element.
- 3. Nesting binds only the leaves. The intermediate names are part of the path, not variables.
const attempt = (fn) => {
try { return fn(); } catch (e) { return e.constructor.name; }
};
const order = {
id: 7,
buyer: { name: 'Emeka', address: { area: 'Yaba', city: 'Lagos' } },
lines: [
{ item: 'rice', qty: 2 },
{ item: 'beans', qty: 1 },
],
};
const { buyer: { name, address: { area } } } = order;
const { lines: [{ item: firstItem, qty: firstQty }] } = order;
const { lines: [, { item: secondItem }] } = order;
const { buyer: { name: buyerName } } = order;
const missingMiddle = attempt(() => {
const { seller: { name: sellerName } } = order;
return sellerName;
});
const { seller: { name: guardedSeller } = {} } = order;
console.log('two levels down:', name, area);
console.log('into an array :', firstItem, firstQty);
console.log('skipping one :', secondItem);
console.log('renamed deep :', buyerName);
console.log('buyer bound? :', typeof buyer, '<- the path is not a variable');
console.log('missing middle :', missingMiddle);
console.log('guarded :', guardedSeller);
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('a nested pattern reaches the leaf directly', [name, area], ['Emeka', 'Yaba']);
test('an array inside an object takes an array pattern', [firstItem, firstQty], ['rice', 2]);
test('and a bare comma still skips an element at depth', secondItem, 'beans');
test('renaming works at any depth', buyerName, 'Emeka');
test('the intermediate name is a path, not a binding', typeof buyer, 'undefined');
test('a missing middle object throws', missingMiddle, 'TypeError');
test('a default on that object makes the read safe', guardedSeller === undefined, true);
test('and the source object is never touched', order.buyer.address.city, 'Lagos');
The fifth assertion is the one that surprises people: const { buyer: { name } } = order gives you name and not buyer. If you want both, ask for both — const { buyer, buyer: { name } } = order is legal and does exactly that.
The sixth is the risk. Each level of nesting is another property read, so one absent middle object throws a TypeError with a message about the leaf, not about the level that was actually missing. Past two levels, either put = {} defaults on the path or read it in steps; a five-level pattern over data you did not create is a crash waiting for a bad payload.
Answers JS Fundamental 22 in the original set.
23 Destructuring in a parameter list
fairReplace a long positional signature with a single options object destructured in the parameter list, with defaults. Make it callable with no argument at all, then show what breaks if you leave off the = {}.
Stuck? Three hints, smallest first
- 1. The pattern goes where the parameter name would:
({ a, b = 1 }) => .... - 2. To allow a call with no argument, give the whole parameter a default:
({ a } = {}) => .... - 3.
fn.lengthcounts parameters before the first default, and a destructured parameter counts as one.
const attempt = (fn) => {
try { return fn(); } catch (e) { return e.constructor.name; }
};
const receipt = ({ branch = 'Ikeja', totalNgn = 0, currency = 'NGN' } = {}) =>
`${branch}: ${currency} ${totalNgn}`;
const noWholeDefault = ({ branch }) => branch;
const calledBare = attempt(() => noWholeDefault());
const distance = ([x1, y1], [x2, y2]) => Math.abs(x1 - x2) + Math.abs(y1 - y2);
const rentOf = ({ rentNgn: rent }) => rent;
const label = ({ code }, ...tags) => code + ':' + tags.length;
console.log('no argument :', receipt());
console.log('keys, any order:', receipt({ totalNgn: 8200, branch: 'Yaba' }));
console.log('partial :', receipt({ totalNgn: 500 }));
console.log('no = {} :', calledBare);
console.log('array pattern :', distance([0, 0], [3, 4]));
console.log('arity :', receipt.length, noWholeDefault.length, distance.length, label.length);
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('with a default on the whole parameter it can be called bare', receipt(), 'Ikeja: NGN 0');
test('named options arrive in any order', receipt({ totalNgn: 8200, branch: 'Yaba' }), 'Yaba: NGN 8200');
test('a partial object keeps the remaining defaults', receipt({ totalNgn: 500 }), 'Ikeja: NGN 500');
test('without that default an omitted argument throws', calledBare, 'TypeError');
test('array patterns work in a parameter list too', distance([0, 0], [3, 4]), 7);
test('so does renaming', rentOf({ rentNgn: 12000 }), 12000);
test('a destructured parameter counts as one towards length', noWholeDefault.length, 1);
test('and a default on it drops the count to zero', receipt.length, 0);
test('a rest parameter is never counted', label.length, 1);
This is the pattern that killed the positional boolean. render(data, true, false, true) is unreadable at the call site and unextendable in the signature; render(data, { animate: true, cache: false }) reads itself, and every option gets a default in one place where a reader will look for it.
The = {} is not optional decoration. Without it, a pattern parameter is destructuring undefined the moment somebody calls the function with no arguments, which throws. Write it every time the options object is genuinely optional.
Answers JS Fundamental 23 in the original set.
24 Object rest, and omitting a key
fairUse ... in an object pattern to make a copy of a record without one sensitive field. Then check what the rest object does and does not pick up: key order, inherited properties, non-enumerable ones, and nested objects.
Stuck? Three hints, smallest first
- 1.
const { pin, ...safe } = rownames the key you want gone and keeps the rest. - 2. The rest object takes own enumerable properties only — the same set
Object.keysreports. - 3. It is one level deep, like every other spread.
const parses = (src) => {
try { new Function(src); return 'parsed'; } catch (e) { return e.constructor.name; }
};
const row = { id: 9, name: 'Emeka', pin: '1234', area: 'Yaba' };
const { pin, ...safe } = row;
const { name: who, ...others } = row; // a renamed pick is still omitted
const nested = { id: 1, meta: { seen: true } };
const { id, ...restOfNested } = nested;
const proto = { inherited: 'from the prototype' };
const child = Object.create(proto);
child.own = 'own value';
const { ...ownOnly } = child;
const hidden = {};
Object.defineProperty(hidden, 'secret', { value: 'not enumerable', enumerable: false });
hidden.shown = 'enumerable';
const { ...visible } = hidden;
const notLast = parses('const { ...r, a } = {};');
console.log('without the pin :', safe);
console.log('key order kept :', Object.keys(safe));
console.log('source intact :', 'pin' in row);
console.log('renamed pick :', who, '->', Object.keys(others));
console.log('nested shared :', restOfNested.meta === nested.meta);
console.log('inherited :', ownOnly, '| non-enumerable:', visible);
console.log('rest not last :', notLast);
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('rest gives back everything the pattern did not name', safe, { id: 9, name: 'Emeka', area: 'Yaba' });
test('in the order the source declared them', Object.keys(safe), ['id', 'name', 'area']);
test('the source is not modified', 'pin' in row, true);
test('a renamed pick is omitted from the rest as well', Object.keys(others), ['id', 'pin', 'area']);
test('the rest object is shallow, so a nested object is shared', restOfNested.meta === nested.meta, true);
test('inherited properties are not copied', ownOnly, { own: 'own value' });
test('nor are non-enumerable ones', visible, { shown: 'enumerable' });
test('the rest property must come last', notLast, 'SyntaxError');
const { password, ...safe } = user is the clean way to strip a field before logging or returning a record, and it is better than delete because the original is left alone. Note it binds password as a variable you probably do not want; some linters will flag it as unused, which is the price.
The set it copies is exactly Object.keys: own, enumerable, string-keyed. Anything on the prototype, anything hidden with enumerable: false, and any getter's definition (the value is read once and frozen into a plain property) does not survive the trip.
Answers JS Fundamental 24 in the original set.
25 Destructuring nothing, and computed keys
hardFind out what destructuring null and undefined does, whether an empty pattern is any safer, and what happens on a number or a string. Then read a property whose name is only known at runtime.
Stuck? Three hints, smallest first
- 1. Destructuring converts the right-hand side to an object first, and
nullandundefinedcannot be converted. - 2. A primitive can be, so
const { length } = 'Lagos'works through the wrapper object. - 3. Square brackets in a pattern take an expression as the key:
const { [name]: value } = obj.
const attempt = (fn) => {
try { return fn(); } catch (e) { return e.constructor.name; }
};
const fromNull = attempt(() => { const { a } = null; return a; });
const fromUndefined = attempt(() => { const { a } = undefined; return a; });
const emptyOnNull = attempt(() => { const {} = null; return 'no throw'; });
const emptyOnString = attempt(() => { const {} = 'anything'; return 'no throw'; });
const arrayOnNull = attempt(() => { const [only] = null; return only; });
const { length: townLength } = 'Lagos';
const { toFixed } = 12000;
const field = 'rentNgn';
const { [field]: rent } = { rentNgn: 12000 };
const { [field.toUpperCase()]: shouted = 'none' } = { RENTNGN: 'loud' };
let reads = 0;
const keyOf = () => { reads++; return 'code'; };
const { [keyOf()]: code } = { code: 'A1' };
console.log('null :', fromNull, '| undefined:', fromUndefined);
console.log('empty {} :', emptyOnNull, 'on null,', emptyOnString, 'on a string');
console.log('array on null:', arrayOnNull);
console.log('primitives :', townLength, typeof toFixed);
console.log('computed :', rent, '| with an expression:', shouted);
console.log('key reads :', reads, '->', code);
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 object pattern on null throws', fromNull, 'TypeError');
test('and on undefined', fromUndefined, 'TypeError');
test('an empty pattern is no protection at all', emptyOnNull, 'TypeError');
test('though on a primitive it is legal and binds nothing', emptyOnString, 'no throw');
test('an array pattern on null throws for the same reason', arrayOnNull, 'TypeError');
test('a primitive is wrapped, so its properties come out', townLength, 5);
test('methods included', typeof toFixed, 'function');
test('a computed key reads the property an expression names', rent, 12000);
test('and the key expression is evaluated exactly once', [reads, code], [1, 'A1']);
Every pattern begins with a coercion to object. null and undefined are the two values that cannot be coerced, so they throw — and const {} = null throws too, which shows the failure is the conversion, not the properties. That is why const { data } = await res.json() is a real hazard: one empty body and the destructure is the crash site.
Computed keys make a pattern dynamic, but the name has to be known by the time the line runs, and the binding still needs a name you write by hand. If keys are genuinely arbitrary, you want Object.entries or a Map, not a pattern.
Answers JS Fundamental 25 in the original set.
26 Spreading into an array literal
easyJoin and copy arrays with ... inside an array literal, then spread a string, a Set and a Map. Check what a spread does to a hole, and what it refuses to do to a plain object.
Stuck? Three hints, smallest first
- 1.
[...a, ...b]replacesa.concat(b), and a spread may sit anywhere in the literal. - 2. Anything with an iterator can be spread: strings, Sets, Maps, generators,
arguments. - 3. A plain object has no iterator. Spreading one into an array throws; into an object literal it is fine.
const attempt = (fn) => {
try { return fn(); } catch (e) { return e.constructor.name; }
};
const week1 = [1200, 900];
const week2 = [1500];
const joined = [...week1, ...week2];
const copy = [...week1];
copy.push(0); // must not reach week1
const surrounded = [0, ...week1, 9999];
const letters = [...'Yaba'];
const unique = [...new Set([1, 1, 2, 2, 3])];
const pairs = [...new Map([['A1', 12000]])];
const holey = [1, , 3]; // index 1 is a hole
const filled = [...holey];
const astral = 'a' + String.fromCodePoint(0x1d7d8); // one letter, one astral digit
const intoArray = attempt(() => [...{ code: 'A1' }]);
console.log('joined :', joined);
console.log('copy :', copy, 'source still', week1);
console.log('anywhere :', surrounded);
console.log('a string :', letters);
console.log('a Set :', unique, '| a Map:', pairs);
console.log('hole before:', 1 in holey, 'after:', 1 in filled);
console.log('code units :', astral.length, 'code points:', [...astral].length);
console.log('an object :', intoArray);
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('two arrays go into one literal', joined, [1200, 900, 1500]);
test('a spread copy is a new array, so the source is untouched', week1, [1200, 900]);
test('a spread may sit anywhere in the literal', surrounded, [0, 1200, 900, 9999]);
test('a string spreads into characters', letters, ['Y', 'a', 'b', 'a']);
test('a Set spreads de-duplicated, in insertion order', unique, [1, 2, 3]);
test('a Map spreads into entry pairs', pairs, [['A1', 12000]]);
test('a hole becomes a real undefined element', [1 in holey, 1 in filled], [false, true]);
test('spread walks code points, so a surrogate pair stays whole', [astral.length, [...astral].length], [3, 2]);
test('a plain object cannot be spread into an array', intoArray, 'TypeError');
The hole assertion is the one that matters in practice. [1, , 3] has no index 1 at all, so map and forEach skip it; after a spread the slot exists and holds undefined, so they visit it. Spread is therefore a hole-flattening operation, which is usually what you want and occasionally a change in behaviour you did not ask for.
The code-point assertion is the argument for [...str] over str.split(''). split('') cuts between UTF-16 units and will slice an emoji or a mathematical digit in half; the iterator does not.
Answers JS Fundamental 26 in the original set.
27 Spread copies one level
fairCopy an object with { ...obj }, then write through the copy at the top level and at a nested level and report which writes leaked back into the source. Then make a copy that does not leak.
Stuck? Three hints, smallest first
- 1. Spread copies property values. For an object or array value, that value is a reference.
- 2. So
copy.owner === original.owner, and a write through either is visible from both. - 3.
structuredClonedoes a deep copy where it exists;JSON.parse(JSON.stringify(x))is the old fallback.
const deepCopy = (o) =>
typeof structuredClone === 'function' ? structuredClone(o) : JSON.parse(JSON.stringify(o));
const stall = { code: 'A1', owner: { name: 'Ngozi' }, tags: ['fabric'] };
const copy = { ...stall };
copy.code = 'A2'; // top level: private to the copy
copy.owner.name = 'Emeka'; // one level down: shared
copy.tags.push('lace'); // same story for an array
const rows = [{ qty: 1 }];
const rowsCopy = [...rows];
rowsCopy[0].qty = 99; // array spread is shallow too
const clone = deepCopy(stall);
clone.owner.name = 'Bola';
console.log('source code :', stall.code, '| copy:', copy.code);
console.log('shared owner :', stall.owner.name, copy.owner === stall.owner);
console.log('shared tags :', stall.tags);
console.log('array of rows :', rows[0].qty);
console.log('deep clone :', clone.owner.name, 'source still', stall.owner.name);
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('the copy is a different object at the top level', copy === stall, false);
test('so a top-level write stays in the copy', stall.code, 'A1');
test('but a nested object is the very same object', copy.owner === stall.owner, true);
test('so writing through the copy changes the source', stall.owner.name, 'Emeka');
test('and pushing to a nested array does the same', stall.tags, ['fabric', 'lace']);
test('array spread is shallow in exactly the same way', rows[0].qty, 99);
test('a deep copy gives a separate nested object', clone.owner === stall.owner, false);
test('so writing to the clone leaves the source alone', stall.owner.name, 'Emeka');
"Copy" in { ...obj } means copy the top row of key-value pairs. Every value that happens to be a reference is copied as a reference, so the two objects share everything below the first level. This is the single most common way that "immutable" state in a reducer turns out to be mutable.
structuredClone is the right deep copy now: it handles Map, Set, Date and cycles, and throws on a function. The JSON round trip silently drops functions and undefined, turns a Date into a string, and dies on a cycle — fine for plain data, wrong as a habit.
Answers JS Fundamental 27 in the original set.
28 Merging objects, and which key wins
fairMerge defaults with caller-supplied options using object spread. Establish which side wins, what a literal key next to a spread does, and what an explicit undefined in the caller's object does to your default.
Stuck? Three hints, smallest first
- 1. Later wins.
{ ...defaults, ...given }letsgivenoverride. - 2. A literal key participates in the same left-to-right order as the spreads around it.
- 3. A property whose value is
undefinedis still a property, so it still overrides.
const defaults = { branch: 'Ikeja', currency: 'NGN', discount: 0 };
const given = { branch: 'Yaba', discount: undefined };
const merged = { ...defaults, ...given };
const reversed = { ...given, ...defaults };
const literalAfter = { ...defaults, branch: 'Lekki' };
const literalBefore = { branch: 'Lekki', ...defaults };
const clean = Object.fromEntries(Object.entries(given).filter(([, v]) => v !== undefined));
const cleaned = { ...defaults, ...clean };
const viaAssign = Object.assign({}, defaults, given);
console.log('merged :', merged, '<- discount is gone from the log');
console.log('key list :', Object.keys(merged), '<- but the key is there');
console.log('reversed :', reversed.branch);
console.log('literal after :', literalAfter.branch, '| before:', literalBefore.branch);
console.log('cleaned :', cleaned);
console.log('Object.assign :', viaAssign.branch);
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 later spread wins', merged.branch, 'Yaba');
test('swap the order and the other side wins', reversed.branch, 'Ikeja');
test('a literal key after a spread overrides it', literalAfter.branch, 'Lekki');
test('a literal key before a spread is overridden by it', literalBefore.branch, 'Ikeja');
test('an explicit undefined overrides the default too', merged.discount === undefined, true);
test('the key really is present, whatever JSON shows', ['discount' in merged, JSON.stringify(merged).includes('discount')], [true, false]);
test('keys keep first-seen order regardless of which side supplied the value', Object.keys(merged), ['branch', 'currency', 'discount']);
test('dropping the undefined values first preserves the default', cleaned.discount, 0);
test('Object.assign into a fresh object merges identically', viaAssign.branch, 'Yaba');
The undefined case is the trap, and it is common: a caller builds an options object from variables, one of them is unset, and the property arrives as undefined. Spread does not care that the value is missing — it is a key, it is later, it wins, and your default is gone. Note the assertion that JSON.stringify hides it: the log looks like the key was never there.
Two fixes. Filter the undefined values out before merging, as above, or destructure with defaults instead — const { discount = 0 } = given fires on undefined, which is precisely the case spread gets wrong.
Answers JS Fundamental 28 in the original set.
29 Spreading into a call
fairPass an array to a function that expects separate arguments, mix a spread with fixed arguments, spread into new, and find the one thing apply could not do. Then check what happens when the array is very large.
Stuck? Three hints, smallest first
- 1.
f(...xs)is the argument-list form of the same three dots, and replacesf.apply(null, xs). - 2. It may appear anywhere in the argument list, more than once, and alongside ordinary arguments.
- 3. Every argument lives on the stack, so an enormous spread can exceed the engine’s limit — a reduce never can.
const attempt = (fn) => {
try { return fn(); } catch (e) { return e.constructor.name; }
};
const nums = [3, 41, 7];
const label = (a, b, c) => [a, b, c].join('-');
class Money {
constructor(major, minor) { this.kobo = major * 100 + minor; }
}
let taken = 0;
function* three() {
for (const n of [1, 2, 3]) { taken++; yield n; }
}
const fromGenerator = label(...three());
const big = Array.from({ length: 200000 }, (_, i) => i);
const spreadBig = attempt(() => Math.max(...big));
const outcome = typeof spreadBig === 'number' ? 'a number' : spreadBig;
const byReduce = big.reduce((m, x) => (x > m ? x : m), -Infinity);
console.log('spread :', Math.max(...nums), '| apply:', Math.max.apply(null, nums));
console.log('mixed :', label(...['x', 'y'], 'z'), label('x', ...['y', 'z']));
console.log('any iterable:', Math.min(...new Set([9, 4, 4])));
console.log('with new :', new Money(...[120, 50]).kobo);
console.log('generator :', fromGenerator, 'values taken:', taken);
console.log('200k spread:', outcome, '| the same answer by reduce:', byReduce);
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('spread turns an array into an argument list', Math.max(...nums), 41);
test('which is what apply was for', Math.max.apply(null, nums), 41);
test('a spread may be mixed with fixed arguments, at either end', [label(...['x', 'y'], 'z'), label('x', ...['y', 'z'])], ['x-y-z', 'x-y-z']);
test('any iterable can be spread into a call', Math.min(...new Set([9, 4, 4])), 4);
test('spreading nothing is simply a call with no arguments', Math.max(...[]) === -Infinity, true);
test('unlike apply, spread works with new', new Money(...[120, 50]).kobo, 12050);
test('a generator is drained exactly once', [fromGenerator, taken], ['1-2-3', 3]);
test('a huge spread is engine-dependent: a number or a RangeError', ['a number', 'RangeError'].includes(outcome), true);
test('a reduce has no argument limit and gives the same answer', byReduce, 199999);
Math.max(...xs) is the idiom, and it is also the classic outage. Arguments are pushed on the stack, so somewhere above a hundred thousand or so — the exact number varies by engine and by how much stack is left — the call throws a RangeError instead of returning. This assertion accepts either result on purpose, because pinning it to one number would be a claim about an engine rather than about JavaScript. For a list of unknown size, reduce.
The new assertion is the reason spread is not just sugar for apply. apply calls; it cannot construct. Before spread you needed Reflect.construct or a bind trick to hand an array to a constructor.
Answers JS Fundamental 29 in the original set.
30 Forwarding arguments with rest and spread
fairWrite a traced wrapper that records what it was called with and passes everything through unchanged, and a partial that pre-fills the first arguments. Then check what wrapping costs you: arity, and the receiver.
Stuck? Three hints, smallest first
- 1. Rest on the way in, spread on the way out:
(...args) => fn(...args)is a transparent relay. - 2.
partialis the same idea with two lists:(fn, ...bound) => (...rest) => fn(...bound, ...rest). - 3. An arrow wrapper cannot forward
this. Afunctionwrapper withapplycan.
const attempt = (fn) => {
try { return fn(); } catch (e) { return e.constructor.name; }
};
const sum = (...ns) => ns.reduce((a, b) => a + b, 0);
const seen = [];
const traced = (fn) => (...args) => { seen.push(args); return fn(...args); };
const tracedSum = traced(sum);
const partial = (fn, ...bound) => (...rest) => fn(...bound, ...rest);
const rentFor = (branch, code, amount) => `${branch}/${code}: ${amount}`;
const ikeja = partial(rentFor, 'Ikeja');
const keepsThis = (fn) => function (...args) { return fn.apply(this, args); };
const till = { total: 100, take(n) { return this.total - n; } };
till.takeWrapped = keepsThis(till.take);
const bareMethod = till.take;
const lostReceiver = attempt(() => bareMethod(40));
console.log('through the relay:', tracedSum(1, 2, 3), tracedSum(), tracedSum(...[4, 5]));
console.log('what it saw :', seen);
console.log('partial :', ikeja('A1', 12000));
console.log('arity :', rentFor.length, '->', ikeja.length, '| sum:', sum.length);
console.log('receiver kept :', till.takeWrapped(40), '| lost:', lostReceiver);
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('the relay passes every argument through untouched', tracedSum(1, 2, 3), 6);
test('and it saw exactly what was passed', seen[0], [1, 2, 3]);
test('rest and spread are inverses, so no arguments is still no arguments', tracedSum(), 0);
test('a spread at the call site meets a rest at the definition', tracedSum(...[4, 5]), 9);
test('partial application pre-fills from the left', ikeja('A1', 12000), 'Ikeja/A1: 12000');
test('but wrapping destroys the declared arity', [rentFor.length, ikeja.length], [3, 0]);
test('a function wrapper forwards the receiver too', till.takeWrapped(40), 60);
test('while a bare method reference loses it and throws in strict mode', lostReceiver, 'TypeError');
(...args) => fn(...args) is the whole pattern behind logging, timing, memoising and rate-limiting wrappers: rest collects an argument list of unknown shape, spread hands it back out in the same order. Nothing is inspected, so it works for any function.
Two things do not survive. fn.length becomes 0, which breaks any library that inspects arity — Express deciding whether a handler is error-handling middleware, for instance. And an arrow wrapper has no this to forward, so wrapping a method needs function plus apply. The TypeError on the last line is the same hazard from the other direction: pull a method off its object in strict mode and this is undefined.
Answers JS Fundamental 30 in the original set.
31 Default parameters run at call time
fairGive a parameter a default that calls a function, and prove the call happens per invocation rather than once at definition. Then compare = [] with a default that names an outer array, and confirm which arguments trigger a default at all.
Stuck? Three hints, smallest first
- 1. The default expression is evaluated on each call that needs it, in the function’s own scope.
- 2. So
= []is a fresh array every time — unlike Python, where a mutable default is shared. - 3.
undefinedtriggers a default;null,0and''do not.
let issued = 0;
const nextId = () => ++issued;
const ticket = (label, id = nextId()) => `${label}#${id}`;
const first = ticket('rice');
const second = ticket('beans');
const supplied = ticket('yam', 99);
const collect = (item, into = []) => { into.push(item); return into; };
const freshA = collect('a');
const freshB = collect('b');
const shared = [];
const collectShared = (item, into = shared) => { into.push(item); return into.length; };
const sharedA = collectShared('a');
const sharedB = collectShared('b');
const greet = (name = 'guest') => name;
console.log('per call :', first, second);
console.log('value passed :', supplied, '| ids issued:', issued);
console.log('fresh array :', freshA, freshB);
console.log('shared array :', sharedA, sharedB, shared);
console.log('triggers :', greet(), greet(undefined), greet(null), JSON.stringify(greet('')));
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 default expression runs on the call that needs it', first, 'rice#1');
test('and runs again, freshly, on the next one', second, 'beans#2');
test('passing a value skips it, so no id is burnt', [supplied, issued], ['yam#99', 2]);
test('= [] gives every call its own array', [freshA, freshB], [['a'], ['b']]);
test('a default naming an outer array shares that one array', [sharedA, sharedB], [1, 2]);
test('an omitted argument takes the default', greet(), 'guest');
test('so does an explicit undefined', greet(undefined), 'guest');
test('null does not', greet(null), null);
test('nor does an empty string', greet(''), '');
A default is an expression evaluated at call time, not a value captured at definition time. That is why (item, into = []) is safe here and the same shape is a notorious bug in Python: JavaScript builds a new array on every call that omits the argument. The collectShared pair shows what the Python behaviour would look like if you asked for it explicitly.
Because it is an ordinary expression, a default can call a function, read a closure, or throw — const required = () => { throw new Error('missing'); } used as (id = required()) turns an omitted argument into an immediate, clear failure.
Answers JS Fundamental 31 in the original set.
32 The scope a default lives in
hardA default can read a parameter to its left. Find out what happens when it reads one to its right, when it reads a var from the body, and which duplicate or shadowed parameter names the parser refuses.
Stuck? Three hints, smallest first
- 1. Parameters are initialised left to right, so an earlier one is available and a later one is still in its dead zone.
- 2. When a function has defaults, its parameters live in their own scope, outside the body’s.
- 3. Duplicate parameter names are a sloppy-mode leftover. Strict mode refuses them, and so does any parameter list with a default or a rest element.
const parses = (src) => {
try { new Function(src); return 'parsed'; } catch (e) { return e.constructor.name; }
};
const attempt = (fn) => {
try { return fn(); } catch (e) { return e.constructor.name; }
};
const area = (w, h = w) => w * h; // reads the parameter to its left
const forwardParses = parses('function f(a = b, b = 1) { return [a, b]; }');
const forwardRuns = attempt(() => {
const f = (a = b, b = 1) => [a, b];
return f();
});
const bodyVar = attempt(() => {
function g(a = inner) { var inner = 1; return a; }
return g();
});
const letShadow = parses('function h(a) { let a = 1; }');
const varShadow = parses('function h(a) { var a = 1; return a; }');
const dupSloppy = parses('function f(a, a) { return a; }');
const dupStrict = parses('"use strict"; function f(a, a) { return a; }');
const dupWithDefault = parses('function f(a, a = 1) { return a; }');
console.log('reads to the left :', area(3), area(3, 4));
console.log('reads to the right:', forwardParses, 'then', forwardRuns);
console.log('reads a body var :', bodyVar);
console.log('let over a param :', letShadow, '| var over a param:', varShadow);
console.log('duplicate names :', dupSloppy, 'sloppy,', dupStrict, 'strict,', dupWithDefault, 'with a default');
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 default may read a parameter to its left', area(3), 9);
test('and is ignored when a value is supplied', area(3, 4), 12);
test('reading one to its right is legal syntax', forwardParses, 'parsed');
test('but throws at call time, from the dead zone', forwardRuns, 'ReferenceError');
test('a default cannot see a var declared in the body', bodyVar, 'ReferenceError');
test('a let in the body may not shadow a parameter', letShadow, 'SyntaxError');
test('a var may, because it is the same binding', varShadow, 'parsed');
test('duplicate parameter names survive only in sloppy mode', [dupSloppy, dupStrict], ['parsed', 'SyntaxError']);
test('and never once any parameter has a default', dupWithDefault, 'SyntaxError');
Once a parameter list contains a default, the parameters get a scope of their own, sitting between the enclosing scope and the function body. That is the whole explanation for two of these results: a = b hits b's temporal dead zone because initialisation is strictly left to right, and a = inner cannot see the body's var inner because the body's variables are declared one scope inward.
Note that new Function bodies are sloppy unless the source says 'use strict', which is what lets the duplicate-parameter case be shown at all — in this page's own panels, every one of which is strict, it would simply refuse to load.
Answers JS Fundamental 32 in the original set.
33 Shorthand properties, methods and accessors
easyBuild an object from variables of the same name without repeating yourself, add a method with the shorthand form, and add a getter and setter pair. Then serialise the result and account for what survives.
Stuck? Three hints, smallest first
- 1.
{ code, owner }is{ code: code, owner: owner }. - 2.
{ describe() {} }is the method shorthand, and itsnamecomes from the key. - 3.
JSON.stringifyskips function values, but a getter is an ordinary enumerable property, so it is read.
const code = 'A1';
const owner = 'Ngozi';
const rentNgn = 12000;
const stall = { code, owner }; // shorthand
const till = {
rentNgn,
describe() { return `rent ${this.rentNgn}`; },
get doubled() { return this.rentNgn * 2; },
set doubled(value) { this.rentNgn = value / 2; },
};
const meter = {
rentNgn: 12000,
get doubled() { return this.rentNgn * 2; },
set doubled(value) { this.rentNgn = value / 2; },
};
meter.doubled = 30000; // the setter runs
console.log('shorthand :', stall);
console.log('method :', till.describe(), '| named', till.describe.name);
console.log('getter :', till.doubled);
console.log('setter ran :', meter.rentNgn);
console.log('serialised :', JSON.stringify(till), '<- method gone, getter called');
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('shorthand takes the key from the variable name', stall, { code: 'A1', owner: 'Ngozi' });
test('and keys appear in the order written', Object.keys(stall), ['code', 'owner']);
test('a method shorthand is a function named after its key', till.describe.name, 'describe');
test('and it can use this', till.describe(), 'rent 12000');
test('a getter is read like a plain property', till.doubled, 24000);
test('though it is an accessor, not a value', typeof Object.getOwnPropertyDescriptor(till, 'doubled').get, 'function');
test('and the matching setter runs on assignment', meter.rentNgn, 15000);
test('JSON drops the method and calls the getter', JSON.stringify(till), '{"rentNgn":12000,"doubled":24000}');
Shorthand is what makes a factory read cleanly: const make = (code, owner) => ({ code, owner }) says everything twice fewer times. It also pairs with destructuring — const { code } = stall and { code } are the same notation used to take apart and to put together.
The serialisation assertion is the one that catches people. A method vanishes from JSON silently, which is usually fine, but a getter is invoked and its result is stored — so an expensive or side-effecting getter runs every time something logs the object. If a property should not be serialised, define it with enumerable: false rather than trusting that it looks like a function.
Answers JS Fundamental 33 in the original set.
34 Computed keys and what a key really is
fairBuild an object whose keys come from expressions, then find out what happens when the key is a number, an object, or a symbol. Check how many times the key expression is evaluated.
Stuck? Three hints, smallest first
- 1. Square brackets in a literal make the key an expression:
{ [name]: value }. - 2. Every key is a string except a symbol. A number, an object, anything else is converted first.
- 3. An object converts through its
toString, which is why two different objects can collide on one key.
const field = 'rentNgn';
const record = { [field]: 12000, [field + 'Paid']: true, ['A' + 1]: 'stall' };
const numeric = { 1: 'one' };
const asKey = { toString: () => 'custom' };
const bag = { [asKey]: 'from an object' };
const k1 = {};
const k2 = {};
const collided = { [k1]: 'first' };
collided[k2] = 'second'; // a different object, the same key
const id = Symbol('id');
const withSymbol = { [id]: 'hidden', plain: 'visible' };
let evaluated = 0;
const keyName = () => { evaluated++; return 'once'; };
const timed = { [keyName()]: true };
console.log('computed keys :', Object.keys(record));
console.log('numeric key :', Object.keys(numeric), numeric[1] === numeric['1']);
console.log('object as key :', Object.keys(bag));
console.log('collision :', Object.keys(collided), '->', collided[k1]);
console.log('symbol :', withSymbol[id], '| visible keys:', Object.keys(withSymbol));
console.log('evaluations :', evaluated);
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('a computed key is the value of the expression', Object.keys(record), ['rentNgn', 'rentNgnPaid', 'A1']);
test('a numeric key is stored as a string', Object.keys(numeric), ['1']);
test('so obj[1] and obj["1"] are one property', numeric[1] === numeric['1'], true);
test('an object key goes through toString', Object.keys(bag), ['custom']);
test('so two different objects collide on a single key', [Object.keys(collided).length, collided[k1]], [1, 'second']);
test('a symbol is the one key that is not converted', withSymbol[id], 'hidden');
test('and it is invisible to Object.keys', Object.keys(withSymbol), ['plain']);
test('the key expression is evaluated once, as the literal is built', [evaluated, Object.keys(timed)], [1, ['once']]);
Computed keys removed the last reason to build an object in two steps — const o = {}; o[k] = v; — so a lookup table can be a single expression, which matters when it is const.
The collision assertion is the argument for Map. An object property name is always a string or a symbol, so using objects as keys silently funnels every one of them into '[object Object]'. A Map keys on identity, so two empty objects are two entries. Symbols are the escape hatch in the other direction: a symbol key cannot collide with anyone else's and does not show up in Object.keys, for...in or JSON.stringify.
Answers JS Fundamental 34 in the original set.
35 The order object keys come back in
hardBuild an object with a mix of integer-like and ordinary string keys and report the order Object.keys gives them. Then find where the boundary of "integer-like" is, and what deleting and re-adding a key does to its position.
Stuck? Three hints, smallest first
- 1. Keys that look like array indexes come first, sorted numerically. Everything else follows in insertion order.
- 2. An array index is a non-negative integer below 2³²−¹. So
'-1','1.5'and'4294967295'are ordinary strings. - 3. A
Maphas none of these rules: insertion order, whatever the key type.
const mixed = { b: 1, 2: 'two', a: 3, 1: 'one', '-1': 'minus', '1.5': 'float', 10: 'ten' };
const edge = { '4294967294': 'last index', '4294967295': 'one past it', '0': 'first' };
const strings = { a: 1, b: 2 };
delete strings.a;
strings.a = 3; // re-added, so it goes to the end
const integers = { 1: 'x', 2: 'y' };
delete integers[1];
integers[1] = 'z'; // re-added, back to its numeric slot
const asMap = new Map([['b', 1], [2, 'two'], ['a', 3]]);
console.log('mixed keys :', Object.keys(mixed));
console.log('as JSON :', JSON.stringify(mixed));
console.log('index limit :', Object.keys(edge));
console.log('string re-add :', Object.keys(strings));
console.log('integer re-add:', Object.keys(integers));
console.log('a Map :', [...asMap.keys()]);
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('integer-like keys come first, in ascending numeric order', Object.keys(mixed), ['1', '2', '10', 'b', 'a', '-1', '1.5']);
test('JSON.stringify follows the same order', JSON.stringify(mixed), '{"1":"one","2":"two","10":"ten","b":1,"a":3,"-1":"minus","1.5":"float"}');
test('a spread copy inherits the ordering rules, not the source order', Object.keys({ ...mixed }), ['1', '2', '10', 'b', 'a', '-1', '1.5']);
test('the largest array index sorts as a number', Object.keys(edge).slice(0, 2), ['0', '4294967294']);
test('one above it is an ordinary string key, so it lands after them', Object.keys(edge)[2], '4294967295');
test('deleting and re-adding a string key moves it to the end', Object.keys(strings), ['b', 'a']);
test('but an integer-like key returns to its numeric position', Object.keys(integers), ['1', '2']);
test('a Map keeps insertion order whatever the key type', [...asMap.keys()], ['b', 2, 'a']);
Key order is specified, not arbitrary, and it is not insertion order. Integer-like keys are sorted numerically and hoisted in front of everything else, then string keys follow in insertion order, then symbols. So an object keyed by numeric id will iterate in id order whether you wanted that or not, and an object keyed by '2024-01' style strings will iterate in the order you inserted them.
This is a real trap for anything ordered by user intent — a form's fields, a column layout, a sequence of steps. If order is part of the data, use an array of pairs or a Map. Note that the reordering survives a spread: { ...obj } re-inserts every key and gets the same rules applied again.
Answers JS Fundamental 35 in the original set.
36 Object.assign against object spread
hardCopy properties two ways and separate the two behaviours: which one mutates its target, which one triggers a setter, what each does with a getter, and what each does with null.
Stuck? Three hints, smallest first
- 1.
Object.assign(target, source)writes intotargetand returns it. Spread always builds a new plain object. - 2. Assign writes with
[[Set]], so a setter on the target intercepts it. Spread defines plain data properties on a fresh object. - 3. Both read only own enumerable properties, and both read a getter rather than copying it.
const attempt = (fn) => {
try { return fn(); } catch (e) { return e.constructor.name; }
};
const target = { a: 1 };
const returned = Object.assign(target, { b: 2 });
const spreadOut = { ...target, c: 3 }; // target must not change
const written = [];
const holder = {
_v: 'fixed',
get name() { return this._v; },
set name(value) { written.push(value); }, // deliberately does not store
};
Object.assign(holder, { name: 'Ngozi' });
const snapshot = { ...holder };
const proto = { inherited: 'from the prototype' };
const child = Object.create(proto);
child.own = 'own value';
const spreadChild = { ...child };
const assignNull = attempt(() => Object.assign(null, { a: 1 }));
const spreadNull = { ...null, ...undefined };
console.log('assign returns its target:', returned === target, target);
console.log('spread leaves it alone :', Object.keys(target), '->', spreadOut);
console.log('setter saw :', written, '| holder.name still', holder.name);
console.log('spread snapshot :', snapshot.name, 'as a plain value');
console.log('inherited :', spreadChild);
console.log('null :', assignNull, '|', spreadNull);
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('Object.assign mutates its first argument and returns it', returned === target, true);
test('so the target now carries both keys', target, { a: 1, b: 2 });
test('spread builds a new object and leaves the source untouched', [Object.keys(target).length, spreadOut.c], [2, 3]);
test('assign writes through a setter on the target', written, ['Ngozi']);
test('and the setter kept control, so nothing was stored', holder.name, 'fixed');
test('spread reads a getter once and stores the result as data', [snapshot.name, Object.getOwnPropertyDescriptor(snapshot, 'name').writable], ['fixed', true]);
test('both ignore inherited properties', spreadChild, { own: 'own value' });
test('and a spread copy is always a plain object', Object.getPrototypeOf(spreadChild) === Object.prototype, true);
test('assign refuses null as a target, while spreading null is harmless', [assignNull, spreadNull], ['TypeError', {}]);
Prefer spread. It cannot mutate anything by accident, it is shorter, and it always produces a plain object. Reach for Object.assign only when mutating an existing object is the point — topping up a config in place, or writing onto this in a constructor.
The setter assertion is the difference that bites. Object.assign assigns, so every setter, and every Object.defineProperty guard on the target, gets a say. Spread defines, so a getter/setter pair on the source is flattened into an ordinary writable value and the accessor is gone. Neither is a faithful copy of a property's definition; for that you need Object.getOwnPropertyDescriptors with Object.defineProperties.
Answers JS Fundamental 36 in the original set.
37 keys, values, entries and back again
fairTotal a table of rents through Object.entries, transform it and rebuild it with Object.fromEntries, then invert it so the values become the keys. Note what happens to the key order when you do.
Stuck? Three hints, smallest first
- 1.
Object.entriesgives[key, value]pairs, so a destructuredfor...ofreads them cleanly. - 2.
Object.fromEntriesis the inverse and also accepts aMap. - 3. The inverted object has numeric-looking keys, which brings the ordering rules of the previous exercise into play.
const rents = { A1: 12000, A2: 9000, B1: 15000 };
const labels = [];
for (const [code, amount] of Object.entries(rents)) labels.push(`${code}=${amount}`);
const total = Object.entries(rents).reduce((sum, [, amount]) => sum + amount, 0);
const roundTrip = Object.fromEntries(Object.entries(rents));
const raised = Object.fromEntries(Object.entries(rents).map(([code, amount]) => [code, amount + 1000]));
const inverted = Object.fromEntries(Object.entries(rents).map(([code, amount]) => [amount, code]));
const fromMap = Object.fromEntries(new Map([['A1', 1]]));
const duplicated = Object.fromEntries([['a', 1], ['a', 2]]);
const proto = { inherited: 'skipped' };
const child = Object.create(proto);
child.own = 'listed';
console.log('entries :', Object.entries(rents));
console.log('destructured in a loop:', labels);
console.log('total :', total);
console.log('raised :', raised);
console.log('inverted :', inverted, '<- keys reordered numerically');
console.log('from a Map:', fromMap, '| duplicate key:', duplicated);
console.log('own only :', Object.keys(child));
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('entries pairs every key with its value', Object.entries(rents), [['A1', 12000], ['A2', 9000], ['B1', 15000]]);
test('destructuring the pair in a for-of reads well', labels, ['A1=12000', 'A2=9000', 'B1=15000']);
test('entries plus reduce totals an object', total, 36000);
test('fromEntries reverses entries exactly', roundTrip, rents);
test('mapping the pairs transforms the values', raised, { A1: 13000, A2: 10000, B1: 16000 });
test('swapping the pairs inverts the object', inverted[12000], 'A1');
test('but the new keys are integer-like, so they come back sorted', Object.keys(inverted), ['9000', '12000', '15000']);
test('fromEntries accepts a Map as well as an array', fromMap, { A1: 1 });
test('a repeated key keeps the last value', duplicated.a, 2);
Object.entries plus an array method plus Object.fromEntries is the standard way to map or filter an object, since objects have no such methods of their own. The whole transformation stays an expression, which keeps it assignable to a const.
Inverting is where the ordering rules ambush you. The values were amounts, so the inverted keys are numeric strings, so they come back sorted ascending rather than in the order the original object had them. If that matters, invert into a Map instead. All three of these functions see own enumerable string keys only — no prototype, no symbols — which is usually what you want and is worth knowing before you debug a missing key.
Answers JS Fundamental 37 in the original set.
38 Object.freeze stops at the first level
fairFreeze a config object, try to write, add and delete a property, then try to change something nested inside it. Write deepFreeze for the case the built-in does not cover.
Stuck? Three hints, smallest first
- 1. These panels run in strict mode, so a write to a frozen object throws rather than failing quietly.
- 2.
Object.freezefreezes the object’s own properties. A property whose value is an object is a reference, and the object it points at is untouched. - 3. A recursive freeze walks the values and freezes each object it finds.
const attempt = (fn) => {
try { return fn(); } catch (e) { return e.constructor.name; }
};
const config = Object.freeze({ branch: 'Ikeja', limits: { daily: 50000 } });
const written = attempt(() => { config.branch = 'Yaba'; return 'no throw'; });
const added = attempt(() => { config.extra = true; return 'no throw'; });
const removed = attempt(() => { delete config.branch; return 'no throw'; });
config.limits.daily = 1; // nothing stops this
const frozenList = Object.freeze([1, 2]);
const pushed = attempt(() => frozenList.push(3));
const deepFreeze = (o) => {
for (const value of Object.values(o)) {
if (value && typeof value === 'object') deepFreeze(value);
}
return Object.freeze(o);
};
const locked = deepFreeze({ branch: 'Yaba', limits: { daily: 5 } });
const deepWrite = attempt(() => { locked.limits.daily = 6; return 'no throw'; });
let holder = Object.freeze({ x: 1 });
holder = { x: 2 }; // the variable was never frozen
console.log('write :', written, '| add:', added, '| delete:', removed);
console.log('nested :', config.limits.daily, '<- changed');
console.log('frozen?:', Object.isFrozen(config), Object.isFrozen(config.limits));
console.log('array :', pushed);
console.log('deep :', deepWrite);
console.log('rebound:', holder);
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('writing to a frozen property throws in strict mode', written, 'TypeError');
test('so does adding a new one', added, 'TypeError');
test('and deleting an existing one', removed, 'TypeError');
test('but a nested object is not frozen at all', config.limits.daily, 1);
test('and isFrozen says so plainly', [Object.isFrozen(config), Object.isFrozen(config.limits)], [true, false]);
test('freezing an array blocks push', pushed, 'TypeError');
test('a recursive freeze reaches the nesting', deepWrite, 'TypeError');
test('freeze locks the object, never the variable holding it', holder, { x: 2 });
Two independent misreadings of freeze are covered here. It is shallow, so freeze(config) protects nothing below the first level — the assertion that config.limits.daily is now 1 is the proof. And it acts on the object, not the binding: const stops the variable being pointed elsewhere, freeze stops the object being edited, and you need both to get what people usually mean by "constant".
In sloppy mode every one of those TypeErrors would instead be a silent no-op, which is far worse: the write looks like it worked and the value never changed. That is one of the strongest practical arguments for strict mode, and it is why these panels run in it.
Answers JS Fundamental 38 in the original set.
39 Optional chaining
fairRead down a path that may not exist using ?., on a property, an index and a method call. Then work out how much of the chain is skipped when a link is missing, and which values count as missing.
Stuck? Three hints, smallest first
- 1.
a?.byieldsundefinedwhenaisnullorundefined, instead of throwing. - 2. For an index or a call the dot stays:
a?.[i]anda?.(). - 3. It short-circuits the whole rest of the chain, so nothing further along is even evaluated.
const attempt = (fn) => {
try { return fn(); } catch (e) { return e.constructor.name; }
};
const parses = (src) => {
try { new Function(src); return 'parsed'; } catch (e) { return e.constructor.name; }
};
const order = {
id: 7,
buyer: { name: 'Emeka' },
lines: [{ item: 'rice' }],
count: 0,
};
const present = order.buyer?.name;
const absent = order.seller?.name;
const unguarded = attempt(() => order.seller.name);
const indexed = order.lines?.[0]?.item;
const past = order.lines?.[5]?.item;
const calledMissing = order.describe?.();
const calledUnguarded = attempt(() => order.describe());
let evaluated = 0;
const idx = () => { evaluated++; return 0; };
const shortCircuit = order.seller?.lines[idx()];
const onZero = order.count?.toFixed(1);
const notAFunction = attempt(() => order.buyer.name?.());
const asTarget = parses('const o = {}; o?.a = 1;');
console.log('present :', present, '| absent:', absent);
console.log('unguarded :', unguarded);
console.log('index :', indexed, '| past the end:', past);
console.log('call :', calledMissing, '| unguarded:', calledUnguarded);
console.log('skipped :', shortCircuit, 'and idx() ran', evaluated, 'times');
console.log('on zero :', onZero, '<- 0 is not nullish');
console.log('not a fn :', notAFunction, '| as a target:', asTarget);
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 present link reads normally', present, 'Emeka');
test('a missing one yields undefined instead of throwing', absent === undefined, true);
test('the same read without the guard throws', unguarded, 'TypeError');
test('?.[] guards an index the same way', [indexed, String(past)], ['rice', 'undefined']);
test('?.() guards a method that is not there', [calledMissing === undefined, calledUnguarded], [true, 'TypeError']);
test('one nullish link skips the rest of the chain, unevaluated', [evaluated, String(shortCircuit)], [0, 'undefined']);
test('only null and undefined short-circuit, so zero carries on', onZero, '0.0');
test('it does not make a non-function callable', notAFunction, 'TypeError');
test('and an optional chain may not be assigned to', asTarget, 'SyntaxError');
The short-circuit assertion is the part that is easy to get wrong: order.seller?.lines[idx()] does not throw on lines being undefined, because once seller is nullish the entire remaining chain is abandoned and idx() is never called. So one ?. guards everything to its right, and writing a?.b?.c?.d when only a can be missing is noise.
The two limits are worth remembering. It tests for null and undefined only — 0, '' and false proceed — and ?.() means "if this is nullish, skip", not "if this is callable". A property holding a string still throws when you try to call it. Used everywhere, ?. also hides real bugs: it is a tool for genuinely optional data, not a way to silence a path that should exist.
Answers JS Fundamental 39 in the original set.
40 Nullish coalescing and the logical assignments
fairSupply a fallback with ?? and with || for a setting whose value is 0, and explain the difference. Then use ??=, ||= and &&=, and prove that when they do not assign, they do not write at all.
Stuck? Three hints, smallest first
- 1.
??falls back onnullandundefinedonly.||falls back on every falsy value. - 2.
x ??= visx ?? (x = v): if the test fails, the assignment never happens. - 3. Mixing
??with||or&&in one expression is a syntax error without parentheses.
const parses = (src) => {
try { new Function(src); return 'parsed'; } catch (e) { return e.constructor.name; }
};
const given = { discount: 0, note: '', branch: null };
const withNullish = given.discount ?? 10;
const withOr = given.discount || 10;
const nullBranch = given.branch ?? 'Ikeja';
const blankNote = given.note ?? 'none';
const cfg = { retries: 0, label: null };
cfg.retries ??= 3; // 0 is not nullish: left alone
cfg.label ??= 'unnamed';
const loose = { retries: 0 };
loose.retries ||= 3; // 0 is falsy: overwritten
const stall = { owner: 'ngozi', area: undefined };
stall.owner &&= stall.owner.toUpperCase();
stall.area &&= 'never assigned';
let writes = 0;
const box = {
_v: 'set',
get v() { return this._v; },
set v(value) { writes++; this._v = value; },
};
box.v ??= 'other'; // getter is not nullish, so no write
const mixedUp = parses('const a = 1, b = 2, c = 3; return a ?? b || c;');
const parenthesised = parses('const a = 1, b = 2, c = 3; return (a ?? b) || c;');
console.log('?? on 0 :', withNullish, '| || on 0:', withOr);
console.log('?? on null:', nullBranch, '| on an empty string:', JSON.stringify(blankNote));
console.log('??= :', cfg, '| ||=:', loose);
console.log('&&= :', stall.owner, stall.area, 'key still there:', 'area' in stall);
console.log('setter :', writes, 'writes, value still', box.v);
console.log('mixing :', mixedUp, '| parenthesised:', parenthesised);
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('?? falls back only on null or undefined, so a zero setting survives', withNullish, 0);
test('while || throws away every falsy value', withOr, 10);
test('null does trigger the fallback', nullBranch, 'Ikeja');
test('and an empty string survives ?? as well', blankNote, '');
test('??= leaves a zero alone and fills a null', [cfg.retries, cfg.label], [0, 'unnamed']);
test('||= overwrites the zero', loose.retries, 3);
test('&&= assigns only where the value was already truthy', [stall.owner, stall.area === undefined], ['NGOZI', true]);
test('when the test fails there is no assignment, so a setter never runs', [writes, box.v], [0, 'set']);
test('?? may not be mixed with || without parentheses', [mixedUp, parenthesised], ['SyntaxError', 'parsed']);
value || fallback was the standard idiom for twenty years and it is wrong for any setting where 0, '' or false is a real value: a discount of zero, an empty search box, a disabled flag. ?? asks the question people actually mean — "was anything supplied?" — and leaves every other value alone.
The setter assertion shows why x ??= v is not sugar for x = x ?? v. The short-circuiting form performs no write when the test fails, so no setter fires, no proxy trap runs, and no framework re-render is triggered. The mandatory parentheses when mixing with || are deliberate: the precedence would be a coin flip for most readers, so the language makes you say which you meant.
Answers JS Fundamental 40 in the original set.
The exercise list follows w3resource's JavaScript exercises (CC BY-NC 4.0), and each exercise names the one it answers. The wording, hints, solutions and explanations here are my own.