JavaScript: Basic, 81–120
The on-ramp: numbers, text, dates, conditionals and loops.
Exercises 81 to 120 of 150
81 Split a bill so the kobo add back up
fairSplit a bill of ₦12,500 three ways. Every share must be a whole number of kobo, and the shares must add back to exactly the bill. Also exercise 63, which argues for holding money in kobo; here the angle is division, where the argument is unanswerable.
Stuck? Three hints, smallest first
- 1. Divide into a base share with
Math.floor, then look at the remainder. - 2. The remainder is how many people pay one kobo more than the rest.
- 3. Add the shares back up and compare with the total. That check is the whole exercise.
function splitBill(totalKobo, people) {
if (!Number.isInteger(totalKobo) || totalKobo < 0) {
throw new TypeError('the total must be a whole number of kobo');
}
if (!Number.isInteger(people) || people < 1) {
throw new RangeError('need at least one person');
}
const base = Math.floor(totalKobo / people);
const extra = totalKobo % people; // this many pay one kobo more
return Array.from({ length: people }, (_, i) => base + (i < extra ? 1 : 0));
}
const BILL = 12_500_00; // twelve and a half thousand naira, in kobo
const shares = splitBill(BILL, 3);
let refused = '';
try {
splitBill(4166.67, 3);
} catch (err) {
refused = err.message;
}
console.log('shares:', shares, 'sum:', shares.reduce((a, b) => a + b, 0));
console.log('the naira route:', (12500 / 3).toFixed(2), 'x 3 =', Number((12500 / 3).toFixed(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('the odd kobo go to the front of the queue', shares, [416667, 416667, 416666]);
test('and the shares add back to the bill exactly', shares.reduce((a, b) => a + b, 0), BILL);
test('an exact split gives everyone the same', splitBill(900_00, 3), [30000, 30000, 30000]);
test('one person pays the lot', splitBill(BILL, 1), [1250000]);
test('rounding each share instead overcharges by a kobo', 416667 * 3, 1250001);
test('toFixed hands back a string, not a number', typeof (12500 / 3).toFixed(2), 'string');
test('and three of those do not add up to the bill', Number((12500 / 3).toFixed(2)) * 3, 12500.01);
test('a fractional total is refused rather than rounded away', refused.includes('whole number'), true);
Division is where naira-with-decimals finally breaks. ₦12,500 over three people is ₦4,166.666…, and every way of rounding that to a printable amount is wrong: round each share up and you collect ₦12,500.01, round down and you are a kobo short. The money has to go somewhere, so decide where — here the first extra people pay one kobo more, which is what a POS receipt does.
toFixed being a string is the second trap, and it hides the first: '4166.67' looks like a number, so it gets multiplied back later and the penny reappears as 12500.01. Keep integers in kobo all the way through, divide with Math.floor and a remainder, and make "the parts sum to the whole" an assertion rather than a hope.
Answers JS Basic 81 in the original set.
82 Walk an object with keys, values and entries
easyGiven a table of fares keyed by stop, list the stops, list the amounts, total them, and count how many there are.
Stuck? Three hints, smallest first
- 1.
Object.keys,Object.valuesandObject.entrieseach give you an array. - 2. Destructure in the loop head:
for (const [stop, kobo] of Object.entries(fares)). - 3. An object has no
length. Count the keys instead.
const fares = { yaba: 200_00, ikeja: 350_00, lekki: 500_00 };
const stops = Object.keys(fares);
const amounts = Object.values(fares);
const pairs = Object.entries(fares);
let total = 0;
for (const [stop, kobo] of Object.entries(fares)) total += kobo;
// for...in reaches the same keys, one at a time, without building an array.
const lines = [];
for (const stop in fares) lines.push(stop + '=' + fares[stop]);
const count = Object.keys(fares).length;
console.log(pairs);
console.log('total:', total, 'across', count, 'stops');
console.log('fares.length is', fares.length);
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 keys, in the order they were written', stops, ['yaba', 'ikeja', 'lekki']);
test('the values, in the same order', amounts, [20000, 35000, 50000]);
test('entries pairs them up', pairs[0], ['yaba', 20000]);
test('destructuring in the loop head reads well', total, 105000);
test('for...in reaches the same keys', lines, ['yaba=20000', 'ikeja=35000', 'lekki=50000']);
test('an object has no length', fares.length, undefined);
test('so count the keys', count, 3);
test('but integer-like keys come back in numeric order, not insertion order', Object.keys({ 10: 'ten', 2: 'two' }), ['2', '10']);
The three Object functions cover almost everything a beginner needs from an object: keys to list, values to total, entries to do both at once. All three return arrays, so every array method is available afterwards — Object.values(fares).reduce(...) replaces the loop above entirely.
fares.length being undefined catches people out because it does not throw; it silently means "no such key". And key order is insertion order except for keys that look like array indices, which are always visited in ascending numeric order. If you need dependable ordering across mixed keys, or keys that are not strings, use a Map.
Answers JS Basic 82 in the original set.
83 Dot notation and bracket notation
easyRead the same property with a dot and with brackets. Then read a key held in a variable, a key with a space in it, and a numeric key.
Stuck? Three hints, smallest first
- 1. Dot notation needs a literal name that is a valid identifier.
- 2. Brackets take any expression, so a variable holding the key name works there and only there.
- 3. Every key is a string. Watch what happens to one that is not.
const passenger = {
name: 'Adaeze',
'seat number': 'B3', // a space, so brackets are the only way in
fares: {},
};
const field = 'name';
passenger.fares.yaba = 200_00; // the parent has to exist first
// A number key is stored as a string, so both spellings reach it.
const seats = {};
seats[3] = 'taken';
// So is any other value used as a key, which is how two objects collide.
const odd = {};
odd[{ a: 1 }] = 'first';
odd[{ b: 2 }] = 'second'; // same key: "[object Object]"
console.log(passenger['seat number'], passenger[field], passenger.field);
console.log('object keys collapse to', Object.keys(odd));
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('dot and bracket read the same key', passenger.name === passenger['name'], true);
test('brackets take a variable key', passenger[field], 'Adaeze');
test('a dot with the same name looks for a literal key called field', passenger.field, undefined);
test('a key with a space needs brackets', passenger['seat number'], 'B3');
test('a number key is stored as a string', Object.keys(seats), ['3']);
test('so either spelling reaches it', seats[3] === seats['3'], true);
test('any other key is stringified too, so these two collide', Object.keys(odd).length, 1);
test('and the second write won', odd['[object Object]'], 'second');
test('a nested key set through its parent', passenger.fares.yaba, 20000);
Dot notation is the readable default and cannot do three things: use a name held in a variable, use a name that is not a valid identifier ('seat number'), or use a name built at runtime. Brackets do all three, which is why lookup tables and loops over Object.keys are written with them.
passenger.field returning undefined rather than throwing is the trap: the dot took field as a literal key name, found none, and said nothing. And keys are always strings — seats[3] and seats['3'] are one property, and two different objects used as keys both stringify to "[object Object]" and overwrite each other. When you need object identity in a key, that is what Map is for.
Answers JS Basic 83 in the original set.
84 Does the key exist, or is the value just missing?
fairDecide whether an object has a property. Compare in, Object.hasOwn and a plain !== undefined against a key whose value is undefined, an inherited key, and a fare of zero.
Stuck? Three hints, smallest first
- 1.
'key' in objasks about the key, not the value. - 2.
inalso finds inherited properties.Object.hasOwndoes not. - 3. A zero fare is falsy, so
if (obj.fare)is asking the wrong question.
const trip = {
route: 'Yaba - Ikeja',
fare: 200_00,
driver: undefined, // the key exists; the value does not
};
const staffShuttle = { route: 'staff shuttle', fare: 0 };
console.log('driver in trip:', 'driver' in trip, '| value:', trip.driver);
console.log('toString in trip:', 'toString' in trip, '| own:', Object.hasOwn(trip, 'toString'));
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('in finds a key', 'route' in trip, true);
test('a key holding undefined still exists', 'driver' in trip, true);
test('so the !== undefined test disagrees with it', trip.driver !== undefined, false);
test('in also finds inherited keys', 'toString' in trip, true);
test('Object.hasOwn refuses those', Object.hasOwn(trip, 'toString'), false);
test('but does find the key holding undefined', Object.hasOwn(trip, 'driver'), true);
test('a zero fare is falsy, so truthiness asks the wrong question', Boolean(staffShuttle.fare), false);
test('the key is plainly there', Object.hasOwn(staffShuttle, 'fare'), true);
test('and ?? keeps the zero where || would replace it', staffShuttle.fare ?? 500_00, 0);
Three questions get confused with each other. "Is there a key?" is in or Object.hasOwn. "Is the value something?" is !== undefined. "Is the value truthy?" is if (obj.fare) — and that last one is false for 0, '' and false, all of which are real values a fare, a note or a flag might legitimately hold.
'toString' in trip being true is why in is the wrong tool for checking user data: every object inherits a handful of properties, so a lookup keyed on an untrusted string can report a hit that was never written. Object.hasOwn(obj, key) is the modern one-liner for own properties; before it existed the incantation was Object.prototype.hasOwnProperty.call(obj, key), written that way because an object can have its own key called hasOwnProperty.
Answers JS Basic 84 in the original set.
85 delete a key, and the hole it leaves in an array
fairRemove a stop from a manifest with delete. Then run delete on an array element and look at what is left. Compare with setting the value to undefined, and with splice.
Stuck? Three hints, smallest first
- 1.
delete obj.keyremoves the key; check within, not with the value. - 2. On an array,
deletedoes not move anything or changelength. - 3.
splice(index, 1)is what actually removes an element.
const manifest = { yaba: 3, ikeja: 5, lekki: 2 };
delete manifest.ikeja;
// Setting undefined is not deleting: the key stays.
const halfHearted = { yaba: 3 };
halfHearted.yaba = undefined;
// On an array, delete blanks the slot and leaves the length alone.
const seats = ['A1', 'A2', 'A3'];
delete seats[1];
// splice is the one that removes.
const spliced = ['A1', 'A2', 'A3'];
spliced.splice(1, 1);
console.log('after delete:', seats, 'length', seats.length);
console.log('as JSON:', JSON.stringify(seats));
console.log('after splice:', spliced, 'length', spliced.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('the key is gone', Object.keys(manifest), ['yaba', 'lekki']);
test('and in agrees', 'ikeja' in manifest, false);
test('deleting a key that was never there still reports true', delete manifest.badagry, true);
test('setting undefined leaves the key behind', 'yaba' in halfHearted, true);
test('delete on an array does not change the length', seats.length, 3);
test('so there is a hole where A2 was', 1 in seats, false);
test('and JSON writes that hole as null', JSON.stringify(seats), '["A1",null,"A3"]');
test('splice removes the element properly', spliced, ['A1', 'A3']);
test('with the length to match', spliced.length, 2);
delete is about keys, and an array index is a key, which is why delete seats[1] is legal and almost never what you want. It leaves a hole: length stays 3, 1 in seats is false, and seats[1] reads as undefined without any key being there. map and forEach skip holes, JSON.stringify writes them as null, and the two behaviours together produce bugs that are miserable to trace. Use splice, or filter for a copy.
Two smaller notes. delete returns true for a key that was never there, so its return value tells you nothing about whether anything was removed — check with Object.hasOwn first if you care. And setting a key to undefined is not removal: the key survives, Object.keys still lists it, and JSON.stringify then drops it, so the two spellings differ in what reaches your server.
Answers JS Basic 85 in the original set.
86 Merge defaults with what the caller passed
fairMerge a defaults object with a caller's object so the caller wins. Do it with spread and with Object.assign, then show what "shallow" means by changing a nested value.
Stuck? Three hints, smallest first
- 1. In
{ ...a, ...b }the later object wins on any shared key. - 2.
Object.assign(target, source)changestargetand returns it. - 3. Both copy one level deep. A nested object is shared, not copied.
const defaults = { fare: 200_00, tier: 'economy', ac: false };
const booking = { fare: 350_00, ac: true };
const merged = { ...defaults, ...booking }; // the caller wins
const wrongWayRound = { ...booking, ...defaults }; // the defaults win, which is a bug
// Object.assign mutates its first argument and hands the same object back.
const target = { ...defaults };
const returned = Object.assign(target, booking);
// Shallow: the nested route object is shared between base and copy.
const base = { route: { from: 'Yaba', to: 'Ikeja' }, fare: 200_00 };
const copy = { ...base };
copy.route.to = 'Lekki';
copy.fare = 500_00;
// An explicit undefined still counts as a value and overwrites.
const blanked = { ...defaults, tier: undefined };
console.log(merged);
console.log('base after editing the copy:', JSON.stringify(base));
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 later object wins', merged, { fare: 35000, tier: 'economy', ac: true });
test('order matters: reversed, the defaults overwrite the booking', wrongWayRound.fare, 20000);
test('Object.assign returns the very object it changed', returned === target, true);
test('so the first argument was mutated', target.fare, 35000);
test('spread copies one level, so the nested route is shared', base.route.to, 'Lekki');
test('a top-level value is independent, though', base.fare, 20000);
test('an explicit undefined overwrites a real default', blanked.tier, undefined);
test('key order follows first appearance, not the later object', Object.keys(merged), ['fare', 'tier', 'ac']);
Spread reads left to right and the last write wins, so defaults go first and the caller's object last. Reversing that is a common and quiet bug: every value the caller passed is silently replaced by a default, and nothing throws. Object.assign(target, source) does the same job but mutates target — fine when you built the target yourself two lines earlier, dangerous when the target is something you were handed. Object.assign({}, a, b) is the safe spelling.
"Shallow" is the trap worth remembering: base.route and copy.route are one object, so editing the copy changed the original. Only the top level is duplicated. A nested merge needs structuredClone or a deliberate deep merge (exercise 40). And tier: undefined overwriting the default catches people building option objects from optional inputs — filter those keys out first, or use ?? at the point of use.
Answers JS Basic 86 in the original set.
87 A lookup table instead of an if-chain
fairReplace a chain of if (route === ...) fare checks with one object lookup. Then ask the table for a route called toString.
Stuck? Three hints, smallest first
- 1. The chain and the table should agree on every route. Prove that before trusting the table.
- 2.
FARES[route] ?? nullhandles an unknown route in one expression. - 3. An object inherits properties, so a lookup keyed on an untrusted string can hit one.
function fareChain(route) {
if (route === 'yaba') return 200_00;
if (route === 'ikeja') return 350_00;
if (route === 'lekki') return 500_00;
return null;
}
const FARES = { yaba: 200_00, ikeja: 350_00, lekki: 500_00 };
const fareTable = (route) => FARES[route] ?? null;
const routes = ['yaba', 'ikeja', 'lekki', 'badagry'];
const agree = routes.every((route) => fareChain(route) === fareTable(route));
// Adding a stop is now a line of data, not another branch.
FARES.epe = 150_00;
// A key nobody wrote, and the table answers anyway.
const safeTable = (route) => (Object.hasOwn(FARES, route) ? FARES[route] : null);
console.log('table says toString costs', fareTable('toString'));
console.log('guarded table says', safeTable('toString'));
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 chain and the table agree on every route', agree, true);
test('a known route', fareTable('ikeja'), 35000);
test('an unknown one', fareTable('badagry'), null);
test('the new stop needed no new branch', fareTable('epe'), 15000);
test('but a bare lookup leaks an inherited property', typeof fareTable('toString'), 'function');
test('the if-chain never had that problem', fareChain('toString'), null);
test('Object.hasOwn closes it', safeTable('toString'), null);
test('and still finds the real routes', safeTable('lekki'), 50000);
An if-chain grows a branch per case and has to be read top to bottom; a table is data, so adding epe is one line and the code that reads it never changes. It is also a single lookup rather than up to n comparisons, though at four routes that difference is theatre — readability is the real argument.
The leak is the price. FARES['toString'] finds the inherited function, so ?? null never fires and the caller gets something bizarre back; with an untrusted key, constructor and __proto__ are worse. Guard with Object.hasOwn, or build the table with new Map(...), or Object.create(null) for an object with no prototype at all. Object.freeze(FARES) is worth adding too once the table is settled, so nothing edits your fares at runtime.
Answers JS Basic 87 in the original set.
88 Turn a lookup table inside out
fairInvert a table of plate numbers to owners, so the owners become the keys. Two plates belong to the same owner: decide what that should do, then handle it properly.
Stuck? Three hints, smallest first
- 1. Walk
Object.entriesand assignout[value] = key. - 2. Two entries with the same value collide, and the last one silently wins.
- 3.
(out[value] ??= []).push(key)collects them instead.
const PLATE_OWNER = {
'LSD-114-AB': 'Adaeze',
'KJA-902-XY': 'Bola',
'BDG-771-KM': 'Adaeze', // same owner as the first plate
};
function invert(table) {
const out = {};
for (const [key, value] of Object.entries(table)) out[value] = key;
return out;
}
function invertAll(table) {
const out = {};
for (const [key, value] of Object.entries(table)) {
(out[value] ??= []).push(key);
}
return out;
}
console.log(invert(PLATE_OWNER));
console.log(invertAll(PLATE_OWNER));
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 values become the keys', invert(PLATE_OWNER).Bola, 'KJA-902-XY');
test('a repeated value collides, and the last write wins', invert(PLATE_OWNER).Adaeze, 'BDG-771-KM');
test('so three plates invert to two owners', Object.keys(invert(PLATE_OWNER)).length, 2);
test('grouping keeps both of her plates', invertAll(PLATE_OWNER).Adaeze, ['LSD-114-AB', 'BDG-771-KM']);
test('with one entry per owner', Object.keys(invertAll(PLATE_OWNER)), ['Adaeze', 'Bola']);
test('a number value comes back as a string key', Object.keys(invert({ yaba: 20000 })), ['20000']);
test('so inverting twice does not return what you started with', invert(invert(PLATE_OWNER)), { 'BDG-771-KM': 'Adaeze', 'KJA-902-XY': 'Bola' });
test('an empty table inverts to an empty one', invert({}), {});
Inverting is three lines, and the interesting part is what it destroys. Object keys are unique, so any value appearing twice collapses to one entry and the earlier plate vanishes with no error. The double inversion assertion shows the damage: LSD-114-AB is simply gone.
(out[value] ??= []).push(key) is the fix and the idiom worth learning — logical assignment creates the array only when the key is missing, then the expression evaluates to the array so push follows on the same line. The other quiet conversion is that values become keys, and keys are strings: invert a table of numbers and 20000 comes back as '20000'. A one-to-one table can be inverted; a many-to-one table has to be grouped, and you should know which one you have before you write the loop.
Answers JS Basic 88 in the original set.
89 Default parameters, and what triggers them
easyWrite a fare calculator whose rate and surcharge have defaults, where the surcharge default is worked out from the rate. Then call it with undefined and with null and explain the difference.
Stuck? Three hints, smallest first
- 1. A default fires when the argument is
undefined, including when it is left off. - 2.
nullis a value, so it is passed through and coerced. - 3. A later default can refer to an earlier parameter, because they are evaluated left to right.
function fare(distanceKm, ratePerKm = 50_00, surcharge = ratePerKm / 10) {
return distanceKm * ratePerKm + surcharge;
}
// A default expression runs on every call, so a fresh array each time.
function addStop(stop, route = []) {
route.push(stop);
return route;
}
const shared = [];
addStop('yaba', shared);
addStop('ikeja', shared);
console.log('defaults:', fare(4), '| own rate:', fare(4, 100_00), '| null rate:', fare(4, null));
console.log('fare.length is', fare.length);
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('both defaults fill themselves in', fare(4), 20500);
test('a passed rate is used, and the surcharge follows it', fare(4, 100_00), 41000);
test('undefined asks for the default', fare(4, undefined, 0), 20000);
test('null does not: it is a value, and it coerces to zero', fare(4, null), 0);
test('length counts only the parameters before the first default', fare.length, 1);
test('a default array is built fresh on every call', addStop('yaba'), ['yaba']);
test('so the second call cannot see the first', addStop('ikeja'), ['ikeja']);
test('an array you passed in is shared, because you own it', shared, ['yaba', 'ikeja']);
Only undefined triggers a default. fare(4, null) passes null straight through, 4 * null is 0, and the answer is a fare of nothing with no error anywhere — the single most common surprise with defaults, and the reason ?? exists (exercise 98).
Defaults are evaluated left to right at call time, which buys two things. A later default can be computed from an earlier parameter, as surcharge is here. And a default of [] or {} is built afresh on every call, unlike Python, where a mutable default is created once and remembered — a whole class of bug that JavaScript simply does not have. Note also that fare.length is 1: length stops counting at the first default, so it is not a reliable arity.
Answers JS Basic 89 in the original set.
90 Rest parameters, and the old arguments object
fairTotal any number of amounts passed as separate arguments. Then split a receipt's first argument from the rest, and compare what ...rest gives you with the old arguments object.
Stuck? Three hints, smallest first
- 1.
...restmust be the last parameter, and it gathers what is left into a real array. - 2. Spread at the call site does the opposite:
total(...list). - 3.
argumentsis array-like, so it has a length and no array methods.
function total(...amounts) {
return amounts.reduce((sum, kobo) => sum + kobo, 0);
}
function receipt(customer, ...items) {
return customer + ' owes ' + total(...items);
}
function isRealArray(...rest) {
return Array.isArray(rest);
}
// The pre-2015 way. arguments exists inside a function declaration, is not an
// array, and does not exist at all inside an arrow function.
function oldStyle() {
const looksLikeArray = Array.isArray(arguments);
let mapped = 'threw';
try {
mapped = arguments.map((n) => n);
} catch {
// arguments has no map
}
return { count: arguments.length, looksLikeArray, mapped };
}
const fares = [200_00, 350_00, 500_00];
console.log('spread back in:', total(...fares));
console.log('arguments:', oldStyle(1, 2));
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 gathers everything into one array', total(200_00, 350_00, 150_00), 70000);
test('and it is a real array, so reduce works on it', isRealArray(1, 2), true);
test('no arguments at all is fine', total(), 0);
test('length ignores a rest parameter', total.length, 0);
test('named parameters before it still count', receipt.length, 1);
test('spread sends an array back in as separate arguments', total(...fares), 105000);
test('a receipt splits the first argument from the rest', receipt('Adaeze', 200_00, 350_00), 'Adaeze owes 55000');
test('the old arguments object is not an array', oldStyle(1, 2).looksLikeArray, false);
test('so calling map on it throws', oldStyle(1, 2).mapped, 'threw');
...rest in a parameter list gathers; ... at a call site spreads. Same three dots, opposite directions, and the pair replaces every use of arguments: rest is a genuine array, so reduce, map and filter are all available, and it can sit after named parameters as in receipt.
arguments is array-like — indices and a length, nothing else — so arguments.map(...) throws a TypeError rather than doing anything useful. It also does not exist inside an arrow function, which inherits it from the enclosing function along with this. The one thing to watch is length: it counts named parameters only, so total.length is 0 despite total accepting anything you throw at it.
Answers JS Basic 90 in the original set.
91 Guard clauses instead of nested ifs
easyValidate a transfer in kobo: there must be an account, it must be open, the amount must be a positive whole number, and it must not exceed the balance. Write it nested, then rewrite it as guards that return early, and prove the two agree.
Stuck? Three hints, smallest first
- 1. Handle each failure as it arises and
return. One line per rule. - 2. The happy path ends up last, at the left margin, with nothing wrapped around it.
- 3. Compare the two versions across every case rather than trusting the rewrite.
// The nested version. The line that does the work is five levels deep.
function transferNested(amountKobo, balanceKobo, account) {
if (account) {
if (account.active) {
if (Number.isInteger(amountKobo) && amountKobo > 0) {
if (amountKobo <= balanceKobo) {
return { ok: true, left: balanceKobo - amountKobo };
} else {
return { ok: false, why: 'insufficient funds' };
}
} else {
return { ok: false, why: 'bad amount' };
}
} else {
return { ok: false, why: 'account closed' };
}
} else {
return { ok: false, why: 'no account' };
}
}
// The same rules as guards. Each failure is dealt with and left behind.
function transfer(amountKobo, balanceKobo, account) {
if (!account) return { ok: false, why: 'no account' };
if (!account.active) return { ok: false, why: 'account closed' };
if (!Number.isInteger(amountKobo) || amountKobo <= 0) return { ok: false, why: 'bad amount' };
if (amountKobo > balanceKobo) return { ok: false, why: 'insufficient funds' };
return { ok: true, left: balanceKobo - amountKobo };
}
function nothing() {
return;
}
const live = { name: 'Adaeze', active: true };
const closed = { name: 'Bola', active: false };
const cases = [
[500_00, 2000_00, live],
[0, 2000_00, live],
[2.5, 2000_00, live],
[5000_00, 2000_00, live],
[500_00, 2000_00, closed],
[500_00, 2000_00, null],
];
const bothAgree = cases.every(([a, b, c]) => same(transfer(a, b, c), transferNested(a, b, c)));
console.log(cases.map(([a, b, c]) => transfer(a, b, c)));
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 good transfer, with the balance left over', transfer(500_00, 2000_00, live), { ok: true, left: 150000 });
test('the guards agree with the nested version on every case', bothAgree, true);
test('zero is not a transfer', transfer(0, 2000_00, live).why, 'bad amount');
test('nor is two and a half kobo', transfer(2.5, 2000_00, live).why, 'bad amount');
test('more than the balance is refused', transfer(5000_00, 2000_00, live).why, 'insufficient funds');
test('a closed account never reaches the amount check', transfer(500_00, 2000_00, closed).why, 'account closed');
test('and no account at all is caught first', transfer(500_00, 2000_00, null).why, 'no account');
test('a bare return hands back undefined', nothing(), undefined);
Both functions implement the same four rules and the assertion proves it, so the choice is purely about reading. The nested version buries the one interesting line under four ifs and pairs every condition with an else a screen away; the guards read as a list of rules, each with its answer beside it, and the success case sits at the bottom unindented.
Order matters and is part of the design: !account has to come before !account.active, or the second guard throws on null. That ordering is visible in the guard version and hidden in the nested one. The reward is that adding a fifth rule is one line rather than another layer, and it is why { ok, why } is worth returning instead of a bare false — the caller gets to say which rule failed.
Answers JS Basic 91 in the original set.
92 A function that changes its input, and one that does not
fairRaise a list of fares two ways: one returning a new list, one editing the list it was given. Then show which array methods mutate, and what the bare sort() does to numbers.
Stuck? Three hints, smallest first
- 1.
mapreturns a new array; aforloop assigning intofees[i]does not. - 2.
sortandreversechange the array and return that same array. - 3.
sort()with no comparator compares text, not numbers.
const marks = [70, 45, 88, 61];
// Pure: the input is copied before it is sorted.
const sorted = marks.slice().sort((a, b) => a - b);
// Mutating: sort edits in place, and hands back the very array it edited.
const mutable = [...marks];
const returned = mutable.sort((a, b) => a - b);
// push mutates and returns the new length; concat returns a new array.
const stops = ['yaba'];
const pushed = stops.push('ikeja');
const joined = stops.concat('lekki');
const raise = (fees, byKobo) => fees.map((fee) => fee + byKobo);
function raiseInPlace(fees, byKobo) {
for (let i = 0; i < fees.length; i++) fees[i] += byKobo;
return fees;
}
const fees = [200_00, 350_00];
const inPlace = [200_00, 350_00];
raiseInPlace(inPlace, 50_00);
console.log('sort() on numbers:', [10, 9, 1].sort());
console.log('push returned', pushed, 'and stops is now', stops);
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 copy leaves the original in place', marks, [70, 45, 88, 61]);
test('and the copy is sorted', sorted, [45, 61, 70, 88]);
test('sort returns the very array it changed', returned === mutable, true);
test('the bare sort compares text, so 10 lands before 9', [10, 9, 1].sort(), [1, 10, 9]);
test('push hands back the new length, not the array', pushed, 2);
test('concat leaves its input alone', stops, ['yaba', 'ikeja']);
test('the pure raise gives a new list', raise(fees, 50_00), [25000, 40000]);
test('and the old one is untouched', fees, [20000, 35000]);
test('the mutating one edited the caller\u2019s array', inPlace, [25000, 40000]);
[10, 9, 1].sort() giving [1, 10, 9] is the one to memorise: with no comparator, sort converts every element to a string and compares those, so '10' < '9'. Numbers always need (a, b) => a - b.
The deeper habit is knowing which functions change what you handed them. sort, reverse, splice, push, fill all mutate; map, filter, concat, slice return something new. sort is the nastiest because it also returns the array, so const sorted = marks.sort(...) reads like a copy and is not — marks is sorted too, and any code depending on the original order is now broken. Copy first with slice() or [...marks], or use the newer toSorted. A mutating function is not wrong, but it should be obvious from its name, as raiseInPlace is.
Answers JS Basic 92 in the original set.
93 Arrow or function, and what this points at
hardGive an object a method that maps over its own data. Write the callback as an arrow, then as a plain function, and see which one can still reach the object. Then detach the method from the object and call it.
Stuck? Three hints, smallest first
- 1. A plain function gets a fresh
thisfrom the way it was called, which for a bare call is nothing. - 2. An arrow has no
thisof its own; it uses the one from where it was written. - 3.
maptakes a second argument that setsthisfor the callback.
const trip = {
prefix: 'stop:',
stops: ['Yaba', 'Ojota', 'Ikeja'],
// The arrow inherits this from labels(), where it is the trip.
labels() {
return this.stops.map((stop) => this.prefix + stop);
},
// A plain function gets its own this. In strict mode that is undefined.
labelsBroken() {
try {
return this.stops.map(function (stop) {
return this.prefix + stop;
});
} catch {
return 'threw: this was not the trip';
}
},
// map's second argument is the this to use. The pre-arrow fix, still valid.
labelsBound() {
return this.stops.map(function (stop) {
return this.prefix + stop;
}, this);
},
};
const detached = trip.labels;
let detachedError = '';
try {
detached();
} catch (err) {
detachedError = err.name;
}
const bound = trip.labels.bind(trip);
// An arrow captures this once, at creation, and nothing can move it afterwards.
const depot = {
tag: 'depot',
makeReader() {
return () => this.tag;
},
};
const reader = depot.makeReader();
let notAConstructor = false;
const Arrow = () => {};
try {
new Arrow();
} catch {
notAConstructor = true;
}
console.log(trip.labels());
console.log('detached call threw a', detachedError);
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 method sees the object it was called on', trip.labels(), ['stop:Yaba', 'stop:Ojota', 'stop:Ikeja']);
test('an inner plain function does not', trip.labelsBroken(), 'threw: this was not the trip');
test('map\u2019s second argument fixes it', trip.labelsBound(), ['stop:Yaba', 'stop:Ojota', 'stop:Ikeja']);
test('a detached method loses the object entirely', detachedError, 'TypeError');
test('call hands it back for one call', detached.call(trip)[0], 'stop:Yaba');
test('bind makes that permanent', bound()[2], 'stop:Ikeja');
test('an arrow keeps the this it was created with', reader(), 'depot');
test('and call cannot move it', reader.call({ tag: 'somewhere else' }), 'depot');
test('an arrow is not a constructor either', notAConstructor, true);
this in a plain function is decided by the call, not by where the function was written: trip.labels() sets it to trip, and the bare detached() sets it to undefined, which is why reading this.stops throws a TypeError. The old var self = this dance existed entirely to smuggle the outer this past that rule.
An arrow has no this of its own, so it reads the enclosing scope's — permanently. That makes it right for callbacks inside a method, as in labels, and wrong for the method itself: an arrow written directly in an object literal captures whatever this was outside the object, which is never the object. reader.call({...}) being ignored is the proof that call, apply and bind have no purchase on an arrow. Arrows also have no arguments, no prototype, and cannot be called with new.
Answers JS Basic 93 in the original set.
94 Pass a function, do not call it
fairWrite one walker that formats a list and takes the formatting as a callback, so two different outputs need no second walker. Then look at what ['10','10','10'].map(parseInt) returns, and why.
Stuck? Three hints, smallest first
- 1.
list.map(describe)passes the function;list.map(describe())calls it far too early. - 2.
maphands the callback three arguments: value, index and the whole array. - 3.
parseInttakes a second argument, and it is a radix.
const SIGN = '\u20a6';
const fares = [200_00, 350_00, 500_00];
function summarise(list, describe) {
return list.map(describe).join(', ');
}
const asNaira = (kobo) => SIGN + (kobo / 100).toFixed(2);
const parsed = ['10', '10', '10'].map(parseInt);
// Calling the callback yourself hands map a value, not a function.
const shout = (text) => text.toUpperCase();
let calledTooEarly = '';
try {
['yaba'].map(shout());
} catch (err) {
calledTooEarly = err.name;
}
console.log(summarise(fares, asNaira));
console.log('map(parseInt) gives', parsed);
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 callback shapes the output', summarise(fares, asNaira), SIGN + '200.00, ' + SIGN + '350.00, ' + SIGN + '500.00');
test('a different callback, the same walker', summarise(fares, (kobo) => kobo / 100 + ' naira'), '200 naira, 350 naira, 500 naira');
test('map hands the callback three arguments, not one', ['a', 'b'].map((v, i, all) => v + i + all.length), ['a02', 'b12']);
test('so parseInt gets the index as its radix: 0 means decimal', parsed[0], 10);
test('radix 1 does not exist, so this one is not a number', Number.isNaN(parsed[1]), true);
test('and radix 2 reads "10" as binary', parsed[2], 2);
test('Number takes one argument, so it is safe here', ['10', '10', '10'].map(Number), [10, 10, 10]);
test('or wrap it and pass the radix yourself', ['10', '10', '10'].map((s) => parseInt(s, 10)), [10, 10, 10]);
test('calling the callback instead of passing it throws', calledTooEarly, 'TypeError');
Taking the formatting as an argument is the whole idea behind map, filter, sort and every event handler: the walker owns the loop, the caller owns the decision. Two outputs, one walker, no duplication.
['10','10','10'].map(parseInt) giving [10, NaN, 2] is the classic demonstration of the trap: map passes three arguments, parseInt accepts two, so the index arrives as the radix. Index 0 means "guess", index 1 is not a legal radix, and index 2 reads the digits as binary. Passing a function by name is only safe when its extra parameters cannot hurt you — hence map(Number) is fine and map(parseInt) is not. And note the missing brackets matter in both directions: map(shout) passes the function, map(shout()) calls it immediately with no argument and hands map the result.
Answers JS Basic 94 in the original set.
95 A counter that keeps its own count
fairWrite a function that returns a counter: a tick that increases a private number and a value that reads it. Two counters must not share a count. Then look at what var does to a function made inside a loop.
Stuck? Three hints, smallest first
- 1. Declare the count inside the maker, and return functions that refer to it.
- 2. Each call to the maker creates a new variable, so each counter is independent.
- 3.
varin a loop header is one variable for the whole loop;letis one per turn.
function makeCounter(start = 0) {
let count = start; // private: only these two functions can see it
return {
tick: () => ++count,
value: () => count,
};
}
const gate = makeCounter();
const kiosk = makeCounter(10);
gate.tick();
const afterTwo = gate.tick();
kiosk.tick();
// var is function-scoped, so all three closures share one i, read after the loop.
const varFns = [];
for (var i = 0; i < 3; i++) varFns.push(() => i);
// let is scoped to the turn, so each closure has its own j.
const letFns = [];
for (let j = 0; j < 3; j++) letFns.push(() => j);
// The same idea as a ticket machine: state that nothing else can reach.
const nextTicket = (() => {
let issued = 0;
return () => 'T' + String(++issued).padStart(3, '0');
})();
console.log('gate:', gate.value(), 'kiosk:', kiosk.value());
console.log('var:', varFns.map((f) => f()), 'let:', letFns.map((f) => f()));
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('two ticks', afterTwo, 2);
test('and the closure still remembers', gate.value(), 2);
test('a second counter has its own count', kiosk.value(), 11);
test('so the first is untouched', gate.value(), 2);
test('the variable itself is unreachable from outside', gate.count, undefined);
test('var in a loop leaves one shared binding, read after the loop ended', varFns.map((f) => f()), [3, 3, 3]);
test('let makes one binding per turn', letFns.map((f) => f()), [0, 1, 2]);
test('a ticket machine keeps its own numbering', [nextTicket(), nextTicket(), nextTicket()], ['T001', 'T002', 'T003']);
count outlives makeCounter because the returned functions still refer to it — that is a closure, and it is the plainest way to have private state in JavaScript. gate.count is undefined not because it is hidden but because it is not a property of anything; there is no path to it except through tick and value. Each call to the maker creates a new one, which is why the two counters cannot interfere.
var giving [3, 3, 3] is the interview question and a real bug in any loop that registers handlers. There is one i for the whole function, all three arrows close over it, and by the time they run the loop has finished and left it at 3. let creates a fresh binding per iteration, so each arrow captures its own. Before let, the fix was an immediately-invoked function per turn to make a new scope by hand — the same trick nextTicket uses here, for the same reason.
Answers JS Basic 95 in the original set.
96 The ternary, and the brackets it needs
easyPick a fare band with a chain of ternaries. Then build a message with + and a ternary in the same expression, without brackets, and work out why the answer never changes.
Stuck? Three hints, smallest first
- 1. A ternary is an expression, so it can be returned or assigned directly.
- 2. Chained ternaries read as a ladder because
? :groups to the right. - 3.
+binds tighter than? :, so the whole concatenation becomes the condition.
const tier = (kobo) => (kobo >= 500_00 ? 'luxury' : kobo >= 300_00 ? 'standard' : 'economy');
const shortest = (a, b) => (a < b ? a : b);
const paid = false;
// + happens first, so the condition is the string 'fare: false', which is truthy.
const wrong = 'fare: ' + paid ? 'yes' : 'no';
// The brackets are what make it a choice between two words.
const right = 'fare: ' + (paid ? 'yes' : 'no');
console.log('wrong:', wrong, '| right:', right);
console.log([600_00, 500_00, 350_00, 120_00].map(tier));
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 top band', tier(600_00), 'luxury');
test('a boundary belongs to the higher band', tier(500_00), 'luxury');
test('the middle band', tier(350_00), 'standard');
test('and the bottom', tier(120_00), 'economy');
test('a chain of ternaries reads as a ladder', [600_00, 350_00, 120_00].map(tier), ['luxury', 'standard', 'economy']);
test('+ binds tighter than ?:, so this is always yes', wrong, 'yes');
test('the brackets are not decoration', right, 'fare: no');
test('and a ternary is an expression, so it can be returned as it stands', shortest(4, 9), 4);
The ternary earns its place when both branches produce a value for the same slot — assigning one of two fares, choosing a word inside a template. Where the branches do two different things, an if says so more clearly, and where there are four bands rather than three, a lookup table (exercise 87) or a switch (exercise 97) is easier to extend.
'fare: ' + paid ? 'yes' : 'no' is the trap, and it is silent: + runs first, so the condition is the non-empty string 'fare: false', every non-empty string is truthy, and the result is 'yes' regardless of what paid holds. Nothing throws, and a test that only checks the happy path passes. Put brackets round a ternary whenever it shares an expression with anything else.
Answers JS Basic 96 in the original set.
97 switch, deliberate fallthrough and the missing break
fairMap a fare tier to an amount with a switch, letting two tiers share one case. Then write the same switch with the breaks left out, and check what a '1' matches.
Stuck? Three hints, smallest first
- 1. Two
caselabels stacked with no code between them share the body below. - 2. Without
breakorreturn, execution runs on into the next case. - 3.
switchmatches with===, so no conversion happens.
function fareBand(tier) {
switch (tier) {
case 'luxury':
case 'executive': // deliberate: both fall into the line below
return 500_00;
case 'standard':
return 350_00;
case 'economy':
return 200_00;
default:
return null;
}
}
// The same ladder with assignments and no breaks. Every match runs to the bottom.
function leaky(tier) {
let fare = 0;
switch (tier) {
case 'luxury':
fare = 500_00;
case 'standard':
fare = 350_00;
case 'economy':
fare = 200_00;
}
return fare;
}
function match(value) {
switch (value) {
case 1:
return 'number one';
default:
return 'no match';
}
}
// default is chosen only when no case matches, wherever it is written.
function defaultFirst(tier) {
switch (tier) {
default:
return 'none';
case 'luxury':
return 'top';
}
}
console.log('leaky:', ['luxury', 'standard', 'economy'].map(leaky));
console.log('switch on a string 1:', match('1'));
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 stacked case is a deliberate fallthrough', fareBand('executive'), 50000);
test('and the plain case beneath it', fareBand('standard'), 35000);
test('an unknown tier reaches default', fareBand('danfo'), null);
test('a missing break falls into the next case', leaky('luxury'), 20000);
test('which is only visible at the bottom of the ladder', leaky('standard'), 20000);
test('the last case has nothing left to fall into', leaky('economy'), 20000);
test('switch compares with ===, so the string 1 is not the number 1', match('1'), 'no match');
test('the number matches', match(1), 'number one');
test('default runs last even when it is written first', defaultFirst('danfo'), 'none');
Fallthrough is a feature and a hazard in the same mechanism. Stacking case 'luxury': case 'executive': with nothing between them is the clean way to give two values one answer. Forgetting a break gives you leaky, where every tier returns ₦200 because control ran through every remaining case — and the compiler is happy, because that is exactly what switch is defined to do.
Returning from each case, as fareBand does, makes the whole class of bug impossible and is the shape to prefer. Two smaller points: switch matches with ===, so a form field's '1' will never hit case 1 — convert first; and default is chosen only when nothing else matched, no matter where it appears, though writing it last is a kindness to the reader. For anything that is purely value-to-value, a lookup table (exercise 87) is shorter than either.
Answers JS Basic 97 in the original set.
98 Nullish or falsy: ?? against ||
fairFill in missing settings with defaults. Try || and ?? against a seat count of zero, an empty note, a null tier and an air-conditioning flag of false, and decide which is right for each.
Stuck? Three hints, smallest first
- 1.
||replaces anything falsy:0,'',false,NaN,null,undefined. - 2.
??replaces onlynullandundefined. - 3.
??=assigns only when the current value is nullish.
const settings = { seats: 0, note: '', tier: null, ac: false };
const trip = { fare: 0 };
trip.fare ??= 200_00; // already 0, which is not nullish, so it stays
trip.tier ??= 'economy'; // missing, so it is filled in
// Mixing ?? with || or && without brackets is a syntax error. new Function
// parses without running, which is how that can be shown without stopping here.
let mixing = '';
try {
new Function('return null ?? true || false;');
} catch (err) {
mixing = err.name;
}
console.log('seats:', settings.seats || 14, 'vs', settings.seats ?? 14);
console.log('trip after ??=:', trip);
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('|| throws away a real zero', settings.seats || 14, 14);
test('?? keeps it', settings.seats ?? 14, 0);
test('|| throws away an empty note', settings.note || 'none', 'none');
test('?? keeps that too', settings.note ?? 'none', '');
test('both replace null, which is what they are for', [settings.tier || 'economy', settings.tier ?? 'economy'], ['economy', 'economy']);
test('?? keeps a deliberate false where || would not', settings.ac ?? true, false);
test('??= leaves a zero alone', trip.fare, 0);
test('but fills in a missing key', trip.tier, 'economy');
test('mixing ?? with || unbracketed is a syntax error', mixing, 'SyntaxError');
|| asks "is this falsy?", and 0, '' and false are all falsy while being perfectly good answers. A shuttle with 0 seats becomes 14, an empty note becomes 'none', and a customer who turned the air conditioning off gets it back on. ?? asks the narrower question — "is this null or undefined?" — which is almost always the question you meant when filling in a default.
|| is still right when any falsy value should be treated as absent, such as a search box whose empty string means "no filter". ??= is the assignment form and is handy for filling gaps in an options object in place. The syntax error is deliberate on the language's part: a ?? b || c has no obvious precedence, so instead of guessing, the parser refuses and makes you write the brackets.
Answers JS Basic 98 in the original set.
99 Optional chaining down a record that may be empty
easyRead a passenger's phone number out of a trip record where the passenger, and their contact details, may be missing. Use ?., then show a chain where one ?. is not enough.
Stuck? Three hints, smallest first
- 1.
a?.bgivesundefinedinstead of throwing whenais null or undefined. - 2. Once it short-circuits, the whole rest of the chain is skipped.
- 3. It only guards the step it is written on, so a later step can still throw.
const trip = {
code: 'LAG-IB-07',
passenger: { name: 'Adaeze', contact: { phone: '0803 000 0000' } },
stops: ['Yaba', 'Ojota'],
};
const empty = { code: 'LAG-IB-08' }; // no passenger at all
const partial = { code: 'LAG-IB-09', passenger: { name: 'Bola' } }; // no contact
let plainThrew = '';
try {
empty.passenger.name;
} catch (err) {
plainThrew = err.name;
}
// passenger exists, contact does not, so .phone runs on undefined and throws.
let stillThrew = '';
try {
partial.passenger?.contact.phone;
} catch (err) {
stillThrew = err.name;
}
console.log('phone:', trip.passenger?.contact?.phone);
console.log('missing passenger:', empty.passenger?.contact?.phone);
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 branch reads normally', trip.passenger?.name, 'Adaeze');
test('a missing one gives undefined rather than throwing', empty.passenger?.name, undefined);
test('without the ?. it throws', plainThrew, 'TypeError');
test('once it short-circuits the rest of the chain is skipped', empty.passenger?.contact.phone, undefined);
test('but an earlier ?. does not protect a later step', stillThrew, 'TypeError');
test('the guarded version of that chain is fine', partial.passenger?.contact?.phone, undefined);
test('?.[ ] does the same for an index', empty.stops?.[0], undefined);
test('?.() for a method that may not be there', trip.describe?.(), undefined);
test('pair it with ?? for a readable default', empty.passenger?.name ?? 'unknown', 'unknown');
?. answers one question: is the thing on its left null or undefined? If so, the whole expression stops there and evaluates to undefined, which is why empty.passenger?.contact.phone does not throw even though .phone is unguarded — evaluation never reached it.
partial is the case that catches people. passenger exists, so the chain does not short-circuit; contact is undefined; and the unguarded .phone throws. A ?. guards the step it is written on, so every step that might be missing needs its own. Two limits worth knowing: ?. cannot be used on the left of an assignment, and it says nothing about a key existing — a genuine undefined value and a missing branch read identically, which is what Object.hasOwn (exercise 84) is for. Reach for ?? 'default' at the end rather than leaving undefined to travel further.
Answers JS Basic 99 in the original set.
100 Loose equality, and the comparisons that surprise
fairCompare a few pairs of values with == and with ===: a number against its string, an empty string against zero, null against undefined and against zero, and an empty array against false. Also exercise 34, which asks what type a value is; here the angle is what happens when two types are compared.
Stuck? Three hints, smallest first
- 1.
==converts before it compares;===refuses and answers false. - 2.
nullis loosely equal toundefinedand to nothing else at all. - 3. An array converts to a string first, and an empty array becomes an empty string.
const pairs = [
[0, '0'],
['', 0],
[null, undefined],
[null, 0],
[[], false],
];
const loose = pairs.map(([a, b]) => a == b);
const strict = pairs.map(([a, b]) => a === b);
console.log('loose: ', loose);
console.log('strict:', strict);
console.log('an empty array as a string:', JSON.stringify(String([])), 'as a number:', Number([]));
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('loose equality converts the string to a number', 0 == '0', true);
test('strict equality just says no', 0 === '0', false);
test('an empty string is loosely zero', '' == 0, true);
test('null is loosely equal to undefined', null == undefined, true);
test('but not to zero, and not to anything else', [null == 0, null == false, null == ''], [false, false, false]);
test('an empty array is loosely false', [] == false, true);
test('because it becomes an empty string first, then zero', [String([]), Number([])], ['', 0]);
test('typeof null is object, a bug too old to fix', typeof null, 'object');
test('and NaN is not equal to itself, so ask Number.isNaN', [NaN === NaN, Number.isNaN(NaN)], [false, true]);
Also exercise 34, there asking what type a value is; here the angle is what == does when the two types differ. It converts, following a table nobody remembers, and the results include '' == 0 and [] == false both being true — the array becomes '', which becomes 0, which equals false's 0. Use === and convert deliberately, and these stop being trivia.
The two exceptions worth keeping in your head. null == undefined is true, and both are false against everything else, which makes value == null a genuinely useful one-line "is it missing?" — the same question ?? asks. And NaN === NaN is false, so indexOf(NaN) never finds it and x === NaN is always false; Number.isNaN is the only reliable test, which is why the assertions in this course check Number.isNaN(x) against true rather than comparing to NaN. typeof null being 'object' is the oldest wart in the language and can never be fixed without breaking pages that depend on it.
Answers JS Basic 100 in the original set.
101 Reading one character: charAt, brackets and at
easyRead the first and last character of a bus stop name three ways — charAt, square brackets and at — then find out what each one does when the index is off the end or negative.
Stuck? Three hints, smallest first
- 1.
at(-1)is the last character; the other two know nothing about negative indices. - 2. Off the end, one of the three gives an empty string and the other two give
undefined. - 3. Before
atexisted, the last character wascharAt(length - 1).
const stop = 'Yaba';
const first = stop.charAt(0);
const alsoFirst = stop[0];
const last = stop.at(-1);
const lastTheOldWay = stop.charAt(stop.length - 1);
console.log('off the end: ', [stop.charAt(9), stop[9], stop.at(9)]);
console.log('negative index:', [stop.charAt(-1), stop[-1], stop.at(-1)]);
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('charAt(0) reads the first character', first, 'Y');
test('brackets read the same one', alsoFirst, 'Y');
test('at(-1) reads the last', last, 'a');
test('which is the length - 1 dance it replaces', last, lastTheOldWay);
test('past the end charAt gives an empty string', stop.charAt(9), '');
test('brackets give undefined instead', stop[9] === undefined, true);
test('and so does at', stop.at(9) === undefined, true);
test('a negative index means nothing to charAt or to brackets', [stop.charAt(-1), stop[-1] === undefined], ['', true]);
test('at counts backwards, and runs out at the front', [stop.at(-4), stop.at(-5) === undefined], ['Y', true]);
All three read the same character in the middle of a string and disagree completely at the edges, which is where bugs live. charAt off the end gives '', so stop.charAt(9).toUpperCase() quietly returns '' and the fault surfaces somewhere else; brackets and at give undefined, so the same line throws immediately. The loud failure is usually the kinder one.
stop[-1] is not an error and not the last character — it reads a property named "-1" that does not exist, so undefined. Only at understands negatives, which is the whole reason it was added. Also exercise 5, which rotated a string with slice; here the angle is a single position rather than a whole cut.
Answers JS Basic 101 in the original set.
102 slice against substring, and negative indices
easyCut Lekki Phase 1 apart: the first word, the last character, and everything except the last three characters. Then hand slice and substring the same reversed and negative arguments and compare what comes back.
Stuck? Three hints, smallest first
- 1. Both take a start and an end, and both exclude the end.
- 2.
substringswaps arguments that are the wrong way round.slicedoes not. - 3. A negative index counts from the right in
sliceand is clamped to zero insubstring.
const area = 'Lekki Phase 1';
const firstWord = area.slice(0, 5);
const lastChar = area.slice(-1);
const withoutLastThree = area.slice(0, -3);
// The same two arguments, the wrong way round, to each method.
const sliceBackwards = area.slice(9, 6);
const substringBackwards = area.substring(9, 6);
// And a negative start to each.
const sliceNegative = area.slice(-5);
const substringNegative = area.substring(-5);
console.log('backwards:', JSON.stringify([sliceBackwards, substringBackwards]));
console.log('negative: ', JSON.stringify([sliceNegative, substringNegative]));
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('slice takes a start and an end, end excluded', firstWord, 'Lekki');
test('one argument runs to the end', area.slice(6), 'Phase 1');
test('a negative start counts from the right', lastChar, '1');
test('a negative end trims from the right', withoutLastThree, 'Lekki Phas');
test('slice with the arguments reversed gives nothing', sliceBackwards, '');
test('substring quietly swaps them and gives text', substringBackwards, 'Pha');
test('slice reads a negative start from the right', sliceNegative, 'ase 1');
test('substring clamps it to zero, so you get everything', substringNegative, area);
test('the excluded end means a split at n rejoins exactly', area.slice(0, 5) + area.slice(5), area);
Reach for slice and forget substring exists. They agree on the ordinary case, so the difference only shows up when the arguments are computed and one of them comes out wrong: slice(start, end) with end before start returns '', which is visibly empty, while substring silently reorders them and hands back text from a range you never asked for. That is a bug that survives review.
The negative case is the same story. slice(-5) means "the last five", which is the idiom for a suffix; substring(-5) treats -5 as 0 and returns the whole string, so a clamped-to-zero index looks like success. The last assertion is the property worth remembering: because the end is excluded, slice(0, n) and slice(n) partition a string with nothing lost or duplicated.
Answers JS Basic 102 in the original set.
103 padStart and padEnd for a fixed width
easyPrint a ticket number as six digits with leading zeros, and a menu label padded out to a ten-character column with dots. Then check what happens when the text is already longer than the width, and when the pad is more than one character.
Stuck? Three hints, smallest first
- 1. Both take the total width you want, not how many characters to add.
- 2. Neither ever shortens anything.
- 3. They are string methods, so a number needs
String(n)first.
const ticket = 87;
const padded = String(ticket).padStart(6, '0');
const label = 'Suya'.padEnd(10, '.');
// Neither one truncates. A string already past the width comes back untouched.
const tooLong = 'LAGOS-IBADAN'.padStart(5, '0');
// A multi-character pad is repeated, then cut to fit the width exactly.
const fancy = '7'.padStart(6, 'ab');
// A number has no padStart, which is the mistake everyone makes once.
let numberThrew = '';
try {
ticket.padStart(6, '0');
} catch (err) {
numberThrew = err.name;
}
console.log(label + '|');
console.log('ticket:', padded, ' multi-character pad:', fancy);
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('leading zeros to a fixed width', padded, '000087');
test('the argument is the total width, not the padding added', padded.length, 6);
test('padEnd fills to the right', label, 'Suya......');
test('a string past the width is returned unchanged', tooLong, 'LAGOS-IBADAN');
test('so padding is not a way to truncate', tooLong.length, 12);
test('a multi-character pad is cut to fit', fancy, 'ababa7');
test('the default pad is a space', 'A'.padStart(3), ' A');
test('an empty string pads to full width', ''.padStart(3, '0'), '000');
test('a number has no such method', numberThrew, 'TypeError');
Also exercise 1, where padStart put a leading zero on a clock's minutes; here the angle is the width rule itself. The argument is the finished length, so padStart(6, '0') on three digits adds three zeros and on eight digits adds none — and adds none silently. If your columns must not exceed a width, pad and then slice, because padding alone will never shorten anything (exercise 37 does the cutting half).
Two smaller edges. A pad longer than one character is repeated and then chopped mid-pattern, so 'ab' gives ababa rather than a whole number of copies. And these live on String.prototype: 87 .padStart is undefined, so calling it is a TypeError. Wrap the number in String(...) before you pad it.
Answers JS Basic 103 in the original set.
104 trim, and the whitespace a form field brings
easyClean up what someone typed into a search box: leading spaces, a trailing newline, a non-breaking space pasted from a web page, and a zero-width space. Use trim, trimStart and trimEnd, and find out which of those four trim actually removes.
Stuck? Three hints, smallest first
- 1. Tabs, newlines and non-breaking spaces all count as whitespace.
- 2. A zero-width space is not whitespace, so nothing trims it.
- 3.
trimtouches the two ends only, never the middle.
const typed = ' Ikeja City Mall \n';
const pasted = '\u00a0Yaba\u00a0'; // non-breaking spaces, from a web page
const invisible = 'Yaba\u200b'; // a zero-width space, from anywhere
console.log(JSON.stringify(typed.trim()));
console.log('zero-width space still there:', invisible.length, 'characters for 4 letters');
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('trim takes both ends off', typed.trim(), 'Ikeja City Mall');
test('trimStart leaves the tail alone', typed.trimStart(), 'Ikeja City Mall \n');
test('trimEnd leaves the head alone', typed.trimEnd(), ' Ikeja City Mall');
test('the middle is never touched', ' two words '.trim(), 'two words');
test('a non-breaking space is whitespace and goes', pasted.trim(), 'Yaba');
test('a zero-width space is not, and stays', invisible.trim(), invisible);
test('which is why the length gives it away', invisible.trim().length, 5);
test('a field of only spaces trims to empty', ' '.trim(), '');
test('but is truthy before you trim it, so trim then test', [' ' ? 'truthy' : 'falsy', ' '.trim() ? 'truthy' : 'falsy'], ['truthy', 'falsy']);
The last assertion is the one that reaches production. ' ' is a non-empty string, so if (value) is true and a required field made entirely of spaces sails through. Trim first, then ask (exercise 116 builds that check properly).
trim is generous about what whitespace means — spaces, tabs, newlines, and the non-breaking space that arrives whenever text is pasted from a web page, which is invisible and not equal to ' '. It is not generous enough for a zero-width space, which is a formatting character rather than whitespace, so 'Yaba\u200b' trims to itself and compares unequal to 'Yaba' for reasons nobody can see on screen. When a string looks right but will not match, print its length. And trim only works on the ends: collapsing runs inside the text needs replace(/\s+/g, ' ').
Answers JS Basic 104 in the original set.
105 Split a full name into its parts
easyTake a full name as typed and pull out the first name, any middle names and the surname. Cope with one name only, with two, with three, with four, and with someone who leaned on the space bar.
Stuck? Three hints, smallest first
- 1. Trim first, then split on
/\s+/so a double space does not become an empty name. - 2. The surname is the last part — but only if there is more than one part.
- 3. Everything between the first and last belongs to the middle, however many there are.
function nameParts(full) {
const parts = String(full).trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return { first: '', middle: '', last: '' };
const [first, ...rest] = parts;
const last = rest.length ? rest[rest.length - 1] : '';
const middle = rest.slice(0, -1).join(' ');
return { first, middle, last };
}
console.log(nameParts('Chinwe Adaeze Okonkwo'));
console.log('a double space, split on a single one:', JSON.stringify('Bola Ahmed'.split(' ')));
Output
8 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 8 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('three names split into three roles', nameParts('Chinwe Adaeze Okonkwo'), { first: 'Chinwe', middle: 'Adaeze', last: 'Okonkwo' });
test('two names leave the middle empty', nameParts('Bola Ahmed'), { first: 'Bola', middle: '', last: 'Ahmed' });
test('one name is a first name, not a surname', nameParts('Ngozi'), { first: 'Ngozi', middle: '', last: '' });
test('several middle names stay together', nameParts('Ifeoma Ada Ugochi Nwosu').middle, 'Ada Ugochi');
test('extra spaces and a stray tab do not become empty parts', nameParts(' Bola\t Ahmed '), { first: 'Bola', middle: '', last: 'Ahmed' });
test('empty input gives empty parts rather than throwing', nameParts(' '), { first: '', middle: '', last: '' });
test('splitting on a single space would have made an empty name', 'Bola Ahmed'.split(' '), ['Bola', '', 'Ahmed']);
test('which /\\s+/ avoids', 'Bola Ahmed'.split(/\s+/), ['Bola', 'Ahmed']);
split(' ') is the trap: it splits on exactly one space, so 'Bola Ahmed' becomes three parts with an empty string in the middle, and the surname ends up being ''. split(/\s+/) treats any run of whitespace as one separator and handles the tab a paste brings with it. The trim matters too — a leading space would still produce an empty first element even with the regex.
The design point is bigger than the code. "First, middle, last" is a Western convention, not a fact: many people have one name, many surnames come first, and plenty contain spaces (de Souza, Ade Bello). This function will get those wrong, and no clever splitting fixes it. If the name matters, ask for the parts you need in separate fields and store what you were given; parse only when you have no choice, as here.
Answers JS Basic 105 in the original set.
106 Initials from a name
easyTurn Chinwe Adaeze Okonkwo into C.A.O. Handle lowercase input, extra spaces, an empty string, and decide what a hyphenated surname should do.
Stuck? Three hints, smallest first
- 1. Split into words, take the first character of each, uppercase it.
- 2. Filter out empty words or you will get a stray separator.
- 3. Whether
Ade-Bellois one initial or two is a decision, not a bug — make it visible.
function initials(name, separator = '.', splitOn = /\s+/) {
const words = String(name).trim().split(splitOn).filter(Boolean);
return words.map((w) => w[0].toUpperCase() + separator).join('');
}
const withHyphens = (name) => initials(name, '.', /[\s-]+/);
console.log(initials('Chinwe Adaeze Okonkwo'), '|', initials('ngozi ade-bello'), '|', withHyphens('ngozi ade-bello'));
Output
8 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 8 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('three names, three initials', initials('Chinwe Adaeze Okonkwo'), 'C.A.O.');
test('lowercase input is raised', initials('bola ahmed'), 'B.A.');
test('one name gives one initial', initials('Ngozi'), 'N.');
test('extra whitespace does not add empty initials', initials(' Bola Ahmed '), 'B.A.');
test('empty input gives empty text, not a lone dot', initials(' '), '');
test('to whitespace, a hyphenated surname is one word', initials('Ngozi Ade-Bello'), 'N.A.');
test('treat the hyphen as a separator and it is two', withHyphens('Ngozi Ade-Bello'), 'N.A.B.');
test('the separator is a parameter, so a compact form is free', initials('Chinwe Adaeze Okonkwo', ''), 'CAO');
filter(Boolean) is doing real work. Without it a leading space produces an empty word, w[0] on it is undefined, and undefined.toUpperCase() throws — so the crash appears only for input with a stray space, which is exactly the input a user provides. Trimming and filtering together make the empty case return '' instead of a lone separator.
The hyphen is the interesting part, and it is a product question rather than a coding one. Splitting on whitespace gives Ngozi Ade-Bello two initials; splitting on whitespace or hyphens gives three. Both are defensible; what is not defensible is discovering which one you chose from a bug report. Passing the pattern in makes the choice a visible argument. Same ground as exercise 24, which capitalised every word — there the first letter was changed in place, here it is all that survives.
Answers JS Basic 106 in the original set.
107 Mask a phone number
easyShow a stored phone number as *******4567: last four digits visible, everything else hidden, and the length unchanged. Then make sure a short number is not accidentally revealed in full.
Stuck? Three hints, smallest first
- 1.
slice(-4)gives the visible tail;padStartcan fill the rest with stars. - 2. If the number is no longer than the visible window, hide all of it.
- 3.
slice(-0)is not "the last nothing" — check what it actually does.
function mask(value, keep = 4, char = '*') {
const text = String(value);
if (keep <= 0 || text.length <= keep) return char.repeat(text.length);
return text.slice(-keep).padStart(text.length, char);
}
console.log(mask('08031234567'), mask('5399123456781234'), mask('0803'));
console.log('slice(-0) returns:', '08031234567'.slice(-0));
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 last four digits stay readable', mask('08031234567'), '*******4567');
test('the length is unchanged, so the layout does not move', mask('08031234567').length, 11);
test('a card number masks the same way', mask('5399123456781234'), '************1234');
test('a number no longer than the window is hidden completely', mask('0803'), '****');
test('empty text stays empty', mask(''), '');
test('keep 0 hides everything', mask('08031234567', 0), '***********');
test('and without that guard slice(-0) would show it all', '08031234567'.slice(-0), '08031234567');
test('a numeric argument survives because of String()', mask(8031234567), '******4567');
test('the mask character is a parameter', mask('08031234567', 3, 'x'), 'xxxxxxxx567');
slice(-keep).padStart(text.length, char) is the whole trick: take the tail you want to show, then pad it back out to the original width. That keeps the mask the same length as the number, so a table of masked numbers stays aligned and nobody learns how long the value was from the column shifting.
keep = 0 is the trap, and it is -0. Negative zero equals zero, so slice(-0) is slice(0) — the entire string, printed in the clear, in the one case where you asked for maximum secrecy. The guard turns that into all stars. Masking is presentation, not protection: the full value is still in memory and in the response that carried it, so if it must not reach the browser, mask it on the server. Money and identifiers are also why String(value) is there — a phone number held as a number has already lost its leading zero.
Answers JS Basic 107 in the original set.
108 Count how many times one string occurs in another
fairCount the occurrences of a word inside a sentence. Then count aa inside aaaa and decide whether the answer is two or three.
Stuck? Three hints, smallest first
- 1.
text.split(needle).length - 1counts non-overlapping occurrences in one line. - 2. A loop with
indexOf(needle, from)lets you choose how far to jump after a match. - 3. An empty needle has to be refused, or the loop never ends.
function countOccurrences(text, needle) {
if (!needle) return 0;
return text.split(needle).length - 1;
}
// Same count, done by hand, so the step after a match is visible.
function countOverlapping(text, needle) {
if (!needle) return 0;
let count = 0;
let at = text.indexOf(needle);
while (at !== -1) {
count++;
at = text.indexOf(needle, at + 1); // + 1, not + needle.length
}
return count;
}
console.log('aaaa contains aa:', countOccurrences('aaaa', 'aa'), 'non-overlapping,', countOverlapping('aaaa', 'aa'), 'overlapping');
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 word repeated three times', countOccurrences('Lagos to Lagos through Lagos', 'Lagos'), 3);
test('a single character', countOccurrences('banana', 'a'), 3);
test('something absent counts zero', countOccurrences('banana', 'z'), 0);
test('overlaps are skipped when you jump the whole match', countOccurrences('aaaa', 'aa'), 2);
test('and counted when you jump one character', countOverlapping('aaaa', 'aa'), 3);
test('counting is case sensitive', countOccurrences('Lagos lagos', 'lagos'), 1);
test('lowercase both sides to count either', countOccurrences('Lagos lagos'.toLowerCase(), 'lagos'), 2);
test('an empty needle is refused rather than answered', [countOccurrences('aaaa', ''), countOverlapping('aaaa', '')], [0, 0]);
test('because splitting on it counts characters, which is a different question', 'aaaa'.split('').length - 1, 3);
Two is right for "how many times can I take this out", three is right for "how many places does it appear". The split version answers the first because split consumes each match; the loop answers whichever you choose by how far it advances — at + 1 allows overlaps, at + needle.length forbids them. Neither is the correct answer in general, so pick deliberately and say which in the name.
The empty needle is not a hypothetical. indexOf('', from) matches at every position, so a loop advancing by needle.length never moves and hangs the tab; and split('') returns every character, so the split version reports the string's length as an occurrence count. Guarding at the top gives one honest answer to a meaningless question. Also exercise 25, which tallied every character at once; here a single needle is counted, and the overlap question only exists because it can be more than one character long.
Answers JS Basic 108 in the original set.
109 Replace every occurrence, not just the first
fairSwap every danfo in a sentence for keke. Try replace with a string, replaceAll, and a global regex — then find out what $ does inside a replacement.
Stuck? Three hints, smallest first
- 1.
replacewith a string replaces the first match only. - 2.
replaceAllwith a regex insists that regex be global. - 3. In a replacement string
$&is the match and$$is a literal dollar sign.
const line = 'danfo, danfo and one more danfo';
const first = line.replace('danfo', 'keke');
const all = line.replaceAll('danfo', 'keke');
const viaRegex = line.replace(/danfo/g, 'keke');
let nonGlobalThrew = '';
try {
line.replaceAll(/danfo/, 'keke');
} catch (err) {
nonGlobalThrew = err.name;
}
// $ is special inside a replacement string, whichever method you used.
const doubled = 'fare'.replace('fare', '$& and $&');
const literalDollar = 'price'.replace('price', 'US$$5');
const viaCallback = 'price'.replace('price', () => 'US$5');
console.log(first);
console.log(all);
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('replace with a string changes the first only', first, 'keke, danfo and one more danfo');
test('replaceAll changes every one', all, 'keke, keke and one more keke');
test('a global regex does the same', viaRegex, all);
test('replaceAll with a non-global regex is refused', nonGlobalThrew, 'TypeError');
test('$& in a replacement means the whole match', doubled, 'fare and fare');
test('so a lone $ needs doubling to survive', literalDollar, 'US$5');
test('a function replacement is taken literally, no escaping needed', viaCallback, 'US$5');
test('strings are immutable, so the original is untouched', line.split('danfo').length - 1, 3);
test('and a needle that is absent changes nothing', line.replaceAll('okada', 'keke'), line);
replace with a string argument replaces one occurrence, which is the single most common cause of "it worked on my test data". replaceAll exists to say what you meant; before it, the only way was a global regex, and forgetting the g gave the same one-match behaviour silently. replaceAll refuses a non-global regex outright rather than repeating that mistake — a rare case of the language choosing a loud error over a plausible guess.
The $ rules bite when the replacement is data rather than a literal. Any user-supplied text containing $&, $1 or $' will be interpreted, so pasting a price of $5 into a replacement string is a real bug. Pass a function instead: its return value is inserted verbatim, no escaping and no surprises. And note none of this mutates — replace returns new text, so ignoring the return value is a no-op.
Answers JS Basic 109 in the original set.
110 camelCase from a sentence
fairTurn total fare in kobo into totalFareInKobo. Handle hyphens and underscores as separators too, cope with input that SHOUTS, and write the inverse that turns it back into total-fare-in-kobo.
Stuck? Three hints, smallest first
- 1. Lowercase everything first, then raise the first letter of every word except the first.
- 2. One
split(/[\s_-]+/)handles spaces, underscores and hyphens together. - 3. Going back is a
replaceon every capital letter.
function camel(text) {
const words = String(text).trim().toLowerCase().split(/[\s_-]+/).filter(Boolean);
return words.map((w, i) => (i === 0 ? w : w[0].toUpperCase() + w.slice(1))).join('');
}
// The same thing without the lowercasing, to show what it was for.
function naiveCamel(text) {
const words = String(text).trim().split(/\s+/).filter(Boolean);
return words.map((w, i) => (i === 0 ? w : w[0].toUpperCase() + w.slice(1))).join('');
}
const kebab = (name) => name.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase());
console.log(camel('total fare in kobo'), '|', kebab(camel('total fare in kobo')));
console.log('shouted input:', naiveCamel('TOTAL FARE'), 'vs', camel('TOTAL FARE'));
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 sentence becomes one identifier', camel('total fare in kobo'), 'totalFareInKobo');
test('the first word stays lowercase', camel('Bus Stop'), 'busStop');
test('hyphens and underscores are separators too', camel('bus-stop_name'), 'busStopName');
test('extra whitespace makes no empty words', camel(' two words '), 'twoWords');
test('a single word is left alone', camel('fare'), 'fare');
test('empty text gives empty text', camel(' '), '');
test('shouted input needs the lowercasing', camel('TOTAL FARE'), 'totalFare');
test('without it, the shout survives', naiveCamel('TOTAL FARE'), 'TOTALFARE');
test('and kebab-case is the trip back', kebab(camel('total fare in kobo')), 'total-fare-in-kobo');
toLowerCase() before splitting is the line people leave out, and it only shows up on real input: a header of TOTAL FARE or a label of Bus Stop keeps letters that should have been flattened, so naiveCamel yields TOTALFARE and BusStop — neither of which matches the key you later look up. Lowercase first, then raise exactly the letters you intend to raise.
Treating spaces, underscores and hyphens as one class of separator means the same function converts a form label, a database column and a CSS class, which is where these conversions are actually used. The round trip is not quite lossless in general: kebab splits on every capital, so parseHTTPResponse becomes parse-h-t-t-p-response. Acronyms need a smarter pattern or a policy of not using them. Also exercise 24, which capitalised the first letter of each word for display; the difference here is that the spaces are removed, so the result is an identifier rather than a title.
Answers JS Basic 110 in the original set.
111 Reverse the words, not the letters
easyGiven Ikeja to Yaba by bus, produce the words in the opposite order, then the letters of each word reversed in place, then the whole line reversed character by character. Three different jobs, three different answers.
Stuck? Three hints, smallest first
- 1. Reversing word order is
split,reverse,join. - 2.
reverse()changes the array it is called on, so copy first if the original still matters. - 3.
[...text]walks whole characters wheresplit('')walks UTF-16 units.
const line = 'Ikeja to Yaba by bus';
const wordsReversed = line.split(' ').reverse().join(' ');
const eachWordReversed = line.split(' ').map((w) => [...w].reverse().join('')).join(' ');
const lettersReversed = [...line].reverse().join('');
// reverse() is in place. Copy with a spread when you need the original after.
const words = line.split(' ');
const copy = [...words].reverse();
const bus = 'a🚌';
console.log(wordsReversed);
console.log(eachWordReversed);
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 words come back in the opposite order', wordsReversed, 'bus by Yaba to Ikeja');
test('each word reversed in place is a different thing', eachWordReversed, 'ajekI ot abaY yb sub');
test('and reversing every character is a third thing', lettersReversed, 'sub yb abaY ot ajekI');
test('reversing a copy leaves the original alone', words[0], 'Ikeja');
test('the copy is the reversed one', copy[0], 'bus');
test('reverse on the original does mutate it', words.reverse()[0], 'bus');
test('splitting on a single space preserves a double gap', 'Yaba Ojota'.split(' ').reverse().join(' '), 'Ojota Yaba');
test('spreading keeps an astral character whole', [...bus].reverse().join(''), '🚌a');
test('splitting on an empty string tears it in half', bus.split('').reverse().join('') === '🚌a', false);
"Reverse a string" is three questions wearing one name, and the assertions separate them: word order, letters within each word, and every character in the line. Getting asked for one and delivering another is the usual interview failure.
reverse() mutates. line.split(' ').reverse() is safe because split just made the array, but existingArray.reverse() rearranges the caller's data, which is a bug at a distance — [...existing].reverse() costs one spread and removes the class of problem. The last two assertions are the character-width trap: split('') cuts on UTF-16 units, so a bus emoji becomes two broken halves and reversing them produces a replacement glyph, while [...text] iterates code points and keeps it intact. Also exercise 14, which reversed text to test for a palindrome; here the angle is what "reversed" means when the unit is a word or a character wider than one unit.
Answers JS Basic 111 in the original set.
112 Prefix, suffix and contains
easyCheck whether a phone number starts with 080, ends with the four digits a customer quoted, and contains a run of digits. Use startsWith, endsWith and includes, including their position arguments, and see what happens if you pass a regex.
Stuck? Three hints, smallest first
- 1. All three take an optional position: a start offset for two of them, an end offset for
endsWith. - 2. The old form is
indexOf(needle) === 0. - 3. These three refuse a regular expression outright.
const phone = '08031234567';
const plate = 'KJA-234-XY';
let regexThrew = '';
try {
phone.startsWith(/^080/);
} catch (err) {
regexThrew = err.name;
}
console.log('network prefix:', phone.slice(0, 4), 'starts with 080:', phone.startsWith('080'));
console.log('plate state code:', plate.startsWith('KJA'));
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 prefix check reads as English', phone.startsWith('080'), true);
test('a suffix check too', phone.endsWith('4567'), true);
test('and contains, anywhere in the middle', phone.includes('123'), true);
test('the old form was an indexOf comparison', phone.indexOf('080') === 0, true);
test('startsWith takes an offset to start from', phone.startsWith('803', 1), true);
test('endsWith takes the position to treat as the end', phone.endsWith('0803', 4), true);
test('the checks are case sensitive', ['Yaba'.startsWith('yaba'), 'Yaba'.toLowerCase().startsWith('yaba')], [false, true]);
test('a regex argument is refused, not coerced', regexThrew, 'TypeError');
test('every string contains the empty string', [phone.includes(''), plate.startsWith('')], [true, true]);
These three replaced a generation of indexOf comparisons, and the win is that startsWith('080') cannot be typed wrong while indexOf('080') === 0 can: > 0, !== -1 and === 0 mean three different things and look alike at a glance.
The position arguments are the part people miss. startsWith(needle, i) asks whether the needle appears at i, which is a cheap way to test a fixed field inside a formatted string without slicing it out first, and endsWith(needle, i) pretends the string ends at i. Passing a regex throws a TypeError rather than stringifying it, so startsWith(/^080/) fails immediately instead of silently comparing against "/^080/" — for a pattern you want test (exercise 114 does the phone properly). And every string includes '', which matters when the needle comes from an empty search box: the filter matches everything, which is usually what you want, but by accident rather than design.
Answers JS Basic 112 in the original set.
113 Seconds to hh:mm:ss
fairFormat a number of seconds as hh:mm:ss — a journey of 3,661 seconds, a stopwatch at 59, a shift of a full day, and a duration of 100 hours. Handle a negative value and a fractional one.
Stuck? Three hints, smallest first
- 1. Hours are
Math.floor(total / 3600); minutes are the remainder divided by 60. - 2. Pad each part to two digits, and remember padding never truncates a three-digit hour.
- 3. The popular
new Date(ms).toISOString()one-liner cannot survive 24 hours.
function hms(totalSeconds) {
const sign = totalSeconds < 0 ? '-' : '';
const whole = Math.floor(Math.abs(totalSeconds));
const h = Math.floor(whole / 3600);
const m = Math.floor((whole % 3600) / 60);
const s = whole % 60;
const pad = (n) => String(n).padStart(2, '0');
return sign + [h, m, s].map(pad).join(':');
}
// A stopwatch usually drops the hours until there are some.
const short = (secs) => hms(secs).replace(/^00:/, '');
// The one-liner this replaces, and where it gives up.
const viaDate = (secs) => new Date(secs * 1000).toISOString().slice(11, 19);
console.log('3661 ->', hms(3661), ' 86400 ->', hms(86400), 'but via Date ->', viaDate(86400));
console.log('stopwatch:', short(125));
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('zero', hms(0), '00:00:00');
test('the minute rolls over', hms(60), '00:01:00');
test('an hour, a minute and a second', hms(3661), '01:01:01');
test('a full day is 24 hours, not zero', hms(86400), '24:00:00');
test('which is where the Date one-liner wraps and lies', viaDate(86400), '00:00:00');
test('a hundred hours needs three digits, and padding never truncates', hms(360000), '100:00:00');
test('a fractional second floors rather than rounds up', hms(59.9), '00:00:59');
test('a negative duration keeps its sign outside the digits', hms(-90), '-00:01:30');
test('and a stopwatch can drop an empty hours field', short(125), '02:05');
Also exercise 1, which formatted a clock time from a Date; here the input is an elapsed duration, and that difference is the whole exercise. A duration has no midnight to wrap at, so the new Date(secs * 1000).toISOString().slice(11, 19) trick that circulates as the elegant answer reports a 24-hour shift as 00:00:00 and a 25-hour one as 01:00:00. Divide and take remainders instead; then 100 hours prints as 100:00:00, because padStart(2, '0') pads short values and leaves long ones alone (exercise 103).
Math.floor on the absolute value, with the sign put back in front, is what keeps -90 as -00:01:30; flooring a negative directly rounds towards minus infinity and gives -1 hours and positive minutes, which is nonsense (exercise 52 is the floor against trunc version of this). Flooring also means a stopwatch never shows a second it has not finished — 59.9 seconds is 00:00:59, and rounding up would let it display 01:00 before the minute exists.
Answers JS Basic 113 in the original set.
114 Validate a Nigerian mobile number
fairAccept a mobile number however it was typed — 08031234567, 0803 123 4567, +2348031234567 — normalise it to the local form, and reject what is not a mobile number at all. Also exercise 49, which validated an email honestly; the same honesty applies here.
Stuck? Three hints, smallest first
- 1. Normalise first, validate second. One shape to check beats five patterns.
- 2. A local mobile number is eleven digits: a zero, then 70, 71, 80, 81, 90 or 91, then eight more.
- 3. The country code may or may not be followed by the trunk zero, so do not add a second one.
function normalisePhone(raw) {
let digits = String(raw).trim().replace(/[\s()-]/g, '');
if (digits.startsWith('+')) digits = digits.slice(1);
if (digits.startsWith('234')) digits = digits.slice(3);
if (!digits.startsWith('0')) digits = '0' + digits;
return digits;
}
// Eleven digits: 0, a network pair from 70/71/80/81/90/91, then eight more.
const isMobile = (raw) => /^0[789][01]\d{8}$/.test(normalisePhone(raw));
for (const n of ['08031234567', '0803 123 4567', '+2348031234567', '2348031234567', '08531234567']) {
console.log(String(n).padEnd(16), normalisePhone(n).padEnd(13), isMobile(n));
}
Output
9 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 9 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('a number as most people type it', isMobile('08031234567'), true);
test('spaces and brackets are cleaned off first', isMobile('(0803) 123-4567'), true);
test('the international form normalises to the local one', normalisePhone('+2348031234567'), '08031234567');
test('so does the country code without the plus', normalisePhone('2348031234567'), '08031234567');
test('and a country code that kept the trunk zero is not given a second', normalisePhone('23408031234567'), '08031234567');
test('ten digits is one short', isMobile('0803123456'), false);
test('letters are not digits', isMobile('0803abc4567'), false);
test('an unissued network pair is refused', [isMobile('08531234567'), isMobile('012345678')], [false, false]);
test('but a well-formed dead number passes, because shape is all this checks', isMobile('08000000000'), true);
Normalise then validate. The alternative — one pattern that tolerates spaces, hyphens, brackets, a leading plus, a country code and an optional trunk zero — is unreadable and impossible to extend, and it leaves the caller holding whichever of five formats arrived. Reducing every input to a single canonical form means the regex describes one shape, and everything downstream compares and stores that shape.
The trunk zero is the edge that catches people. +234 803 ... drops the zero and +234 0803 ... keeps it, both are written in the wild, and blindly prepending a zero after stripping 234 gives 00803.... The prefix sniffing is a heuristic, not a parse: a number that genuinely begins with the digits 234 would be mangled, which is the price of guessing. And as with the email in exercise 49, the last assertion is the lesson — 08000000000 is perfectly formed and belongs to nobody. Only a one-time code sent to the line proves it exists.
Answers JS Basic 114 in the original set.
115 Validate and format a plate number
fairCheck a Nigerian plate number — three letters, three digits, two letters, as in KJA-234-XY — accepting it typed in lowercase, with spaces, or with no separators at all. Print it back in the canonical hyphenated form.
Stuck? Three hints, smallest first
- 1. Strip everything that is not a letter or a digit, uppercase, then match one pattern.
- 2. The letter slots must be letters: a zero typed for the letter O has to fail.
- 3. Formatting is three slices with hyphens between them.
const PLATE = /^[A-Z]{3}[0-9]{3}[A-Z]{2}$/;
const normalisePlate = (raw) => String(raw).toUpperCase().replace(/[^A-Z0-9]/g, '');
const isPlate = (raw) => PLATE.test(normalisePlate(raw));
function formatPlate(raw) {
const p = normalisePlate(raw);
if (!PLATE.test(p)) return '';
return p.slice(0, 3) + '-' + p.slice(3, 6) + '-' + p.slice(6);
}
for (const p of ['KJA-234-XY', 'kja 234 xy', 'KJ0-234-XY', 'KJA-23-XY']) {
console.log(String(p).padEnd(12), isPlate(p), formatPlate(p));
}
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 canonical form is accepted', isPlate('KJA-234-XY'), true);
test('so is lowercase with spaces instead of hyphens', isPlate('kja 234 xy'), true);
test('and with no separators at all', isPlate('kja234xy'), true);
test('all three print the same way', formatPlate('kja234xy'), 'KJA-234-XY');
test('formatting an already formatted plate changes nothing', formatPlate(formatPlate('kja234xy')), 'KJA-234-XY');
test('two digits where three belong is refused', isPlate('KJA-23-XY'), false);
test('so are digits in the letter slots', isPlate('123-KJA-XY'), false);
test('a zero typed for the letter O fails, and looks identical on screen', isPlate('KJ0-234-XY'), false);
test('an invalid plate formats to empty rather than to nonsense', [formatPlate(''), formatPlate('KJA-23-XY')], ['', '']);
The KJ0 case is the one worth remembering. A zero in a letter slot fails the pattern, which is correct, but the person who typed it cannot see the difference between 0 and O in most fonts — so "invalid plate number" is a useless message. Either say which slot is wrong, or fix the obvious confusions before validating (O to 0 in digit slots and back in letter slots) and tell the user what you assumed.
Stripping non-alphanumerics before matching means one pattern covers every way a human might type separators, and it makes the validator and the formatter share a single definition — worth doing because the two drifting apart is how a value passes validation and then displays wrongly. Returning '' rather than a half-formatted string for invalid input keeps the failure visible. As in exercise 114, shape is all this proves: AAA-111-AA is well formed and hanging on no vehicle.
Answers JS Basic 115 in the original set.
116 A required field that is genuinely required
easyDecide whether a form field was filled in. It must reject an empty string, spaces only, null and undefined — and accept a fare of 0 and an air-conditioning answer of false, both of which are real answers.
Stuck? Three hints, smallest first
- 1.
if (value)throws away0andfalsealong with the blanks. - 2. A string needs trimming before it is measured.
- 3.
NaNis what a failedNumber()leaves behind, and it is not an answer.
function isProvided(value) {
if (value === null || value === undefined) return false;
if (typeof value === 'string') return value.trim().length > 0;
if (typeof value === 'number') return Number.isFinite(value);
return true;
}
const form = { stop: 'Yaba', note: ' ', seats: 0, ac: false, fare: Number('abc') };
console.log(Object.keys(form).filter((k) => !isProvided(form[k])), 'still to fill in');
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('text counts as provided', isProvided('Yaba'), true);
test('spaces only do not', isProvided(' '), false);
test('nor does an empty string', isProvided(''), false);
test('nor a missing value of either kind', [isProvided(null), isProvided(undefined)], [false, false]);
test('but zero is an answer', isProvided(0), true);
test('and so is a deliberate false', isProvided(false), true);
test('NaN is not, because it is a failed conversion', isProvided(Number('abc')), false);
test('a bare truthiness test disagrees on all three', [Boolean(0), Boolean(false), Boolean(' ')], [false, false, true]);
test('an empty array slips through, so a multi-select needs its own rule', isProvided([]), true);
if (!value) return 'required' is the bug this exercise exists to kill. It rejects 0, which is a valid number of extra bags; it rejects false, which is a customer deliberately declining air conditioning; and it accepts ' ', which is nothing at all. The falsy set and the empty set are different sets — exercise 98 makes the same point about || against ??.
So the check is per type: nullish is missing, a string is measured after trimming (exercise 104), a number must be finite so NaN from a failed Number() and Infinity from a division both fail, and anything else counts as given. The last assertion is deliberately uncomfortable: [] is a value, so this says provided, while a multi-select that submitted an empty array chose nothing. There is no universal answer to "is this filled in" — there is only the answer for this field, which is why the rule belongs next to the field rather than in one clever helper.
Answers JS Basic 116 in the original set.
117 Is the text long enough, and what counts as a character
easyCheck that a note is between 3 and 20 characters, inclusive. Then count a note containing an emoji both ways and decide which count the user would agree with.
Stuck? Three hints, smallest first
- 1. Inclusive bounds mean
>=and<=; getting one wrong is invisible until someone hits the edge. - 2. Trim before measuring, or trailing spaces buy length.
- 3.
.lengthcounts UTF-16 units;[...text].lengthcounts code points.
function lengthOk(text, min, max) {
const value = String(text).trim();
const n = [...value].length;
return n >= min && n <= max;
}
const note = 'Yaba 🚌';
const queue = '🚌'.repeat(10);
console.log(note, 'is', note.length, 'units but', [...note].length, 'characters');
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('exactly the minimum is allowed', lengthOk('abc', 3, 20), true);
test('one short is not', lengthOk('ab', 3, 20), false);
test('exactly the maximum is allowed', lengthOk('a'.repeat(20), 3, 20), true);
test('one over is not', lengthOk('a'.repeat(21), 3, 20), false);
test('trailing spaces do not buy length', [lengthOk('abc ', 3, 3), lengthOk('ab ', 3, 3)], [true, false]);
test('an emoji is two UTF-16 units but one character', [note.length, [...note].length], [7, 6]);
test('so ten buses look like twenty to .length', [queue.length, [...queue].length], [20, 10]);
test('and a limit of ten accepts them when you count characters', lengthOk(queue, 1, 10), true);
test('a minimum of zero lets an empty field through, which is a different rule', lengthOk('', 0, 5), true);
Also exercise 37, which cut text to a length; here the angle is what a length counts. JavaScript strings are UTF-16, and .length reports storage units, not characters — so an emoji costs 2, and a note of ten bus emojis reports 20 and is rejected by a 20-character limit the user has not reached. Spreading or Array.from iterates code points and gives the count a person would recognise.
Code points are closer, not correct. A flag, a family emoji or an accented letter written as e plus a combining mark is several code points and one visible character, so [...'e\u0301'].length is 2. The genuinely correct unit is the grapheme cluster, which needs Intl.Segmenter; reach for it when the count is shown to a user, as in a character countdown. Note also that the inclusive bounds are asserted at all four edges — min, min - 1, max, max + 1 — because an off-by-one in a range check passes every test that only uses values in the middle.
Answers JS Basic 117 in the original set.
118 A number in range, out of a form field
fairA seat count arrives from a form as text. Accept 1 to 20, and give a specific reason for each rejection: nothing typed, not a number, not a whole number, out of range. Also exercise 55, which clamped a number into a range; here an out-of-range value is a rejection rather than something to bend.
Stuck? Three hints, smallest first
- 1.
Number('')is0, so check for empty before converting. - 2.
Number('12kg')isNaNwhereparseInt('12kg')is12. - 3. Return the reason, not just
false, or the form cannot tell the user anything useful.
function parseSeats(raw, min = 1, max = 20) {
const text = String(raw).trim();
if (text === '') return { ok: false, reason: 'required' };
const n = Number(text);
if (!Number.isFinite(n)) return { ok: false, reason: 'not a number' };
if (!Number.isInteger(n)) return { ok: false, reason: 'not whole' };
if (n < min || n > max) return { ok: false, reason: 'out of range' };
return { ok: true, value: n };
}
for (const raw of ['14', ' 7 ', '', '12kg', '2.5', '99', '1e2']) {
console.log(JSON.stringify(raw).padEnd(8), JSON.stringify(parseSeats(raw)));
}
Output
9 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 9 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('a good value comes back converted', parseSeats('14'), { ok: true, value: 14 });
test('surrounding spaces are fine', parseSeats(' 7 ').value, 7);
test('an empty field is required, not zero', parseSeats('').reason, 'required');
test('which matters because Number of empty text is zero', Number(''), 0);
test('and whitespace converts to zero too', Number(' '), 0);
test('a unit stuck on the end is not a number', parseSeats('12kg').reason, 'not a number');
test('though parseInt would have accepted the first digits', parseInt('12kg', 10), 12);
test('a fraction of a seat is refused', parseSeats('2.5').reason, 'not whole');
test('and a valid number outside the range says so', parseSeats('99').reason, 'out of range');
Number('') being 0 is the trap that costs money. An empty quantity field converts to zero, zero passes a "must be a number" check, and the order is placed for nothing — or, with a range of 0 to 20, accepted outright. Test for empty text before converting, which is also why trim comes first: Number(' ') is 0 as well.
Number against parseInt is the other decision. parseInt reads as far as it understands and discards the rest, so '12kg' is 12 and '1e2' is 1; Number demands the whole string be numeric, so '12kg' is NaN and '1e2' is 100. For validating user input, whole-string strictness is what you want — silent truncation turns a typo into a plausible order. Number.isFinite rather than the global isFinite avoids the same coercion coming back in through the check itself, and returning a reason instead of a boolean is what lets the form point at the field and say why.
Answers JS Basic 118 in the original set.
119 One of a list of allowed options
easyCheck that a fare tier is one of economy, standard or executive, tolerating stray case and spaces. Compare an array, a Set and a plain object as the place to keep the list.
Stuck? Three hints, smallest first
- 1.
includessays what you mean;indexOf(x) !== -1is the older spelling. - 2. A
Setis the right shape once the list is long. - 3. Using an object as a membership list answers yes to keys it inherited.
const TIERS = ['economy', 'standard', 'executive'];
const TIER_SET = new Set(TIERS);
const AS_OBJECT = { economy: true, standard: true, executive: true };
const isTier = (value) => TIERS.includes(String(value).trim().toLowerCase());
console.log('executive:', isTier(' Executive '), ' luxury:', isTier('luxury'));
console.log('the object route on an inherited key:', Boolean(AS_OBJECT['toString']));
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 listed option is accepted', isTier('economy'), true);
test('case and spaces are normalised away first', isTier(' Executive '), true);
test('an unlisted option is refused', isTier('luxury'), false);
test('so is an empty choice', isTier(''), false);
test('indexOf is the same check, spelled worse', TIERS.indexOf('standard') !== -1, true);
test('a Set answers the same question', TIER_SET.has('economy'), true);
test('an object as a list says yes to keys it never had', Boolean(AS_OBJECT['toString']), true);
test('hasOwn is the honest version of that', [Object.hasOwn(AS_OBJECT, 'toString'), Object.hasOwn(AS_OBJECT, 'economy')], [false, true]);
test('and includes finds a NaN that indexOf never will', [[NaN].includes(NaN), [NaN].indexOf(NaN)], [true, -1]);
The object trap is the reason this is not just includes. AS_OBJECT[value] looks like a fast membership test until value is 'toString', 'constructor' or '__proto__' — all inherited from Object.prototype, all truthy, none of them yours. With user input as the key that is a validation bypass, not a curiosity. Use includes, a Set, or Object.hasOwn (exercise 84), or build the lookup with Object.create(null) so there is no prototype to inherit from.
includes over indexOf for the same reason as exercise 112: an index comparison has three plausible spellings and only one is right. It also uses SameValueZero, so it finds NaN, which indexOf cannot because indexOf compares with === and NaN === NaN is false (exercise 100). Normalising the input before the comparison — trim, lowercase — is what turns a strict list into one a human can type into, and keeping the list in one const means the same array can populate the <select> that offered the choice.
Answers JS Basic 119 in the original set.
120 A weighted grade, and the average that lies
fairWork out a final mark from assignments at 20%, a midterm at 30% and an exam at 50%, then turn it into a letter. Compare it with the plain average of the same three scores.
Stuck? Three hints, smallest first
- 1. Multiply each score by its weight, add them up, then divide by the total weight.
- 2. Dividing by the total weight means the weights can be 20/30/50 or 0.2/0.3/0.5 or 1/3/5.
- 3. A ladder of
ifs for the letter must run from the highest threshold down.
const SCHEME = [
{ name: 'assignments', weight: 20, score: 90 },
{ name: 'midterm', weight: 30, score: 80 },
{ name: 'exam', weight: 50, score: 55 },
];
function weighted(parts) {
const totalWeight = parts.reduce((sum, p) => sum + p.weight, 0);
if (totalWeight === 0) return 0;
const points = parts.reduce((sum, p) => sum + p.score * p.weight, 0);
return Math.round((points / totalWeight) * 10) / 10;
}
function letter(mark) {
if (mark >= 70) return 'A';
if (mark >= 60) return 'B';
if (mark >= 50) return 'C';
if (mark >= 45) return 'D';
return 'F';
}
const plainAverage = (parts) =>
Math.round((parts.reduce((sum, p) => sum + p.score, 0) / parts.length) * 10) / 10;
const asFractions = SCHEME.map((p) => ({ ...p, weight: p.weight / 100 }));
console.log('weighted', weighted(SCHEME), letter(weighted(SCHEME)), '/ unweighted', plainAverage(SCHEME), letter(plainAverage(SCHEME)));
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 weighted mark', weighted(SCHEME), 69.5);
test('which is a B, by half a mark', letter(weighted(SCHEME)), 'B');
test('the plain average would have given an A', [plainAverage(SCHEME), letter(plainAverage(SCHEME))], [75, 'A']);
test('fractions as weights give the same answer', weighted(asFractions), 69.5);
test('so do weights that do not total a hundred', weighted([{ weight: 1, score: 100 }, { weight: 3, score: 60 }]), 70);
test('dropping the exam reweights what is left', weighted(SCHEME.slice(0, 2)), 84);
test('the threshold is inclusive at the bottom of each band', [letter(70), letter(69.9)], ['A', 'B']);
test('an empty scheme is zero, not NaN', weighted([]), 0);
test('which is what the division alone would have produced', Number.isNaN(0 / 0), true);
The gap between 69.5 and 75 is the exercise. Averaging the three scores treats a coursework mark as equal to an exam that is half the grade, and here that difference is a whole letter — the student who did well on assignments and badly in the exam gets an A from the wrong formula. Weight, then divide by the total weight.
Dividing by the total rather than by 100 is what makes the function reusable: 20/30/50, 0.2/0.3/0.5 and 1/3/5 all give 69.5, and a component that gets dropped reweights the rest automatically instead of quietly scoring the missing part as zero. The totalWeight === 0 guard matters because 0 / 0 is NaN, and NaN propagates silently through every later sum and comparison — the assertion pins that down rather than trusting it. The letter ladder must descend: written from the lowest threshold up, every passing mark would return the lowest letter, because the first true branch wins (exercise 91 on guard clauses is the same shape).
Answers JS Basic 120 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.