Emmanuel Akinfulubi

JavaScript: Array, 1–40

Building, searching, reshaping and reducing arrays.

Exercises 1 to 40 of 53

1 Five ways to build an array

easy

Produce [1, 2, 3] five different ways. Then explain why Array(3) and Array.of(3) are not the same thing.

Stuck? Three hints, smallest first
  1. 1. A literal, Array.of, Array.from, spreading an iterable, and Array.from({ length: n }, mapper).
  2. 2. Array(3) reads its single number as a length. Array.of(3) reads it as a value.
  3. 3. An array built from a length has holes in it, not zeros, and not undefined values.

The answer

const literal = [1, 2, 3];
const listed = Array.of(1, 2, 3);
const mapped = Array.from('123', Number);              // a string is iterable
const spread = [...new Set([1, 2, 3, 3])];             // any iterable will do
const counted = Array.from({ length: 3 }, (_, i) => i + 1);

// The single-argument constructor is the trap. One number means a length.
const sized = Array(3);
const single = Array.of(3);

console.log('literal :', literal);
console.log('of      :', listed);
console.log('from    :', mapped);
console.log('spread  :', spread);
console.log('counted :', counted);
console.log('--- Array(3) against Array.of(3) ---');
console.log('Array(3)    :', sized, 'length', sized.length);
console.log('Array.of(3) :', single, 'length', single.length);
console.log('is index 0 present in Array(3)?', 0 in sized, '<- a hole, not a value');

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.

9 checks

test('a literal', literal, [1, 2, 3]);
test('Array.of takes values', listed, [1, 2, 3]);
test('Array.from maps as it copies', mapped, [1, 2, 3]);
test('spreading an iterable', spread, [1, 2, 3]);
test('a length plus a mapper', counted, [1, 2, 3]);
test('Array(3) is three empty slots', sized.length, 3);
test('Array.of(3) is one slot holding 3', single, [3]);
test('nothing is stored at index 0 of Array(3)', 0 in sized, false);
test('though JSON prints the holes as null', JSON.stringify(sized), '[null,null,null]');

Array.of exists solely because of the last three assertions. Array(3) gives you a length-3 array with nothing in it, while Array(3, 4) gives you two elements — one argument means a length, two or more mean values, and that inconsistency is why Array.of was added.

Array.from({ length: n }, mapper) is the workhorse. It is the one build that produces real values in every slot, which matters because holes are skipped by map and forEach later on.

Answers JS Array 1 in the original set.

2 Fill an array with a value

easy

Fill a new array with zeros, then fill only part of an existing one. Then work out why new Array(3).fill([]) is almost never what you want.

Stuck? Three hints, smallest first
  1. 1. fill(value) writes over every slot and returns the same array, mutated.
  2. 2. fill(value, start, end) takes a half-open range, and negative indexes count from the end.
  3. 3. fill writes the same value into every slot. For an object, that is one object shared by every slot.

The answer

const zeros = new Array(4).fill(0);
const partial = [1, 2, 3, 4, 5].fill(0, 1, 3);         // indexes 1 and 2 only
const tail = [1, 2, 3, 4].fill(0, -2);                 // the last two

// One array object, referenced three times.
const shared = new Array(3).fill([]);
shared[0].push('x');

// A factory runs once per slot, so each slot gets its own array.
const separate = Array.from({ length: 3 }, () => []);
separate[0].push('x');

console.log('zeros   :', zeros);
console.log('partial :', partial, '<- fill(0, 1, 3) covers 1 and 2, not 3');
console.log('tail    :', tail);
console.log('--- one array, or three? ---');
console.log('fill([])       :', shared, '<- pushing into row 0 changed all three');
console.log('Array.from(...) :', separate);

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.

9 checks

test('fill covers every slot', zeros, [0, 0, 0, 0]);
test('a range is half-open: start included, end excluded', partial, [1, 0, 0, 4, 5]);
test('negative indexes count from the end', tail, [1, 2, 0, 0]);
test('fill returns the same array, mutated', (() => { const a = [1, 2]; return a.fill(9) === a; })(), true);
test('fill([]) shares one array across every slot', shared, [['x'], ['x'], ['x']]);
test('because all three slots are the same object', shared[0] === shared[2], true);
test('a factory gives each slot its own', separate, [['x'], [], []]);
test('fill with no argument writes real undefined values, not holes', [1, 2, 3].fill().every((v) => v === undefined), true);
test('and those slots do exist', 0 in [1, 2, 3].fill(), true);

fill evaluates its argument once. With a number or a string that is invisible; with an object, an array or a Map it means every slot points at the same thing, and the assertion where pushing into row 0 changes rows 1 and 2 is what that looks like in practice. Use Array.from({ length: n }, () => []) whenever the filled value is a reference.

The last two assertions are the reason fill() with no argument is a useful trick: it converts holes into genuine undefined values, which map will then visit.

Answers JS Array 2 in the original set.

3 A range of numbers

easy

Write range(start, end, step) giving the numbers from start up to but not including end. It has to handle a negative step, an empty range and a step of zero.

Stuck? Three hints, smallest first
  1. 1. Work out the count first, then build that many values with Array.from.
  2. 2. Math.ceil((end - start) / step) is the count, and a negative count means an empty range.
  3. 3. A step of zero would never terminate, so refuse it rather than looping forever.

The answer

function range(start, end, step = 1) {
  if (step === 0) throw new RangeError('step must not be 0');
  const count = Math.max(0, Math.ceil((end - start) / step));
  return Array.from({ length: count }, (_, i) => start + i * step);
}

const refused = (() => {
  try { range(1, 5, 0); return 'no error'; } catch (e) { return e.constructor.name; }
})();

console.log('range(1, 5)      :', range(1, 5));
console.log('range(0, 10, 2)  :', range(0, 10, 2));
console.log('range(5, 1, -1)  :', range(5, 1, -1));
console.log('range(3, 3)      :', range(3, 3), '<- end is excluded, so nothing');
console.log('range(1, -5)     :', range(1, -5), '<- wrong direction for a +1 step');
console.log('step of 0        :', refused);
console.log('the shortcut for range(0, n):', [...Array(5).keys()]);

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.

9 checks

test('the end is excluded', range(1, 5), [1, 2, 3, 4]);
test('a step of 2', range(0, 10, 2), [0, 2, 4, 6, 8]);
test('counting down needs a negative step', range(5, 1, -1), [5, 4, 3, 2]);
test('an empty range', range(3, 3), []);
test('a step bigger than the range still yields the start', range(1, 5, 10), [1]);
test('the wrong direction yields nothing rather than looping', range(1, -5), []);
test('a long range has the length you would expect', range(0, 1000).length, 1000);
test('keys() is the shortcut for 0 to n', [...Array(5).keys()], [0, 1, 2, 3, 4]);
test('a zero step is refused', refused, 'RangeError');

Computing the count up front is what makes the negative step fall out for free: Math.ceil((1 - 5) / -1) is 4, and Math.ceil((-5 - 1) / 1) is negative, which Math.max(0, ...) turns into an empty array. A for loop with i < end would silently produce nothing in one direction and run away in the other.

[...Array(5).keys()] is the shortest form for range(0, n), and it works precisely because keys() is an iterator that does not skip holes.

Answers JS Array 3 in the original set.

4 Turn other things into arrays

easy

Use Array.from on a string, a Set, a Map and an object with a length. Then find the one of those four that spread syntax cannot handle.

Stuck? Three hints, smallest first
  1. 1. Spread needs an iterable. Array.from also accepts an array-like: any object with a numeric length.
  2. 2. A Map iterates as [key, value] pairs.
  3. 3. A string iterates by code point, but .length and split(‘’) count UTF-16 units.

The answer

const chars = Array.from('Lagos');
const unique = Array.from(new Set([1, 1, 2, 3]));
const pairs = Array.from(new Map([['a', 1], ['b', 2]]));
const arrayLike = Array.from({ length: 3, 0: 'a', 1: 'b', 2: 'c' });

// The array-like has a length but no Symbol.iterator, so spread refuses it.
const spreadArrayLike = (() => {
  try { return [...{ length: 2, 0: 'a', 1: 'b' }]; } catch (e) { return e.constructor.name; }
})();

const withEmoji = 'a😀b';                       // three code points, four units

console.log('string     :', chars);
console.log('Set        :', unique);
console.log('Map        :', pairs);
console.log('array-like :', arrayLike);
console.log('spreading the array-like:', spreadArrayLike);
console.log('--- a string is not a row of characters ---');
console.log('Array.from :', Array.from(withEmoji), Array.from(withEmoji).length);
console.log('.length    :', withEmoji.length);
console.log("split('')  :", withEmoji.split('').length, '<- the emoji came apart');

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.

9 checks

test('a string splits into characters', chars, ['L', 'a', 'g', 'o', 's']);
test('a Set drops the duplicate', unique, [1, 2, 3]);
test('a Map arrives as key/value pairs', pairs, [['a', 1], ['b', 2]]);
test('an object with a length works', arrayLike, ['a', 'b', 'c']);
test('but spreading it throws, because it is not iterable', spreadArrayLike, 'TypeError');
test('from() walks a string by code point', Array.from(withEmoji).length, 3);
test('while length counts UTF-16 units', withEmoji.length, 4);
test("and split('') cuts the emoji in half", withEmoji.split('').length, 4);
test('the halves are not the emoji', withEmoji.split('')[1] === '😀', false);

Array.from and spread are close but not interchangeable. Spread requires an iterable; Array.from also accepts an array-like, which is any object with a numeric length. That is what you need for the arguments object in older code, and for a NodeList in browsers that predate its iterator.

The string assertions are the ones to remember. split('') cuts by UTF-16 unit and will happily produce half an emoji; Array.from and spread iterate by code point and do not. Neither respects grapheme clusters, so a flag or a family emoji still comes apart — for that you need Intl.Segmenter.

Answers JS Array 4 in the original set.

5 A grid, without accidentally sharing a row

fair

Build a rows × cols grid of zeros so that writing to one cell changes exactly one cell. Show what goes wrong with new Array(rows).fill(new Array(cols).fill(0)).

Stuck? Three hints, smallest first
  1. 1. The outer array must be built with a factory, so the inner array is created once per row.
  2. 2. Array.from({ length: rows }, () => ...) calls its mapper once per slot.
  3. 3. Write to grid[0][0] and then read grid[1][0]. That single check tells you which version you have.

The answer

const grid = (rows, cols, value = 0) =>
  Array.from({ length: rows }, () => Array.from({ length: cols }, () => value));

const good = grid(2, 2);
good[0][0] = 9;

// One inner array, referenced by every row.
const shared = new Array(2).fill(new Array(2).fill(0));
shared[0][0] = 9;

// Indexes are available if the mapper wants them: row, then column.
const coords = Array.from({ length: 2 }, (_, r) =>
  Array.from({ length: 3 }, (_, c) => r * 3 + c));

console.log('good   :', good, '<- one cell changed');
console.log('shared :', shared, '<- both rows changed');
console.log('same object?', shared[0] === shared[1]);
console.log('coords :', coords);
console.log('reading a cell:', coords[1][2], ' out of range:', coords[9]?.[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.

9 checks

test('the shape is rows of columns', grid(2, 3), [[0, 0, 0], [0, 0, 0]]);
test('writing one cell changes one cell', good, [[9, 0], [0, 0]]);
test('fill() with an array shares it across every row', shared, [[9, 0], [9, 0]]);
test('because the rows are the same object', shared[0] === shared[1], true);
test('while the factory rows are distinct', good[0] === good[1], false);
test('the mapper can compute from the indexes', coords, [[0, 1, 2], [3, 4, 5]]);
test('zero rows is an empty array', grid(0, 3), []);
test('zero columns is rows of nothing', grid(2, 0), [[], []]);
test('a missing row needs guarding, not indexing twice', coords[9]?.[0], undefined);

This is exercise 2's sharing trap in the shape it usually appears: a grid where writing one cell changes a whole column. fill takes one value and writes that same reference everywhere, so new Array(2).fill(new Array(2).fill(0)) builds one row and points both slots at it.

The last assertion is worth keeping. coords[9][0] throws, because coords[9] is undefined; coords[9]?.[0] gives undefined. On a grid read from user input, that one ?. is the difference between a blank cell and a crash.

Answers JS Array 5 in the original set.

6 First, last and nth

easy

Read the first, last and second-to-last element. Then check what xs[-1] actually gives you.

Stuck? Three hints, smallest first
  1. 1. at(-1) counts back from the end; at(0) is the first.
  2. 2. xs[-1] is not an error and not the last element. Ask yourself what -1 becomes when used as a property key.
  3. 3. Reading out of range gives undefined, so a first/last helper never needs a try/catch.

The answer

const stops = ['Yaba', 'Surulere', 'Mushin', 'Oshodi'];

const first = (xs) => xs.at(0);
const last = (xs) => xs.at(-1);

console.log('first     :', first(stops));
console.log('last      :', last(stops));
console.log('at(-2)    :', stops.at(-2));
console.log('stops[-1] :', stops[-1], '<- a property named "-1", which does not exist');
console.log('the old way:', stops[stops.length - 1]);
console.log('out of range:', stops.at(99), ' on an empty array:', [].at(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.

9 checks

test('at(0) is the first', first(stops), 'Yaba');
test('at(-1) is the last', last(stops), 'Oshodi');
test('at(-2) is the one before it', stops.at(-2), 'Mushin');
test('xs[-1] is undefined, not the last element', stops[-1], undefined);
test('because there is no property called "-1"', Object.hasOwn(stops, '-1'), false);
test('the length-based form does work', stops[stops.length - 1], 'Oshodi');
test('reading past the end is undefined, not an error', stops.at(99), undefined);
test('and an empty array has no first element', [].at(0), undefined);
test('at() works on strings too', 'Lagos'.at(-1), 's');

at exists because xs[-1] looks like it should work and does not. Bracket access converts its argument to a string key, so xs[-1] asks for a property literally named "-1" — absent, hence undefined. Assigning to it would add that property without changing length, which is the confusing version of the same mistake.

Everything here returns undefined rather than throwing, which is why a last() helper needs no error handling. It is also why an empty array and an array whose last element is undefined are indistinguishable through at alone.

Answers JS Array 6 in the original set.

7 Read an element safely, with a fallback

easy

Write pick(xs, i, fallback) that returns the element at i, or the fallback when the index is out of range or xs is not an array at all.

Stuck? Three hints, smallest first
  1. 1. Array.isArray is the reliable test; typeof reports ‘object’ for an array.
  2. 2. Compare against undefined to decide whether to fall back — but not against a falsy check, or 0 and ‘’ would trigger it.
  3. 3. ?? falls back on null and undefined only, which is usually the behaviour you want.

The answer

function pick(xs, i, fallback = null) {
  if (!Array.isArray(xs)) return fallback;
  const value = xs.at(i);                    // handles negative indexes for us
  return value === undefined ? fallback : value;
}

const xs = ['a', 'b', 'c'];

console.log('in range      :', pick(xs, 1));
console.log('out of range  :', pick(xs, 9, '?'));
console.log('from the end  :', pick(xs, -1));
console.log('not an array  :', pick(null, 0, 'none'));
console.log('--- a falsy element must not trigger the fallback ---');
console.log('pick([0], 0, 99)  :', pick([0], 0, 99), '<- 0 is a real value');
console.log("pick([''], 0, 'x'):", JSON.stringify(pick([''], 0, 'x')));
console.log('the one-liner     :', xs?.[9] ?? '?');

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.

9 checks

test('an in-range element comes back', pick(xs, 1), 'b');
test('out of range gives the fallback', pick(xs, 9, '?'), '?');
test('a negative index counts from the end', pick(xs, -1), 'c');
test('the default fallback is null', pick([], 0), null);
test('a non-array gives the fallback', pick(null, 0, 'none'), 'none');
test('a stored 0 is not treated as missing', pick([0], 0, 99), 0);
test('nor is an empty string', pick([''], 0, 'x'), '');
test('a stored null survives, because only undefined falls back', pick([null], 0, 'none'), null);
test('a stored undefined is indistinguishable from a miss', pick([undefined], 0, 'none'), 'none');

The line to get right is value === undefined. Writing value || fallback would replace a stored 0, '' or false with the fallback, and the assertions for pick([0], 0, 99) and pick([''], 0, 'x') are there to catch exactly that.

The honest limitation is the last assertion: an array that really contains undefined cannot be told apart from one that is too short. If that distinction matters, return a { found, value } pair or test i in xs instead.

Answers JS Array 7 in the original set.

8 length is a property you can assign to

fair

length is not read-only. Truncate an array by shortening it, grow one by lengthening it, and empty one in place. Then look at what the grown slots contain.

Stuck? Three hints, smallest first
  1. 1. Assigning a smaller length deletes the elements past it, permanently.
  2. 2. xs.length = 0 empties an array in place, which every other reference to it sees.
  3. 3. Assigning a larger length adds holes, not undefined values. index in xs tells them apart.

The answer

const cut = [1, 2, 3, 4, 5];
cut.length = 3;                          // the last two are gone for good

const grown = [1, 2];
grown.length = 4;                        // two holes appended

// Emptying in place, which every reference sees. Reassigning to [] does not.
const original = [1, 2, 3];
const alsoOriginal = original;
original.length = 0;

// A sparse array from a far-off index.
const sparse = [];
sparse[9] = 'x';

const badLength = (() => {
  try { const a = [1]; a.length = -1; return 'accepted'; } catch (e) { return e.constructor.name; }
})();

console.log('truncated :', cut);
console.log('grown     :', grown, 'length', grown.length);
console.log('is index 3 present?', 3 in grown, '<- a hole');
console.log('emptied in place:', original, 'and the other reference:', alsoOriginal);
console.log('sparse    : length', sparse.length, 'but real keys', Object.keys(sparse));
console.log('length = -1:', badLength);

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.

9 checks

test('a shorter length truncates', cut, [1, 2, 3]);
test('the removed elements are unrecoverable', cut[4], undefined);
test('a longer length reports the new size', grown.length, 4);
test('but the added slots are holes, not undefined values', 3 in grown, false);
test('length = 0 empties in place', original, []);
test('which every other reference sees', alsoOriginal.length, 0);
test('length is one past the highest index', sparse.length, 10);
test('even though only one element exists', Object.keys(sparse), ['9']);
test('a negative length is refused', badLength, 'RangeError');

An array's length is defined as one past its highest index, and it is writable. That gives two idioms worth knowing: xs.length = 0 empties an array in place, which matters when other code holds the same reference — xs = [] would leave those references pointing at the old contents — and xs.length = n truncates without building a copy the way slice would.

Growing is the part that surprises. The new slots are holes: length counts them, Object.keys does not, and map will skip straight over them. Exercise 45 pursues that.

Answers JS Array 8 in the original set.

9 indexOf, lastIndexOf and includes

easy

Find a value's first and last position, and answer whether it is present at all. Then try all three against NaN.

Stuck? Three hints, smallest first
  1. 1. indexOf returns a position or -1; includes returns a boolean.
  2. 2. They compare differently. One uses ===, the other treats NaN as equal to itself.
  3. 3. Both take a second argument saying where to start looking.

The answer

const readings = [10, 20, 30, 20, NaN];

console.log('indexOf(20)      :', readings.indexOf(20));
console.log('lastIndexOf(20)  :', readings.lastIndexOf(20));
console.log('indexOf(99)      :', readings.indexOf(99), '<- the miss value is -1');
console.log('includes(30)     :', readings.includes(30));
console.log('--- NaN is where the two part company ---');
console.log('indexOf(NaN)     :', readings.indexOf(NaN), '<- === says NaN !== NaN');
console.log('includes(NaN)    :', readings.includes(NaN), '<- SameValueZero says it is');
console.log('findIndex is the other way to locate a NaN:', readings.findIndex(Number.isNaN));
console.log("indexOf('20')    :", readings.indexOf('20'), '<- no coercion');

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.

9 checks

test('indexOf finds the first match', readings.indexOf(20), 1);
test('lastIndexOf finds the last', readings.lastIndexOf(20), 3);
test('a miss is -1, not undefined', readings.indexOf(99), -1);
test('includes answers with a boolean', readings.includes(30), true);
test('indexOf cannot find NaN', readings.indexOf(NaN), -1);
test('includes can', readings.includes(NaN), true);
test('findIndex can locate it as well', readings.findIndex(Number.isNaN), 4);
test('indexOf does not coerce a string to a number', readings.indexOf('20'), -1);
test('a start index skips the earlier matches', readings.indexOf(20, 2), 3);

The two methods use different equality. indexOf uses strict equality, and NaN === NaN is false, so it can never find one. includes uses SameValueZero, which treats NaN as equal to itself — that is the entire reason it was added, beyond reading better than !== -1.

The two also share a limitation: they compare by identity, so [{ id: 1 }].includes({ id: 1 }) is false. For objects you want some or find with a predicate. Neither coerces, which is why indexOf('20') misses a stored 20.

Answers JS Array 9 in the original set.

10 find, findIndex and filter

easy

From a list of records, get the first match, the position of the first match, and every match. Then show which of the three stops early.

Stuck? Three hints, smallest first
  1. 1. find returns the element, findIndex returns its position, filter returns an array.
  2. 2. Their miss values all differ: undefined, -1, and an empty array.
  3. 3. Count how many times the predicate runs to see which methods stop at the first hit.

The answer

const fares = [
  { route: 'Yaba', price: 450 },
  { route: 'Ikeja', price: 900 },
  { route: 'Ojota', price: 450 },
];

let findCalls = 0;
const firstDear = fares.find((f) => { findCalls++; return f.price > 500; });

let filterCalls = 0;
const allCheap = fares.filter((f) => { filterCalls++; return f.price === 450; });

console.log('find      :', firstDear);
console.log('findIndex :', fares.findIndex((f) => f.price > 500));
console.log('filter    :', allCheap.map((f) => f.route));
console.log('--- the miss values are all different ---');
console.log('find    misses ->', fares.find((f) => f.price > 1e6));
console.log('findIndex misses ->', fares.findIndex((f) => f.price > 1e6));
console.log('filter  misses ->', fares.filter((f) => f.price > 1e6));
console.log('predicate calls: find', findCalls, 'of 3, filter', filterCalls, 'of 3');

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.

9 checks

test('find returns the element itself', firstDear.route, 'Ikeja');
test('findIndex returns its position', fares.findIndex((f) => f.price > 500), 1);
test('filter returns every match', allCheap.map((f) => f.route), ['Yaba', 'Ojota']);
test('find gives undefined when nothing matches', fares.find((f) => f.price > 1e6), undefined);
test('findIndex gives -1', fares.findIndex((f) => f.price > 1e6), -1);
test('filter gives an empty array', fares.filter((f) => f.price > 1e6), []);
test('find stopped at the second element', findCalls, 2);
test('filter had to visit all three', filterCalls, 3);
test('find returns the element, so it is the same object', firstDear === fares[1], true);

Three methods, one predicate, three different answers to "what did you find". Reach for find when you want the thing, findIndex when you need to splice or replace it, and filter when you want all of them.

The call counts are the practical difference: find and findIndex stop at the first hit, filter always walks the whole array. So filter(...)[0] is not just wordier than find(...), it does more work — and on an empty result it gives undefined by indexing rather than by design.

The last assertion is worth noticing: find hands back the original object, not a copy, so mutating it mutates the array's element.

Answers JS Array 10 in the original set.

11 Search from the other end

fair

Find the last element matching a predicate, and its index. Then check what happens if you reach for a reversed copy instead.

Stuck? Three hints, smallest first
  1. 1. findLast and findLastIndex walk from the end towards the start.
  2. 2. lastIndexOf takes a value; findLastIndex takes a predicate.
  3. 3. Reversing a copy finds the right element but the wrong index: you would have to convert it back with length - 1 - i.

The answer

const readings = [3, 8, 2, 9, 4];

let lastCalls = 0;
const lastFour = readings.findLast((n) => { lastCalls++; return n === 4; });

let firstCalls = 0;
const firstFour = readings.find((n) => { firstCalls++; return n === 4; });

// The pre-findLast idiom, and the index bug that comes with it.
const reversed = [...readings].reverse();
const reversedIndex = reversed.findIndex((n) => n > 5);

console.log('findLast(n > 5)      :', readings.findLast((n) => n > 5));
console.log('findLastIndex(n > 5) :', readings.findLastIndex((n) => n > 5));
console.log('find(n > 5)          :', readings.find((n) => n > 5), '<- the first one instead');
console.log('--- the reversed-copy workaround ---');
console.log('index in the reversed copy:', reversedIndex);
console.log('converted back            :', readings.length - 1 - reversedIndex);
console.log('predicate calls: findLast', lastCalls, ' find', firstCalls);
console.log('both found the same value:', lastFour === firstFour);

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.

9 checks

test('findLast returns the last match', readings.findLast((n) => n > 5), 9);
test('findLastIndex returns its position', readings.findLastIndex((n) => n > 5), 3);
test('find returns the first match instead', readings.find((n) => n > 5), 8);
test('a findLast miss is undefined', readings.findLast((n) => n > 99), undefined);
test('a findLastIndex miss is -1', readings.findLastIndex((n) => n > 99), -1);
test('a reversed copy reports an index into the copy, not the original', reversedIndex, 1);
test('which has to be converted back', readings.length - 1 - reversedIndex, 3);
test('findLast reached the last element in one call', lastCalls, 1);
test('while find had to walk all five', firstCalls, 5);

Before findLast existed the idiom was [...xs].reverse().find(p), and the assertions show why it was a nuisance: the value is right but any index you get out of it belongs to the copy, so you must subtract it from length - 1. Forgetting that conversion is a genuinely common off-by-one.

The call counts show the other reason to prefer it. Searching from the correct end is not a style choice — on a long log where the interesting entry is recent, findLast stops immediately and find reads the whole thing.

Answers JS Array 11 in the original set.

12 some and every

easy

Answer "is any of them over 23?" and "are all of them over 18?". Then run both against an empty array and explain the results.

Stuck? Three hints, smallest first
  1. 1. some stops at the first true; every stops at the first false.
  2. 2. On an empty array they do not agree. One is true, the other false.
  3. 3. "All of nothing satisfies the rule" is vacuously true — there is no counter-example to find.

The answer

const temps = [21, 24, 19];

let someCalls = 0;
const anyHot = temps.some((t) => { someCalls++; return t > 23; });

let everyCalls = 0;
const allWarm = temps.every((t) => { everyCalls++; return t > 22; });

console.log('some(t > 23) :', anyHot, ' after', someCalls, 'of 3 calls');
console.log('every(t > 22):', allWarm, ' after', everyCalls, 'of 3 calls');
console.log('every(t > 18):', temps.every((t) => t > 18));
console.log('--- the empty array ---');
console.log('[].some(...)  :', [].some(() => true));
console.log('[].every(...) :', [].every(() => false), '<- true, with a predicate that is never true');
console.log('every is "no counter-example exists", and an empty list has none');

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.

9 checks

test('some finds a match', anyHot, true);
test('some stopped at the second element', someCalls, 2);
test('every fails on the first element that does not qualify', allWarm, false);
test('and it stopped there', everyCalls, 1);
test('every is true when they all qualify', temps.every((t) => t > 18), true);
test('some on an empty array is false', [].some(() => true), false);
test('every on an empty array is true, whatever the predicate', [].every(() => false), true);
test('some is also the readable way to ask about objects', [{ id: 1 }].some((r) => r.id === 1), true);
test('but on a real array, not-some is every-with-the-test-flipped', !temps.some((t) => t > 30) === temps.every((t) => t <= 30), true);

[].every(() => false) being true looks wrong and is not. every asks whether any element fails; an empty array has no elements, so nothing fails, so the answer is true. Mathematicians call it vacuous truth, and it bites when validating: fields.every(isValid) passes an empty form. Check for emptiness separately.

Both short-circuit, which makes them the right tools for an expensive predicate — and the wrong tools if the predicate has side effects you were relying on, since it may not run for every element.

Answers JS Array 12 in the original set.

13 map, and the parseInt trap

easy

Double every number, then build a label from each element and its index. Then explain why ['1', '7', '11'].map(parseInt) does not give [1, 7, 11].

Stuck? Three hints, smallest first
  1. 1. The callback is passed three arguments: value, index and the whole array.
  2. 2. parseInt takes two: the text and a radix. Work out what the index is doing there.
  3. 3. map(Number) is safe because Number only looks at its first argument.

The answer

const doubled = [1, 2, 3].map((n) => n * 2);
const labelled = ['a', 'b'].map((v, i) => `${i}:${v}`);

// parseInt(value, index): the index arrives as the radix.
const broken = ['1', '7', '11'].map(parseInt);
const fixed = ['1', '7', '11'].map(Number);

console.log('doubled :', doubled);
console.log('labelled:', labelled);
console.log('--- map(parseInt) hands the index over as the radix ---');
console.log('map(parseInt) :', broken);
console.log("parseInt('1', 0)  ->", parseInt('1', 0), ' radix 0 means "guess", so decimal');
console.log("parseInt('7', 1)  ->", parseInt('7', 1), ' radix 1 does not exist');
console.log("parseInt('11', 2) ->", parseInt('11', 2), ' binary 11 is 3');
console.log('map(Number)   :', fixed);

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.

9 checks

test('map applies the function to every element', doubled, [2, 4, 6]);
test('the callback receives the index', labelled, ['0:a', '1:b']);
test('map returns a new array and leaves the original alone', (() => { const a = [1, 2]; a.map((n) => n * 9); return a; })(), [1, 2]);
test('the result is always the same length', [1, 2, 3].map(() => 0).length, 3);
test('map(Number) converts cleanly', fixed, [1, 7, 11]);
test('map(parseInt) does not', broken.map(String), ['1', 'NaN', '3']);
test('because radix 1 is not a number system', Number.isNaN(parseInt('7', 1)), true);
test('and radix 2 reads "11" as binary', parseInt('11', 2), 3);
test('wrapping the call in your own arrow fixes it', ['1', '7', '11'].map((s) => parseInt(s, 10)), [1, 7, 11]);

map(parseInt) is the standard example of passing a function whose arity you have not checked. map calls back with three arguments, parseInt accepts two, so the index lands in the radix slot: index 0 means "work it out" (decimal), index 1 is not a valid radix at all, and index 2 makes '11' binary. Hence [1, NaN, 3].

map(Number) is safe because Number ignores extra arguments. The general rule: pass a named function to map only when you know it takes exactly one argument, otherwise wrap it in an arrow.

The assertion converts through String deliberately. JSON.stringify turns NaN into null, so comparing the raw arrays would have passed even if the middle value had been null or undefined.

Answers JS Array 13 in the original set.

14 Drop the empty values

easy

Remove the falsy values from a mixed list with filter(Boolean). Then decide whether that is really what you wanted, using 0 and '' as your test cases.

Stuck? Three hints, smallest first
  1. 1. filter(Boolean) works because Boolean is a one-argument function that returns a boolean.
  2. 2. The falsy values are false, 0, -0, 0n, ‘’, null, undefined and NaN. Nothing else.
  3. 3. To remove only the absent ones, test v != null, which is loose on purpose.

The answer

const mixed = [0, 1, '', null, 'a', undefined, NaN, 2, false];

const truthy = mixed.filter(Boolean);
const present = mixed.filter((v) => v != null);      // loose !=, so null AND undefined

console.log('input   :', mixed);
console.log('truthy  :', truthy);
console.log('present :', present, '<- keeps 0, "" and NaN');
console.log('--- things that look empty but are truthy ---');
console.log("['0']      ->", ['0'].filter(Boolean), 'a non-empty string');
console.log('[[]]       ->', [[]].filter(Boolean), 'an empty array is an object');
console.log("['false']  ->", ['false'].filter(Boolean), 'text, not a boolean');
console.log('[{}]       ->', [{}].filter(Boolean).length, 'an empty object too');

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.

9 checks

test('filter(Boolean) keeps only the truthy values', truthy, [1, 'a', 2]);
test('0 counts as falsy, so it goes', truthy.includes(0), false);
test('but the string "0" is truthy', ['0'].filter(Boolean), ['0']);
test('an empty array is truthy, being an object', [[]].filter(Boolean).length, 1);
test('and so is the text "false"', ['false'].filter(Boolean), ['false']);
test('filter never mutates its input', mixed.length, 9);
test('v != null keeps the falsy-but-real values', present.length, 7);
test('including the zero', present[0], 0);
test('a single = would have been an assignment, and != null catches both', [null, undefined].filter((v) => v != null), []);

filter(Boolean) is idiomatic and slightly too eager. It removes 0 and '', which are usually real data — a price of zero, a blank middle name. When you mean "drop the missing ones", say v != null: the loose != is deliberate there, because null != null is false and undefined != null is also false, so one comparison covers both.

The truthiness of [] and {} catches people out regularly. Every object is truthy, however empty. if (arr) therefore tells you nothing about whether an array has contents; if (arr.length) does.

Answers JS Array 14 in the original set.

15 slice against splice

fair

Copy the middle of an array with slice, then remove the middle with splice. Pay attention to what each one returns.

Stuck? Three hints, smallest first
  1. 1. slice(start, end) copies a half-open range and never changes the original.
  2. 2. splice(start, count, ...insert) mutates, and returns the elements it took out.
  3. 3. So const shorter = xs.splice(1, 2) almost certainly does not do what the name says.

The answer

const source = [1, 2, 3, 4, 5];
const sliced = source.slice(1, 3);

const spliceTarget = [1, 2, 3, 4, 5];
const removed = spliceTarget.splice(1, 2);          // returns what it removed

const inserted = [1, 2, 3];
const nothingRemoved = inserted.splice(1, 0, 'x', 'y');   // insert only

console.log('slice(1, 3)      :', sliced, ' source still:', source);
console.log('splice(1, 2)     :', removed, '<- the REMOVED elements');
console.log('the array after  :', spliceTarget);
console.log("splice(1, 0, 'x', 'y'):", nothingRemoved, '<- nothing removed');
console.log('the array after  :', inserted);
console.log('slice(-2)        :', source.slice(-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.

9 checks

test('slice copies a half-open range', sliced, [2, 3]);
test('and leaves the source untouched', source, [1, 2, 3, 4, 5]);
test('slice() with no arguments copies the whole thing', source.slice(), [1, 2, 3, 4, 5]);
test('but the copy is a different array', source.slice() === source, false);
test('a negative start counts back from the end', source.slice(-2), [4, 5]);
test('splice returns the elements it removed', removed, [2, 3]);
test('and shortens the array it was called on', spliceTarget, [1, 4, 5]);
test('a count of 0 removes nothing, so it returns an empty array', nothingRemoved, []);
test('while the insertion happened in place', inserted, [1, 'x', 'y', 2, 3]);

Two methods one letter apart, doing opposite things. slice is a copy and its return value is the answer. splice is an edit and its return value is the offcut — the array you wanted is the one you called it on. Writing const trimmed = xs.splice(0, 3) gives you the first three elements while quietly destroying xs.

splice is also the only built-in that removes and inserts at once, which is genuinely useful: xs.splice(i, 1, replacement) swaps one element in place. When you want that without mutating, exercise 16 has the copying versions.

Answers JS Array 15 in the original set.

16 Insert, remove and replace without mutating

easy

Write insertAt, removeAt and replaceAt that all return a new array and leave the input alone.

Stuck? Three hints, smallest first
  1. 1. Two slices and a spread: everything before the index, the new values, everything after.
  2. 2. slice clamps out-of-range indexes rather than throwing, which handles the edges for free.
  3. 3. replaceAt is just a map that changes one position.

The answer

const insertAt = (xs, i, ...values) => [...xs.slice(0, i), ...values, ...xs.slice(i)];
const removeAt = (xs, i, count = 1) => [...xs.slice(0, i), ...xs.slice(i + count)];
const replaceAt = (xs, i, value) => xs.map((v, k) => (k === i ? value : v));

const stops = ['Yaba', 'Mushin', 'Oshodi'];

// The built-in copying replace, where the engine has it.
const hasWith = typeof Array.prototype.with === 'function';
const builtIn = hasWith ? [1, 2, 3].with(1, 'x') : [1, 'x', 3];

console.log('insert  :', insertAt(stops, 1, 'Surulere'));
console.log('remove  :', removeAt(stops, 1));
console.log('replace :', replaceAt(stops, 0, 'Ebute Metta'));
console.log('original:', stops, '<- untouched by all three');
console.log('insert past the end appends:', insertAt(stops, 99, 'Ikeja'));
console.log('with() available here?', hasWith, '->', builtIn);

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.

9 checks

test('insert in the middle', insertAt([1, 2, 3], 1, 'x'), [1, 'x', 2, 3]);
test('insert several at once, at the start', insertAt([3, 4], 0, 1, 2), [1, 2, 3, 4]);
test('an index past the end appends', insertAt([1, 2], 99, 'x'), [1, 2, 'x']);
test('remove one element', removeAt([1, 2, 3], 1), [1, 3]);
test('remove two', removeAt([1, 2, 3, 4], 1, 2), [1, 4]);
test('removing an index that is not there changes nothing', removeAt([1, 2], 99), [1, 2]);
test('replace one element', replaceAt([1, 2, 3], 1, 'x'), [1, 'x', 3]);
test('the input array is never modified', stops, ['Yaba', 'Mushin', 'Oshodi']);
test('with() does the same job where the engine has it', builtIn, [1, 'x', 3]);

These three are worth memorising because they are what you need in any code that treats state as immutable — a reducer, a signal, anything that compares by reference to decide whether to re-render. splice would be shorter and would break that comparison, since the array object stays the same.

slice doing the clamping is what keeps them short: slice(0, 99) on a two-element array gives both elements rather than complaining, so insertAt(xs, 99, v) appends and removeAt(xs, 99) is a no-op with no special cases written.

with(i, value) is the built-in copying replace. It is recent, so the assertion feature-tests it rather than assuming — the fallback keeps the check honest on an older engine.

Answers JS Array 16 in the original set.

17 Joining arrays: concat, spread and push

easy

Combine two arrays three ways. Then find the difference between push(other) and push(...other).

Stuck? Three hints, smallest first
  1. 1. concat and spread both return a new array; push changes one in place.
  2. 2. push returns the new length, not the array, so it does not chain.
  3. 3. concat unwraps array arguments one level deep, but not two.

The answer

const a = [1, 2];
const b = [3, 4];

const joined = a.concat(b);
const spread = [...a, ...b];

const pushedSpread = [1];
pushedSpread.push(...b);            // appends 3 and 4

const pushedWhole = [1];
const newLength = pushedWhole.push(b);   // appends ONE element: the array

console.log('concat      :', joined);
console.log('spread      :', spread);
console.log('inputs after:', a, b, '<- both untouched');
console.log('push(...b)  :', pushedSpread);
console.log('push(b)     :', pushedWhole, '<- one nested element');
console.log('push returned', newLength, 'which is a length, not an array');
console.log('concat with loose values:', a.concat(3, [4, 5]));
console.log('one level only         :', [1].concat([[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.

9 checks

test('concat joins into a new array', joined, [1, 2, 3, 4]);
test('spread gives the same result', spread, [1, 2, 3, 4]);
test('neither input is modified', [a, b], [[1, 2], [3, 4]]);
test('concat takes loose values as well as arrays', a.concat(3, [4, 5]), [1, 2, 3, 4, 5]);
test('but it unwraps only one level', [1].concat([[2]]), [1, [2]]);
test('push(...b) appends the elements', pushedSpread, [1, 3, 4]);
test('push(b) appends the array as a single element', pushedWhole, [1, [3, 4]]);
test('push returns the new length', newLength, 2);
test('concat() with no arguments is a shallow copy', a.concat().length === a.length && a.concat() !== a, true);

push returning a length is the detail that trips chaining: const xs = [1].push(2) gives you 2, not [1, 2]. Use concat or spread when you want an expression, push when you want a statement.

push(...other) reads well and has a real limit: the spread becomes one argument per element, and a few hundred thousand of them will exceed the engine's argument limit and throw. For very large arrays, concat or a loop is the safe form.

Answers JS Array 17 in the original set.

18 flat and flatMap

fair

Flatten [1, [2, [3, [4]]]] by one level, by two, and completely. Then use flatMap to map and filter in one pass.

Stuck? Three hints, smallest first
  1. 1. flat() defaults to a depth of 1. flat(Infinity) goes all the way down.
  2. 2. flatMap(f) is map(f).flat() — one level of flattening, no more.
  3. 3. Returning [] from a flatMap callback removes that element; returning two values duplicates it.

The answer

const nested = [1, [2, [3, [4]]]];

const words = ['the lagos bus', 'is late'];
const allWords = words.flatMap((line) => line.split(' '));

// Returning [] drops an element; returning several expands it.
const oddsOnly = [1, 2, 3, 4].flatMap((n) => (n % 2 ? [n] : []));
const doubledUp = [1, 2].flatMap((n) => [n, n * 10]);

console.log('flat()        :', nested.flat());
console.log('flat(2)       :', nested.flat(2));
console.log('flat(Infinity):', nested.flat(Infinity));
console.log('holes go too  :', [1, , 3].flat(), '<- the middle slot was a hole');
console.log('words         :', allWords);
console.log('odds only     :', oddsOnly);
console.log('doubled up    :', doubledUp);

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.

9 checks

test('flat() removes one level', nested.flat(), [1, 2, [3, [4]]]);
test('a depth argument removes more', nested.flat(2), [1, 2, 3, [4]]);
test('Infinity flattens completely', nested.flat(Infinity), [1, 2, 3, 4]);
test('flat also drops holes', [1, , 3].flat(), [1, 3]);
test('flatMap maps then flattens one level', allWords, ['the', 'lagos', 'bus', 'is', 'late']);
test('returning [] removes the element', oddsOnly, [1, 3]);
test('returning two values expands it', doubledUp, [1, 10, 2, 20]);
test('flatMap flattens one level only', [1].flatMap((n) => [[n]]), [[1]]);
test('a non-array return is kept as it is', [1, 2].flatMap((n) => n), [1, 2]);

Also Basic 35, which flattens by hand with recursion. The angle here is the built-in and its depth argument: flat() is one level, and the recursive hand-written version is only needed when you want to stop at a computed depth or flatten something that is not an array.

flatMap is the one to internalise. Because the callback returns an array, it can produce zero, one or many elements per input — which makes it a filter and a map at the same time, in a single pass. map(...).filter(Boolean) is two passes and mishandles a legitimate falsy result.

Answers JS Array 18 in the original set.

19 reverse changes the array it is called on

easy

Reverse an array, then reverse one without disturbing the original. Then explain why const backwards = xs.reverse() is usually a bug.

Stuck? Three hints, smallest first
  1. 1. reverse mutates and returns the same array, not a copy.
  2. 2. So the "copy" and the original are one object, and both look reversed.
  3. 3. [...xs].reverse(), xs.slice().reverse() or xs.toReversed() all leave the input alone.

The answer

const original = [1, 2, 3];
const backwards = original.reverse();      // same array, now reversed

const safe = [1, 2, 3];
const safeCopy = [...safe].reverse();

const hasToReversed = typeof Array.prototype.toReversed === 'function';
const builtIn = hasToReversed ? [1, 2, 3].toReversed() : [3, 2, 1];

console.log('backwards :', backwards);
console.log('original  :', original, '<- reversed as well, because it is the same array');
console.log('same object?', backwards === original);
console.log('--- with a copy ---');
console.log('safeCopy  :', safeCopy);
console.log('safe      :', safe, '<- untouched');
console.log('toReversed available?', hasToReversed, '->', builtIn);
console.log('reversing text needs an array first:', [...'Lagos'].reverse().join(''));

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.

9 checks

test('reverse reverses', [1, 2, 3].reverse(), [3, 2, 1]);
test('and returns the very same array', (() => { const a = [1, 2]; return a.reverse() === a; })(), true);
test('so the original is reversed too', original, [3, 2, 1]);
test('a spread copy protects it', safe, [1, 2, 3]);
test('while still giving the reversed order', safeCopy, [3, 2, 1]);
test('slice() works the same way', (() => { const a = [1, 2, 3]; a.slice().reverse(); return a; })(), [1, 2, 3]);
test('toReversed is the built-in copy', builtIn, [3, 2, 1]);
test('reversing twice restores the order', [1, 2, 3].reverse().reverse(), [1, 2, 3]);
test('a string has to become an array first', [...'Lagos'].reverse().join(''), 'sogaL');

The bug is subtle because the line reads correctly. const backwards = xs.reverse() does produce a reversed array — it just also reverses xs, so any later code that expected the original order gets the new one. In a render function that runs twice, the array flips back and forth.

sort has the same shape and exercise 25 covers it. The rule of thumb: if a method's name is a verb in the imperative (sort, reverse, fill, splice), assume it mutates; the copying versions are named with a to prefix or a past participle (toSorted, toReversed, sorted in other languages).

Answers JS Array 19 in the original set.

20 join and toString

fair

Join a list with a separator. Then join one containing null, undefined, a hole and a nested array, and account for every character of the result.

Stuck? Three hints, smallest first
  1. 1. join() with no argument uses a comma, which is also what toString does.
  2. 2. null and undefined become empty strings rather than the text "null".
  3. 3. A nested array is joined too, and it always uses a comma regardless of your separator.

The answer

const stops = ['Yaba', 'Mushin', 'Oshodi'];
const awkward = [1, null, 2, undefined, 3];
const nested = [1, [2, 3], 4];

console.log("join('-')      :", stops.join('-'));
console.log('join()         :', stops.join());
console.log('toString()     :', stops.toString(), '<- the same as join()');
console.log('--- values that vanish ---');
console.log("null/undefined :", awkward.join('-'), '<- two empty gaps');
console.log('a hole too     :', [1, , 3].join('-'));
console.log('--- nesting ---');
console.log('nested         :', nested.join('-'), '<- the inner array used a comma');
console.log('splitting back :', nested.join('-').split('-'));

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.

9 checks

test('join uses the separator you give it', stops.join('-'), 'Yaba-Mushin-Oshodi');
test('with no argument it uses a comma', stops.join(), 'Yaba,Mushin,Oshodi');
test('toString is join() by another name', stops.toString(), stops.join());
test('null and undefined become empty strings', awkward.join('-'), '1--2--3');
test('and so does a hole', [1, , 3].join('-'), '1--3');
test('a nested array is joined with a comma, not your separator', nested.join('-'), '1-2,3-4');
test('an empty array joins to an empty string', [].join('-'), '');
test('a single element needs no separator', ['a'].join('-'), 'a');
test('so join is not reversible when the data contains the separator', nested.join('-').split('-'), ['1', '2,3', '4']);

join converts each element with String() except null and undefined, which become ''. That is why a missing value shows up as a doubled separator instead of the word "null" — and why ['a', null, 'b'].join(', ') renders as "a, , b" in a user interface. Filter first if that matters.

The nesting rule catches people building CSV. An inner array is stringified with commas whatever separator you asked for, so one nested value silently corrupts a row. And since join does not quote or escape, split cannot undo it once a value contains the separator — the last assertion shows the damage.

Answers JS Array 20 in the original set.

21 Chunk a list into rows

fair

Split [1, 2, 3, 4, 5] into rows of two. Decide what to do with the remainder, and what to do when the size is zero.

Stuck? Three hints, smallest first
  1. 1. Step the index forward by size rather than by one, and slice each window.
  2. 2. slice clamps, so the last row comes out short with no special case.
  3. 3. A size of zero would step nowhere and loop forever. Refuse it.

The answer

function chunk(xs, size) {
  if (!Number.isInteger(size) || size < 1) throw new RangeError('size must be a positive integer');
  const rows = [];
  for (let i = 0; i < xs.length; i += size) rows.push(xs.slice(i, i + size));
  return rows;
}

// The same thing without a loop: work out the row count, then build it.
const chunkFrom = (xs, size) =>
  Array.from({ length: Math.ceil(xs.length / size) }, (_, i) => xs.slice(i * size, i * size + size));

const record = { id: 1 };
const rowsOfObjects = chunk([record], 1);

const refused = (() => {
  try { chunk([1, 2], 0); return 'no error'; } catch (e) { return e.constructor.name; }
})();

console.log('exact fit    :', chunk([1, 2, 3, 4], 2));
console.log('a remainder  :', chunk([1, 2, 3, 4, 5], 2), '<- short last row');
console.log('size > length:', chunk([1, 2], 9));
console.log('empty        :', chunk([], 3));
console.log('size of 0    :', refused);
console.log('flat() undoes it:', chunk([1, 2, 3, 4, 5], 2).flat());

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.

9 checks

test('an exact fit', chunk([1, 2, 3, 4], 2), [[1, 2], [3, 4]]);
test('a remainder becomes a shorter final row', chunk([1, 2, 3, 4, 5], 2), [[1, 2], [3, 4], [5]]);
test('a size larger than the list gives one row', chunk([1, 2], 9), [[1, 2]]);
test('an empty list gives no rows at all', chunk([], 3), []);
test('the loop-free version agrees', chunkFrom([1, 2, 3, 4, 5], 2), chunk([1, 2, 3, 4, 5], 2));
test('flat() puts it back together', chunk([1, 2, 3, 4, 5], 2).flat(), [1, 2, 3, 4, 5]);
test('the rows are new arrays, so the source is untouched', chunk([1, 2], 1).length, 2);
test('but the elements are shared, not cloned', rowsOfObjects[0][0] === record, true);
test('a size of zero is refused instead of hanging', refused, 'RangeError');

Also Basic 44. The angle here is the guard. chunk(xs, 0) written the obvious way never advances i, so it allocates rows until the tab dies — the sort of bug that only shows up when a page size arrives from a query string. Number.isInteger rejects 0, -1, 1.5 and NaN in one condition.

The rows are fresh arrays but the elements inside them are the same objects, which the object assertion pins down. That is what "shallow" means, and it is what you want here: chunking a thousand records should not clone a thousand records.

Answers JS Array 21 in the original set.

22 Zip two lists together

fair

Pair up two lists by position, then take a list of pairs apart again. Decide what happens when the lists are different lengths, and write both answers.

Stuck? Three hints, smallest first
  1. 1. Build the result from the length you decide on, then index both inputs.
  2. 2. Math.min truncates to the shorter list; Math.max plus a pad value keeps everything.
  3. 3. Object.fromEntries turns a list of pairs straight into an object.

The answer

const zip = (as, bs) =>
  Array.from({ length: Math.min(as.length, bs.length) }, (_, i) => [as[i], bs[i]]);

// The other reasonable answer: keep every element and pad the gaps.
const zipPad = (as, bs, pad = null) =>
  Array.from({ length: Math.max(as.length, bs.length) }, (_, i) => [
    i < as.length ? as[i] : pad,
    i < bs.length ? bs[i] : pad,
  ]);

const unzip = (pairs) => [pairs.map((p) => p[0]), pairs.map((p) => p[1])];

const keys = ['route', 'price'];
const values = ['Yaba', 450];

console.log('zip        :', zip([1, 2], ['a', 'b']));
console.log('uneven     :', zip([1, 2, 3], ['a']), '<- truncated to the shorter one');
console.log('padded     :', zipPad([1, 2, 3], ['a']));
console.log('unzip      :', unzip([[1, 'a'], [2, 'b']]));
console.log('as an object:', Object.fromEntries(zip(keys, values)));

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.

9 checks

test('zip pairs by position', zip([1, 2], ['a', 'b']), [[1, 'a'], [2, 'b']]);
test('the shorter list decides the length', zip([1, 2, 3], ['a']), [[1, 'a']]);
test('zipping with an empty list gives nothing', zip([1, 2], []), []);
test('the padding version keeps every element', zipPad([1, 2, 3], ['a']).length, 3);
test('and marks the gaps', zipPad([1, 2, 3], ['a'])[1], [2, null]);
test('unzip is the inverse of zip', unzip([[1, 'a'], [2, 'b']]), [[1, 2], ['a', 'b']]);
test('so zip then unzip round-trips', unzip(zip([1, 2], ['a', 'b'])), [[1, 2], ['a', 'b']]);
test('pairs convert straight into an object', Object.fromEntries(zip(keys, values)), { route: 'Yaba', price: 450 });
test('and a zipped pair is an array, not an object', Array.isArray(zip([1], ['a'])[0]), true);

There is no built-in zip, and the reason is the disagreement this exercise makes explicit: with lists of different lengths, truncating and padding are both defensible, so the language leaves the choice to you. Say which one you picked in the function's name.

zip plus Object.fromEntries is the practical payoff — a header row and a data row become a record in one line, which is most of what reading a CSV amounts to.

Answers JS Array 22 in the original set.

23 Transpose a matrix

fair

Turn [[1, 2, 3], [4, 5, 6]] into [[1, 4], [2, 5], [3, 6]]. Handle an empty matrix, and say what happens to a ragged one.

Stuck? Three hints, smallest first
  1. 1. The result has one row per column of the input, so map over the first row to get the column indexes.
  2. 2. rows[0].map((_, c) => rows.map((row) => row[c])) is the whole thing.
  3. 3. An empty input has no rows[0], so guard it before indexing.

The answer

const transpose = (rows) =>
  rows.length === 0 ? [] : rows[0].map((_, c) => rows.map((row) => row[c]));

const grid = [[1, 2, 3], [4, 5, 6]];
const ragged = transpose([[1, 2], [3]]);

console.log('input      :', grid, `${grid.length}x${grid[0].length}`);
console.log('transposed :', transpose(grid), `${transpose(grid).length}x${transpose(grid)[0].length}`);
console.log('twice      :', transpose(transpose(grid)), '<- back to the start');
console.log('one row    :', transpose([[1, 2]]));
console.log('empty      :', transpose([]));
console.log('ragged     :', ragged, '<- the missing cell reads as undefined');

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.

9 checks

test('rows become columns', transpose(grid), [[1, 4], [2, 5], [3, 6]]);
test('the shape flips', [transpose(grid).length, transpose(grid)[0].length], [3, 2]);
test('transposing twice restores the original', transpose(transpose(grid)), grid);
test('a single row becomes a single column', transpose([[1, 2]]), [[1], [2]]);
test('an empty matrix stays empty', transpose([]), []);
test('a ragged row leaves an undefined cell', ragged[1][1] === undefined, true);
test('which JSON would have shown as null, hiding it', JSON.stringify(ragged), '[[1,3],[2,null]]');
test('the input is not modified', grid, [[1, 2, 3], [4, 5, 6]]);
test('the cells are the same values, in new rows', transpose(grid)[0][1], 4);

The trick is to drive the outer map from rows[0], because the number of columns in the input is the number of rows in the output. The callback ignores the value and uses only the index, which is why the parameter is written _.

Two assertions cover the same ragged matrix on purpose. Reading the cell directly shows it is undefined; serialising the whole thing shows null. That is JSON.stringify having no way to represent undefined inside an array — worth knowing before you use it to compare results, here or anywhere.

Answers JS Array 23 in the original set.

24 Rotate a list by k places

fair

Rotate [1, 2, 3, 4, 5] left by 2 to get [3, 4, 5, 1, 2]. It must cope with k larger than the list and with a negative k.

Stuck? Three hints, smallest first
  1. 1. A rotation is two slices swapped round, which is one line once you know the offset.
  2. 2. k % n handles a large k but not a negative one: the remainder keeps the sign.
  3. 3. ((k % n) + n) % n is the standard fix, and it is worth writing once.

The answer

function rotate(xs, k) {
  const n = xs.length;
  if (n === 0) return [];
  const shift = ((k % n) + n) % n;         // normalise: large k, negative k, both
  return [...xs.slice(shift), ...xs.slice(0, shift)];
}

const xs = [1, 2, 3, 4, 5];

console.log('left by 2  :', rotate(xs, 2));
console.log('left by 0  :', rotate(xs, 0));
console.log('right by 1 :', rotate(xs, -1), '<- a negative k goes the other way');
console.log('by 7 on 5  :', rotate(xs, 7), 'same as by 2');
console.log('--- why the double modulo ---');
console.log('-1 % 5           =', -1 % 5, '<- still negative, so slice would read from the end');
console.log('((-1 % 5) + 5) % 5 =', ((-1 % 5) + 5) % 5);
console.log('input afterwards :', xs);

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.

9 checks

test('rotating left by 2', rotate(xs, 2), [3, 4, 5, 1, 2]);
test('rotating by 0 changes nothing', rotate(xs, 0), [1, 2, 3, 4, 5]);
test('a negative k rotates right', rotate(xs, -1), [5, 1, 2, 3, 4]);
test('a k larger than the length wraps round', rotate(xs, 7), rotate(xs, 2));
test('rotating by the length is a no-op', rotate(xs, 5), [1, 2, 3, 4, 5]);
test('the plain remainder keeps the minus sign', -1 % 5, -1);
test('which is what the double modulo repairs', ((-1 % 5) + 5) % 5, 4);
test('an empty list is safe', rotate([], 3), []);
test('the input is never modified', xs, [1, 2, 3, 4, 5]);

Also Basic 46. The angle here is the offset arithmetic, because that is where rotations actually go wrong. JavaScript's % is a remainder, not a modulo: it keeps the sign of the left operand, so -1 % 5 is -1. Feed that to slice and you get a negative index, which silently counts from the other end and produces a plausible-looking wrong answer.

((k % n) + n) % n is the idiom for "wrap into 0..n-1". It is worth recognising on sight — it shows up in ring buffers, in circular queues, and any time an index cycles.

Answers JS Array 24 in the original set.

25 sort compares as text by default

easy

Sort [10, 9, 1, 100, 20] with sort() and explain the result. Then sort it properly. Also check what sort did to the original array.

Stuck? Three hints, smallest first
  1. 1. With no comparator, sort converts every element to a string and compares those.
  2. 2. So ‘100’ comes before ‘20’, for the same reason ‘ab’ comes before ‘b’.
  3. 3. sort also mutates. It returns the same array, reordered.

The answer

const nums = [10, 9, 1, 100, 20];

const asText = [...nums].sort();                   // the default: string order
const asNumbers = [...nums].sort((a, b) => a - b);

// What sort does to the array it is called on.
const victim = [3, 1, 2];
const returned = victim.sort((a, b) => a - b);

console.log('input        :', nums);
console.log('sort()       :', asText, '<- string order');
console.log('sort((a,b)=>a-b):', asNumbers);
console.log('why: as strings,', ['10', '9', '1', '100', '20'].sort());
console.log('--- and it mutates ---');
console.log('victim after sorting:', victim);
console.log('the return value is the same array:', returned === victim);
console.log('the classic:', [10, 9, 1].sort(), '<- 1, 10, 9');

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.

9 checks

test('the default sort compares strings', asText, [1, 10, 100, 20, 9]);
test('so 100 lands before 20', asText.indexOf(100) < asText.indexOf(20), true);
test('the classic three-element surprise', [10, 9, 1].sort(), [1, 10, 9]);
test('a numeric comparator fixes it', asNumbers, [1, 9, 10, 20, 100]);
test('sort returns the array it was called on, not a copy', returned === victim, true);
test('so the original is reordered', victim, [1, 2, 3]);
test('text sorts as you would expect', ['b', 'a', 'c'].sort(), ['a', 'b', 'c']);
test('though capitals come first, being lower code points', ['b', 'A'].sort(), ['A', 'b']);
test('undefined is moved to the end without being compared', [3, undefined, 1].sort((a, b) => a - b).map(String), ['1', '3', 'undefined']);

Two traps in one method, and both are in the assertions. The default comparator stringifies, so [10, 9, 1] sorts to [1, 10, 9] — correct string order, useless number order. And sort mutates: the array you called it on is reordered, and the return value is that same array, so const sorted = xs.sort() gives you two names for one reordered array.

The undefined assertion is a third, smaller one. undefined values are always shuffled to the end and the comparator is never called on them, which is why a comparator like a - b does not produce NaN chaos on a sparse list — it simply never sees those slots.

Answers JS Array 25 in the original set.

26 Writing a comparator

fair

Sort numbers up, then down, then sort words by length. Then work out why (a, b) => a > b is not a comparator.

Stuck? Three hints, smallest first
  1. 1. A comparator returns a negative number, zero, or a positive number. Only the sign is read.
  2. 2. a > b returns a boolean, which becomes 0 or 1. There is no way for it to say "a comes first".
  3. 3. a - b works for numbers and gives NaN for text, which is why strings need < and > or localeCompare.

The answer

const asc = (a, b) => a - b;
const desc = (a, b) => b - a;
const byLength = (a, b) => a.length - b.length;
const byText = (a, b) => (a < b ? -1 : a > b ? 1 : 0);

console.log('ascending  :', [10, 9, 1].sort(asc));
console.log('descending :', [10, 9, 1].sort(desc));
console.log('by length  :', ['ccc', 'a', 'bb'].sort(byLength));
console.log('--- only the sign matters ---');
console.log('Math.sign version:', [3, 1, 2].sort((a, b) => Math.sign(a - b)));
console.log('-99 and 99 work as well as -1 and 1:', [3, 1, 2].sort((a, b) => (a < b ? -99 : a > b ? 99 : 0)));
console.log('--- the boolean mistake ---');
console.log('1 > 2 is', 1 > 2, 'which as a number is', Number(1 > 2));
console.log('2 > 1 is', 2 > 1, 'which is', Number(2 > 1), '<- never negative, so nothing moves left');
console.log("'b' - 'a' is", 'b' - 'a', '<- so a - b cannot sort text');

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.

9 checks

test('a - b sorts ascending', [10, 9, 1].sort(asc), [1, 9, 10]);
test('b - a sorts descending', [10, 9, 1].sort(desc), [10, 9, 1]);
test('a computed key works the same way', ['ccc', 'a', 'bb'].sort(byLength), ['a', 'bb', 'ccc']);
test('only the sign is read, so Math.sign changes nothing', [3, 1, 2].sort((a, b) => Math.sign(a - b)), [1, 2, 3]);
test('and any negative number means the same as -1', [3, 1, 2].sort((a, b) => (a < b ? -99 : a > b ? 99 : 0)), [1, 2, 3]);
test('a > b can only ever return 0 or 1', [Number(1 > 2), Number(2 > 1)], [0, 1]);
test('so a boolean comparator can never ask for a swap leftwards', [1, 2].map((n) => Number(n > 99)).includes(-1), false);
test('subtracting strings gives NaN', Number.isNaN('b' - 'a'), true);
test('so text needs explicit comparisons', ['banana', 'apple'].sort(byText), ['apple', 'banana']);

A comparator answers one question: does a belong before b? Negative means yes, positive means no, zero means "either, leave them as they are". The magnitude is ignored, which is why a - b and Math.sign(a - b) sort identically.

(a, b) => a > b is the mistake worth recognising. A boolean coerces to 0 or 1, so the comparator can say "swap" or "equal" but never "move a earlier". Depending on the engine's algorithm and the input, the result may be sorted, partly sorted, or untouched — which makes it a bug that passes in testing and fails in production. Assert the arithmetic rather than the output, as above, because the output is genuinely engine-dependent.

Answers JS Array 26 in the original set.

27 Sort records by a key, and by two keys

fair

Sort a list of people by age, then by department and age together. Then check what happens to two records that tie.

Stuck? Three hints, smallest first
  1. 1. One comparator per key, then a combinator that tries each in turn until one returns non-zero.
  2. 2. JavaScript’s sort is required to be stable: equal elements keep their original relative order.
  3. 3. That means sorting by the minor key first and the major key second gives the same answer as one combined comparator.

The answer

const staff = [
  { name: 'Ada', dept: 'ops', age: 34 },
  { name: 'Bola', dept: 'eng', age: 29 },
  { name: 'Chi', dept: 'ops', age: 29 },
  { name: 'Dami', dept: 'eng', age: 34 },
];

const byAge = (a, b) => a.age - b.age;
const byKey = (key) => (a, b) => (a[key] < b[key] ? -1 : a[key] > b[key] ? 1 : 0);

// Try each comparator until one has an opinion.
const thenBy = (...comparators) => (a, b) => {
  for (const cmp of comparators) {
    const verdict = cmp(a, b);
    if (verdict !== 0) return verdict;
  }
  return 0;
};

const names = (list) => list.map((s) => s.name);
const sortedByAge = [...staff].sort(byAge);
const twoKeys = [...staff].sort(thenBy(byKey('dept'), byKey('name')));
const twoPasses = [...staff].sort(byKey('name')).sort(byKey('dept'));

console.log('by age            :', names(sortedByAge));
console.log('dept then name    :', names(twoKeys));
console.log('name, then re-sorted by dept:', names(twoPasses), '<- stability makes these agree');
console.log('by age descending :', names([...staff].sort((a, b) => b.age - a.age)));
console.log('original order    :', names(staff));

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.

9 checks

test('sorting by a numeric key', names(sortedByAge), ['Bola', 'Chi', 'Ada', 'Dami']);
test('the two 29s kept their original order, which is stability', names(sortedByAge).slice(0, 2), ['Bola', 'Chi']);
test('a second comparator breaks the tie', names(twoKeys), ['Bola', 'Dami', 'Ada', 'Chi']);
test('two stable passes give the same answer as one combined comparator', names(twoPasses), names(twoKeys));
test('descending reverses the comparator, not the array', names([...staff].sort((a, b) => b.age - a.age)), ['Ada', 'Dami', 'Bola', 'Chi']);
test('sorting a copy leaves the original order alone', names(staff), ['Ada', 'Bola', 'Chi', 'Dami']);
test('the sorted list holds the same objects, not copies', sortedByAge[0] === staff[1], true);
test('a key that no record has leaves the order untouched', names([...staff].sort(byKey('nope'))), names(staff));
test('and a comparator returning 0 throughout is a no-op', [...staff].sort(() => 0).length, 4);

Stability is a guarantee, not luck: the specification has required it since ES2019. It is what makes the two-pass sort work — sort by name, then by department, and within each department the names are still in order because equal departments did not move.

thenBy is worth keeping in a utilities file. Written out longhand, multi-key sorting turns into nested ternaries that nobody can read six months later; as a list of comparators it stays obvious, and adding a third key is one more argument.

The last-but-one assertion is the quiet one: sorting by a key none of the records has compares undefined with undefined, returns 0 every time, and leaves the array exactly as it was. A typo in a key name therefore fails silently rather than loudly.

Answers JS Array 27 in the original set.

28 Sorting text that is not plain ASCII

fair

Sort ['apple', 'Banana', 'cherry'], then ['item10', 'item9', 'item2'], then a word with an accent. Fix each one.

Stuck? Three hints, smallest first
  1. 1. The default comparator uses code point order, where every capital letter is below every lower-case one.
  2. 2. localeCompare knows that a and A differ less than a and b.
  3. 3. localeCompare(other, undefined, { numeric: true }) compares digit runs as numbers.

The answer

const mixedCase = ['apple', 'Banana', 'cherry'];
const numbered = ['item10', 'item9', 'item2'];
const accented = ['zebra', 'éclair'];

const caseInsensitive = (a, b) => a.toLowerCase().localeCompare(b.toLowerCase());
const natural = (a, b) => a.localeCompare(b, undefined, { numeric: true });

console.log('default    :', [...mixedCase].sort(), '<- B before a, by code point');
console.log('locale     :', [...mixedCase].sort((a, b) => a.localeCompare(b)));
console.log('lower-cased:', [...mixedCase].sort(caseInsensitive));
console.log('--- numbers inside text ---');
console.log('default    :', [...numbered].sort(), '<- item10 before item2');
console.log('numeric    :', [...numbered].sort(natural));
console.log('--- accents ---');
console.log('default    :', [...accented].sort(), '<- é is code point 233, past z');
console.log('locale     :', [...accented].sort((a, b) => a.localeCompare(b)));
console.log("'a'.localeCompare('B') =", 'a'.localeCompare('B'));

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.

9 checks

test('the default sort puts every capital first', [...mixedCase].sort(), ['Banana', 'apple', 'cherry']);
test('localeCompare orders them as a reader would', [...mixedCase].sort((a, b) => a.localeCompare(b)), ['apple', 'Banana', 'cherry']);
test('lower-casing first works too', [...mixedCase].sort(caseInsensitive), ['apple', 'Banana', 'cherry']);
test("because localeCompare says 'a' comes before 'B'", Math.sign('a'.localeCompare('B')), -1);
test('while a code point comparison says the opposite', 'a' < 'B', false);
test('digits in text sort as text by default', [...numbered].sort(), ['item10', 'item2', 'item9']);
test('the numeric option reads the runs as numbers', [...numbered].sort(natural), ['item2', 'item9', 'item10']);
test('an accented letter sorts past z by code point', [...accented].sort(), ['zebra', 'éclair']);
test('but next to e by locale', [...accented].sort((a, b) => a.localeCompare(b)), ['éclair', 'zebra']);

The default comparator is < on strings, which is code point order: all 26 capitals sit below all 26 lower-case letters, and every accented character sits above both. That is why an untouched sort() produces a list no human would call alphabetical.

localeCompare is the fix, and its third argument is the part people miss. { numeric: true } gives you the "natural" ordering that puts item9 before item10, which is what a file list should do. It is slower than <, so for a large array build a key with Intl.Collator once and reuse it rather than calling localeCompare inside the comparator.

Answers JS Array 28 in the original set.

29 The copying methods

fair

toSorted, toReversed, toSpliced and with are the non-mutating twins of sort, reverse, splice and index assignment. Use each one and prove the input survived.

Stuck? Three hints, smallest first
  1. 1. They are recent additions, so feature-test rather than assume: typeof Array.prototype.toSorted === ‘function’.
  2. 2. Each returns a new array; the original is untouched in every case.
  3. 3. toSorted() with no comparator still sorts as strings, exactly like sort().

The answer

const hasCopying = ['toSorted', 'toReversed', 'toSpliced', 'with']
  .every((m) => typeof Array.prototype[m] === 'function');

// Each wrapper uses the built-in where it exists and the old idiom otherwise,
// so the checks below are honest on any engine.
const sortedCopy = (xs, cmp) => (hasCopying ? xs.toSorted(cmp) : [...xs].sort(cmp));
const reversedCopy = (xs) => (hasCopying ? xs.toReversed() : [...xs].reverse());
const splicedCopy = (xs, start, count) =>
  hasCopying ? xs.toSpliced(start, count) : [...xs.slice(0, start), ...xs.slice(start + count)];
const withCopy = (xs, i, v) =>
  hasCopying ? xs.with(i, v) : xs.map((old, k) => (k === i ? v : old));

const nums = [3, 1, 2];

console.log('this engine has the copying methods:', hasCopying);
console.log('toSorted   :', sortedCopy(nums, (a, b) => a - b), ' original:', nums);
console.log('toReversed :', reversedCopy(nums), ' original:', nums);
console.log('toSpliced  :', splicedCopy([1, 2, 3, 4], 1, 2), '<- removed indexes 1 and 2');
console.log('with       :', withCopy(nums, 0, 'x'), ' original:', nums);
console.log('no comparator still means string order:', sortedCopy([10, 9, 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.

9 checks

test('toSorted returns a sorted copy', sortedCopy(nums, (a, b) => a - b), [1, 2, 3]);
test('and the original keeps its order', nums, [3, 1, 2]);
test('toReversed copies as well', reversedCopy(nums), [2, 1, 3]);
test('toSpliced removes without mutating', splicedCopy([1, 2, 3, 4], 1, 2), [1, 4]);
test('with replaces one index', withCopy(nums, 0, 'x'), ['x', 1, 2]);
test('every one of them returns a different array', sortedCopy(nums, (a, b) => a - b) === nums, false);
test('toSorted with no comparator still sorts as text', sortedCopy([10, 9, 1]), [1, 10, 9]);
test('the feature test answered with a boolean', typeof hasCopying, 'boolean');
test('and the mutating versions are still there when you want them', (() => { const a = [2, 1]; a.sort(); return a; })(), [1, 2]);

These four close a long-standing gap: before them, "sort without mutating" meant remembering to spread first, and forgetting was a bug that only showed up in whatever code held the other reference. xs.toSorted(cmp) says what it does.

The wrappers are not decoration. These methods shipped in 2023, so a reader on an older browser would see failures for something that is not their mistake — the feature test keeps the page honest and shows the pre-2023 idiom next to each one. Note that they are still shallow: toSorted gives a new array holding the same objects.

Answers JS Array 29 in the original set.

30 reduce: a sum and a product

easy

Add up an array and multiply it out with reduce. Then call reduce on an empty array with no initial value and read the error.

Stuck? Three hints, smallest first
  1. 1. The callback takes the accumulator first, then the element: (total, n) => total + n.
  2. 2. The initial value is the second argument to reduce, and it decides the type of the result.
  3. 3. With no initial value, the first element becomes the accumulator — and an empty array has no first element.

The answer

const sum = (xs) => xs.reduce((total, n) => total + n, 0);
const product = (xs) => xs.reduce((total, n) => total * n, 1);

const emptyNoSeed = (() => {
  try { return [].reduce((a, b) => a + b); } catch (e) { return e.constructor.name; }
})();

// With one element and no initial value, the callback never runs at all.
let calls = 0;
const lone = [5].reduce((a, b) => { calls++; return a + b; });

console.log('sum([1,2,3,4])   :', sum([1, 2, 3, 4]));
console.log('product([2,3,4]) :', product([2, 3, 4]));
console.log('sum([])          :', sum([]), '<- the initial value');
console.log('[].reduce with no initial value:', emptyNoSeed);
console.log('[5].reduce with no initial value:', lone, 'after', calls, 'calls');
console.log('a wrong seed shows up at once: product with 0 ->', [2, 3, 4].reduce((t, n) => t * n, 0));
console.log('floats do not add up exactly:', [0.1, 0.2].reduce((t, n) => t + n, 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.

9 checks

test('a sum', sum([1, 2, 3, 4]), 10);
test('a product', product([2, 3, 4]), 24);
test('an empty array returns the initial value', sum([]), 0);
test('with no initial value, an empty array throws', emptyNoSeed, 'TypeError');
test('with one element and no initial value the callback never runs', calls, 0);
test('and that element is returned as it is', lone, 5);
test('the seed decides the type of the result', ['a', 'b'].reduce((acc, c) => acc + c, ''), 'ab');
test('a seed of 0 ruins a product', [2, 3, 4].reduce((t, n) => t * n, 0), 0);
test('and floating point still does not add up', [0.1, 0.2].reduce((t, n) => t + n, 0), 0.30000000000000004);

Also Basic 22, which sums with a loop. The angle here is reduce's second argument, because that is where the bugs are. Omit it and the first element becomes the accumulator, which works for a sum of numbers, produces nonsense for a sum of strings, and throws TypeError on an empty array — the one case your tests are least likely to cover.

Always pass an initial value. It documents the result type, it makes the empty case correct instead of fatal, and it is the difference between a total of 0 and a crash on a filtered-to-nothing list.

Answers JS Array 30 in the original set.

31 Largest and smallest

fair

Find the largest and smallest value two ways: with reduce, and with Math.max and spread. Then find a case where the two disagree.

Stuck? Three hints, smallest first
  1. 1. Math.max() with no arguments returns -Infinity, which is the correct identity for a maximum.
  2. 2. Spread turns every element into an argument, and there is a limit on how many arguments a call can take.
  3. 3. Math.max propagates NaN. A comparison like n > best is always false against NaN, so it skips it.

The answer

const largest = (xs) => xs.reduce((best, n) => (n > best ? n : best), -Infinity);
const smallest = (xs) => xs.reduce((best, n) => (n < best ? n : best), Infinity);

// Pick the record with the smallest price, not just the price itself.
const cheapest = (rows) => rows.reduce((best, r) => (r.price < best.price ? r : best));

const fares = [
  { route: 'Yaba', price: 450 },
  { route: 'Ikeja', price: 900 },
  { route: 'Ojota', price: 300 },
];

const big = Array.from({ length: 200000 }, (_, i) => i);
const spreadBig = (() => {
  try { return String(Math.max(...big)); } catch (e) { return e.constructor.name; }
})();

console.log('reduce  :', largest([3, 9, 4]), smallest([3, 9, 4]));
console.log('Math.max:', Math.max(...[3, 9, 4]), Math.min(...[3, 9, 4]));
console.log('Math.max() with no arguments:', Math.max(), '<- the identity for a maximum');
console.log('--- where they differ ---');
console.log('Math.max(1, NaN, 3) :', Math.max(1, NaN, 3), '<- one bad value poisons it');
console.log('largest([1, NaN, 3]):', largest([1, NaN, 3]), '<- the comparison skips it');
console.log('cheapest record      :', cheapest(fares).route);
console.log('200,000 values through spread:', spreadBig);
console.log('the same through reduce      :', largest(big));

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.

9 checks

test('reduce finds the largest', largest([3, 9, 4]), 9);
test('and the smallest', smallest([3, 9, 4]), 3);
test('Math.max with spread agrees', Math.max(...[3, 9, 4]), 9);
test('Math.max of nothing is -Infinity', Math.max() === -Infinity, true);
test('so an empty array reduces to the identity, not an error', largest([]) === -Infinity, true);
test('Math.max is poisoned by a single NaN', Number.isNaN(Math.max(1, NaN, 3)), true);
test('while the comparison-based reduce steps over it', largest([1, NaN, 3]), 3);
test('reduce handles an array too long to spread', largest(big), 199999);
test('and reduce can return the whole record, not just the number', cheapest(fares).route, 'Ojota');

Math.max(...xs) is the short form and it has two real limits. Spread becomes one argument per element, and an engine will throw RangeError somewhere over a hundred thousand of them — the log above shows what your engine does with 200,000. And a single NaN anywhere makes the answer NaN.

The reduce version is longer and neither problem applies: it looks at one value at a time, and n > best is false for NaN so the bad value is simply passed over. Which behaviour you want is a decision — silently ignoring corrupt data is not always right — but you should know which one you are getting.

cheapest is the pattern to take away: reduce over the records rather than over the prices, and you keep the whole object instead of having to search for it again.

Answers JS Array 31 in the original set.

32 Reduce a list into an object

fair

Turn a list of records into a lookup keyed by id. Then find out what happens if you forget to return the accumulator.

Stuck? Three hints, smallest first
  1. 1. The accumulator is the object you are building, and you must return it every time.
  2. 2. An object key is always a string, so a numeric id comes back as ‘1’.
  3. 3. Object.fromEntries(rows.map(...)) does the same job without an accumulator to forget.

The answer

const rows = [
  { id: 1, name: 'Ada' },
  { id: 2, name: 'Bola' },
  { id: 3, name: 'Chi' },
];

const indexById = (list) => list.reduce((acc, row) => {
  acc[row.id] = row;
  return acc;                                  // forget this line and the next call gets undefined
}, {});

const viaEntries = Object.fromEntries(rows.map((r) => [r.id, r]));
const asMap = new Map(rows.map((r) => [r.id, r]));

const forgotten = (() => {
  try {
    return rows.reduce((acc, row) => { acc[row.id] = row; }, {});
  } catch (e) { return e.constructor.name; }
})();

const keyed = indexById(rows);

console.log('lookup by id   :', keyed[2].name);
console.log('the keys       :', Object.keys(keyed), '<- strings, though the ids were numbers');
console.log('via fromEntries:', Object.keys(viaEntries));
console.log('a Map instead  :', asMap.get(2).name, ' size', asMap.size);
console.log('forgetting the return:', forgotten);
console.log('a duplicate id overwrites:', Object.keys(indexById([...rows, { id: 1, name: 'Late' }])));

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.

9 checks

test('every record is reachable by its id', keyed[2].name, 'Bola');
test('the keys became strings', Object.keys(keyed), ['1', '2', '3']);
test('a numeric lookup still works, because it is converted too', keyed['2'] === keyed[2], true);
test('the values are the original objects', keyed[1] === rows[0], true);
test('Object.fromEntries gives the same thing', same(viaEntries, keyed), true);
test('forgetting to return the accumulator throws', forgotten, 'TypeError');
test('a duplicate key keeps the last record', indexById([...rows, { id: 1, name: 'Late' }])[1].name, 'Late');
test('a Map keeps numeric keys as numbers', [...asMap.keys()], [1, 2, 3]);
test('and it reports its size directly', asMap.size, 3);

The forgotten return is the classic reduce bug, and the error it produces is one step removed from the cause: the first call succeeds and returns undefined, and the second call then fails trying to set a property on it. If you see "cannot set properties of undefined" inside a reduce, this is why.

Object.fromEntries avoids the whole shape, and a Map avoids the other problem in the assertions: object keys are strings, so a lookup keyed by numeric id quietly converts 1 to '1'. Harmless until you have ids of 1 and '1', or you iterate and find your integer keys reordered.

Answers JS Array 32 in the original set.

33 Group a list by a key

fair

Group records into { key: [records] }. Then group by a numeric key and notice what the object did to it.

Stuck? Three hints, smallest first
  1. 1. The accumulator is an object of arrays, so create the array the first time you see a key.
  2. 2. (acc[k] ??= []).push(x) does the create-then-push in one expression.
  3. 3. A Map keyed accumulator keeps numbers as numbers; a plain object cannot.

The answer

const staff = [
  { name: 'Ada', dept: 'ops', age: 34 },
  { name: 'Bola', dept: 'eng', age: 29 },
  { name: 'Chi', dept: 'ops', age: 29 },
  { name: 'Dami', dept: 'eng', age: 34 },
];

const groupBy = (xs, keyOf) => xs.reduce((acc, x) => {
  (acc[keyOf(x)] ??= []).push(x);               // create the bucket on first sight
  return acc;
}, {});

const groupByMap = (xs, keyOf) => xs.reduce((acc, x) => {
  const key = keyOf(x);
  acc.set(key, [...(acc.get(key) ?? []), x]);
  return acc;
}, new Map());

const names = (list) => list.map((s) => s.name);
const byDept = groupBy(staff, (s) => s.dept);
const byAge = groupByMap(staff, (s) => s.age);

// The built-in, where the engine has it.
const hasGroupBy = typeof Object.groupBy === 'function';
const builtIn = hasGroupBy ? Object.groupBy(staff, (s) => s.dept) : byDept;

console.log('groups     :', Object.keys(byDept), '<- insertion order');
console.log('ops        :', names(byDept.ops));
console.log('eng        :', names(byDept.eng));
console.log('by age, Map:', [...byAge.keys()], '<- still numbers');
console.log('by age, object keys:', Object.keys(groupBy(staff, (s) => s.age)), '<- strings, and reordered');
console.log('Object.groupBy available?', hasGroupBy);

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.

9 checks

test('the groups are named after the key values', Object.keys(byDept), ['ops', 'eng']);
test('each group holds its members in input order', names(byDept.ops), ['Ada', 'Chi']);
test('and the groups account for everyone', byDept.ops.length + byDept.eng.length, staff.length);
test('an empty input produces no groups', groupBy([], (x) => x), {});
test('a Map accumulator keeps a numeric key numeric', [...byAge.keys()], [34, 29]);
test('while an object turns it into a string', Object.keys(groupBy(staff, (s) => s.age)), ['29', '34']);
test('and reorders it, because integer-like keys sort first', Object.keys(groupBy(staff, (s) => s.age))[0], '29');
test('a key function returning undefined groups under "undefined"', Object.keys(groupBy(staff, () => undefined)), ['undefined']);
test('Object.groupBy produces the same grouping where it exists', names(builtIn.ops), ['Ada', 'Chi']);

Also Basic 38. The angle here is the accumulator's type, and the assertions make the difference concrete: grouping by age into an object gives keys '29' and '34' in that order, even though 34 was seen first, because integer-like keys are enumerated numerically before the rest. Grouping into a Map keeps 34 and 29 as numbers and in the order encountered.

??= is what keeps the reduce to two lines: it assigns only when the left side is null or undefined, so the bucket is created once and appended to thereafter. Object.groupBy does all of this natively and is recent enough to be worth feature-testing, which is why the last assertion goes through a guard.

Answers JS Array 33 in the original set.

34 Count how often each value appears

fair

Count the occurrences of each value in a list. Do it with a Map and with a plain object, then find two inputs the object gets wrong.

Stuck? Three hints, smallest first
  1. 1. map.set(v, (map.get(v) ?? 0) + 1) is the whole loop body, and set returns the map.
  2. 2. An object key is a string, so 1 and ‘1’ end up in the same bucket.
  3. 3. A Map can key on NaN, on objects, and on numbers, all kept distinct.

The answer

const countMap = (xs) => xs.reduce((m, v) => m.set(v, (m.get(v) ?? 0) + 1), new Map());
const countObject = (xs) => xs.reduce((o, v) => { o[v] = (o[v] ?? 0) + 1; return o; }, {});

const votes = ['bus', 'keke', 'bus', 'okada', 'bus'];
const mixedTypes = [1, '1', 1];
const notNumbers = [NaN, NaN, undefined];

const a = {};
const b = {};

console.log('votes      :', [...countMap(votes)]);
console.log('as an object:', countObject(votes));
console.log('--- what the object cannot do ---');
console.log("[1, '1', 1] object:", countObject(mixedTypes), '<- one bucket');
console.log("[1, '1', 1] Map   :", [...countMap(mixedTypes)], '<- two');
console.log('NaN as a Map key  :', countMap(notNumbers).get(NaN));
console.log('two empty objects :', countMap([a, b]).size, 'buckets, because keys are identities');
console.log('the same object twice:', countMap([a, a]).get(a));

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.

9 checks

test('the counts are right', [...countMap(votes)], [['bus', 3], ['keke', 1], ['okada', 1]]);
test('and the total matches the length', [...countMap(votes).values()].reduce((t, n) => t + n, 0), votes.length);
test('an object gives the same answer for string data', countObject(votes), { bus: 3, keke: 1, okada: 1 });
test('but it merges 1 and "1" into one bucket', countObject(mixedTypes), { 1: 3 });
test('while a Map keeps them apart', countMap(mixedTypes).size, 2);
test('a Map can count NaN', countMap(notNumbers).get(NaN), 2);
test('and undefined, without stringifying it', countMap(notNumbers).get(undefined), 1);
test('objects are counted by identity, so two empty ones are two keys', countMap([a, b]).size, 2);
test('an empty list produces an empty map', countMap([]).size, 0);

Also Basic 25, which counts characters in a string. Over an array of arbitrary values the choice of container starts to matter, and the assertions show the two failures a plain object cannot avoid: 1 and '1' collide because every object key is a string, and NaN, undefined and null become the text 'NaN', 'undefined' and 'null' — indistinguishable from strings that happen to spell those words.

A Map keys on the value itself using SameValueZero, so numbers stay numbers, NaN is a usable key, and objects are counted by identity. Use an object for counting words; use a Map for counting anything else.

Answers JS Array 34 in the original set.

35 reduceRight, and why direction matters

fair

Fold an array from the right. Find an operation where the direction changes the answer, and use reduceRight to compose functions.

Stuck? Three hints, smallest first
  1. 1. reduceRight starts at the last element and works towards the first.
  2. 2. Addition does not care about direction; subtraction and string concatenation do.
  3. 3. Function composition is reduceRight by definition: f(g(x)) applies g first.

The answer

const letters = ['a', 'b', 'c'];
const nums = [10, 3, 2];

const visitedRight = [];
letters.reduceRight((acc, v, i) => { visitedRight.push(i); return acc; }, null);

const double = (n) => n * 2;
const increment = (n) => n + 1;

// compose applies right to left; pipe applies left to right.
const compose = (...fns) => (x) => fns.reduceRight((v, f) => f(v), x);
const pipe = (...fns) => (x) => fns.reduce((v, f) => f(v), x);

const emptyRight = (() => {
  try { return [].reduceRight((a, b) => a + b); } catch (e) { return e.constructor.name; }
})();

console.log('reduce      :', letters.reduce((a, b) => a + b));
console.log('reduceRight :', letters.reduceRight((a, b) => a + b));
console.log('indexes visited by reduceRight:', visitedRight);
console.log('--- subtraction is not symmetric ---');
console.log('reduce      : 10 - 3 - 2 =', nums.reduce((a, b) => a - b));
console.log('reduceRight :  2 - 3 - 10 =', nums.reduceRight((a, b) => a - b));
console.log('compose(double, increment)(5):', compose(double, increment)(5), '<- increment first');
console.log('pipe(double, increment)(5)   :', pipe(double, increment)(5), '<- double first');

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.

9 checks

test('reduce folds left to right', letters.reduce((a, b) => a + b), 'abc');
test('reduceRight folds right to left', letters.reduceRight((a, b) => a + b), 'cba');
test('it visits the indexes in reverse', visitedRight, [2, 1, 0]);
test('a sum is the same either way', nums.reduce((a, b) => a + b, 0), nums.reduceRight((a, b) => a + b, 0));
test('a subtraction is not', nums.reduce((a, b) => a - b), 5);
test('the right fold gives a different number', nums.reduceRight((a, b) => a - b), -11);
test('compose applies the rightmost function first', compose(double, increment)(5), 12);
test('pipe applies the leftmost first', pipe(double, increment)(5), 11);
test('reduceRight on an empty array with no seed throws too', emptyRight, 'TypeError');

reduceRight earns its keep in exactly two situations: an operation that is not associative, and function composition. For a sum, a count or a maximum it is a coin flip and reduce reads better.

The compose/pipe pair is the clearest illustration. compose(double, increment) means double(increment(x)), which is reduceRight; pipe means the same list applied in reading order, which is reduce. Both are four tokens of real work, and both are worth writing once so you never have to guess which direction a library's compose goes.

Answers JS Array 35 in the original set.

36 Running totals

fair

Turn [1, 2, 3, 4] into [1, 3, 6, 10]: each element plus everything before it. Then do a running maximum, and recover the original list from the totals.

Stuck? Three hints, smallest first
  1. 1. A map with one variable outside it is the cheapest version: total += n as the returned value.
  2. 2. total += n is an expression, so an arrow can return it directly.
  3. 3. The inverse is the differences between neighbours, with the first element left as it is.

The answer

function cumulative(xs) {
  let total = 0;
  return xs.map((n) => (total += n));           // += evaluates to the new total
}

const runningMax = (xs) => {
  let best = -Infinity;
  return xs.map((n) => (best = Math.max(best, n)));
};

// The same thing with reduce, which copies the accumulator on every step.
const cumulativeReduce = (xs) => xs.reduce((acc, n) => [...acc, (acc.at(-1) ?? 0) + n], []);

// The inverse: differences.
const differences = (totals) => totals.map((t, i) => (i === 0 ? t : t - totals[i - 1]));

const sales = [1, 2, 3, 4];

console.log('input        :', sales);
console.log('running sum  :', cumulative(sales));
console.log('reduce version:', cumulativeReduce(sales));
console.log('running max  :', runningMax([1, 3, 2, 5, 4]));
console.log('differences  :', differences(cumulative(sales)), '<- back to the input');
console.log('the last total is the sum:', cumulative(sales).at(-1));
console.log('empty        :', cumulative([]));

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.

9 checks

test('a running sum', cumulative(sales), [1, 3, 6, 10]);
test('the last value is the total', cumulative(sales).at(-1), 10);
test('the reduce version agrees', cumulativeReduce(sales), cumulative(sales));
test('an empty list gives an empty list', cumulative([]), []);
test('a running maximum never goes down', runningMax([1, 3, 2, 5, 4]), [1, 3, 3, 5, 5]);
test('differences undo a running sum', differences(cumulative(sales)), sales);
test('negative values make a running sum fall', cumulative([5, -2, 1]), [5, 3, 4]);
test('the output is always the same length as the input', cumulative([1, 2, 3]).length, 3);
test('and the input is untouched', sales, [1, 2, 3, 4]);

Both versions are correct and they do not cost the same. The map with a variable beside it does one pass and allocates one array. The reduce version builds a fresh array on every step, so it copies 1 + 2 + 3 + ... + n elements — quadratic work for a linear problem. It reads more "functionally" and is the wrong choice past a few hundred elements.

A closure over a mutable total inside map is sometimes called impure and frowned on. It is fine here: the variable never escapes the function, so the function as a whole is still pure, which is the property that actually matters.

Answers JS Array 36 in the original set.

37 Unique values

easy

Remove duplicates while keeping the first occurrence of each value. Then try it on [NaN, NaN] with both the Set version and the older filter/indexOf one.

Stuck? Three hints, smallest first
  1. 1. A Set keeps insertion order and rejects a value it already holds.
  2. 2. Spread or Array.from converts it back, because a Set has no indexes and no array methods.
  3. 3. filter((v, i, a) => a.indexOf(v) === i) was the old idiom, and indexOf cannot find NaN.

The answer

const unique = (xs) => [...new Set(xs)];
const uniqueOldWay = (xs) => xs.filter((v, i, all) => all.indexOf(v) === i);

// Unique by a computed key, which a Set alone cannot do for objects.
function uniqueBy(xs, keyOf) {
  const seen = new Set();
  return xs.filter((x) => {
    const key = keyOf(x);
    if (seen.has(key)) return false;
    seen.add(key);
    return true;
  });
}

const rows = [{ id: 1, n: 'a' }, { id: 2, n: 'b' }, { id: 1, n: 'again' }];

console.log('unique      :', unique([1, 2, 2, 3, 1]));
console.log('order kept  :', unique(['b', 'a', 'b']));
console.log('old idiom   :', uniqueOldWay([1, 2, 2, 3, 1]));
console.log('--- NaN ---');
console.log('Set     :', unique([NaN, NaN]).length, 'value');
console.log('indexOf :', uniqueOldWay([NaN, NaN]).length, 'values <- both dropped');
console.log('--- objects ---');
console.log('two lookalikes stay:', unique([{ id: 1 }, { id: 1 }]).length);
console.log('uniqueBy id       :', uniqueBy(rows, (r) => r.id).map((r) => r.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.

9 checks

test('duplicates are removed', unique([1, 2, 2, 3, 1]), [1, 2, 3]);
test('the first occurrence sets the order', unique(['b', 'a', 'b']), ['b', 'a']);
test('the old idiom agrees on ordinary values', uniqueOldWay([1, 2, 2, 3, 1]), [1, 2, 3]);
test('a Set treats NaN as one value', unique([NaN, NaN]).length, 1);
test('but indexOf cannot find NaN, so the old idiom drops every one', uniqueOldWay([NaN, NaN]), []);
test('0 and -0 are also one value to a Set', unique([0, -0]), [0]);
test('objects are kept by identity, so lookalikes both survive', unique([{ id: 1 }, { id: 1 }]).length, 2);
test('uniqueBy fixes that, keeping the first of each key', uniqueBy(rows, (r) => r.id).map((r) => r.n), ['a', 'b']);
test('a Set has no indexes, which is why you convert back', new Set([1, 2])[0], undefined);

Also Basic 26. The angle here is the comparison each version uses. A Set uses SameValueZero, so NaN equals itself and de-duplicates properly; filter with indexOf uses strict equality, and since indexOf(NaN) is always -1, the condition indexOf(v) === i is false for every NaN and they all vanish. That is a data-loss bug, not a cosmetic one.

Neither version helps with objects, because both compare identity. When "the same" means "the same id", you need a key function — uniqueBy is the smallest thing that works, and it is worth keeping around.

Answers JS Array 37 in the original set.

38 Union, intersection and difference

fair

Given [1, 2, 3, 4] and [3, 4, 5], produce the union, the intersection, both differences, and the symmetric difference.

Stuck? Three hints, smallest first
  1. 1. Put the second list in a Set first, so each membership test is one lookup rather than a scan.
  2. 2. De-duplicate the first list too, or a repeated value appears twice in the answer.
  3. 3. Difference is not symmetric: a \ b and b \ a are different questions.

The answer

const union = (a, b) => [...new Set([...a, ...b])];

const intersection = (a, b) => {
  const inB = new Set(b);                       // one Set, then n lookups
  return [...new Set(a)].filter((v) => inB.has(v));
};

const difference = (a, b) => {
  const inB = new Set(b);
  return [...new Set(a)].filter((v) => !inB.has(v));
};

const symmetric = (a, b) => [...difference(a, b), ...difference(b, a)];

const a = [1, 2, 3, 4];
const b = [3, 4, 5];

const hasSetMethods = typeof new Set().union === 'function';
const builtInUnion = hasSetMethods ? [...new Set(a).union(new Set(b))] : union(a, b);

console.log('union        :', union(a, b));
console.log('intersection :', intersection(a, b));
console.log('a minus b    :', difference(a, b));
console.log('b minus a    :', difference(b, a), '<- not the same question');
console.log('symmetric    :', symmetric(a, b));
console.log('duplicates do not multiply:', intersection([3, 3, 4], [3, 4]));
console.log('Set methods available?', hasSetMethods, '->', builtInUnion);

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.

9 checks

test('the union holds each value once', union(a, b), [1, 2, 3, 4, 5]);
test('the intersection is what both have', intersection(a, b), [3, 4]);
test('a minus b', difference(a, b), [1, 2]);
test('b minus a is a different answer', difference(b, a), [5]);
test('so difference is not symmetric', same(difference(a, b), difference(b, a)), false);
test('the symmetric difference is both leftovers', symmetric(a, b), [1, 2, 5]);
test('a repeated value appears once in the result', intersection([3, 3, 4], [3, 4]), [3, 4]);
test('intersecting with an empty list gives nothing', intersection(a, []), []);
test('the built-in Set methods agree where the engine has them', builtInUnion, [1, 2, 3, 4, 5]);

The Set is not a style choice, it is the algorithm. Written with b.includes(v) instead, intersection re-scans b for every element of a — fine for ten items, and quadratic for ten thousand. Building one Set up front makes each test a hash lookup.

De-duplicating a matters too: without the inner new Set(a), intersection([3, 3, 4], [3, 4]) would return [3, 3, 4], which is no longer a set. And the two differences being different answers is worth stating out loud, because "difference" in a function name without an argument order convention is a bug waiting to happen.

Answers JS Array 38 in the original set.

39 Find the first duplicate

fair

Return the first value that appears twice, scanning left to right. [5, 9, 9, 5] should give 9, not 5. Then list every duplicated value.

Stuck? Three hints, smallest first
  1. 1. Walk once, remembering what you have seen in a Set, and stop at the first value already in it.
  2. 2. "First duplicate" means the first second occurrence, so [5, 9, 9, 5] gives 9.
  3. 3. new Set(xs).size !== xs.length answers "is there any duplicate" in one line.

The answer

function firstDuplicate(xs) {
  const seen = new Set();
  for (const v of xs) {
    if (seen.has(v)) return v;
    seen.add(v);
  }
  return undefined;
}

const allDuplicates = (xs) => {
  const seen = new Set();
  const twice = new Set();
  for (const v of xs) (seen.has(v) ? twice : seen).add(v);
  return [...twice];
};

const hasDuplicate = (xs) => new Set(xs).size !== xs.length;

const shared = {};

console.log('[1, 2, 3, 2]  ->', firstDuplicate([1, 2, 3, 2]));
console.log('[5, 9, 9, 5]  ->', firstDuplicate([5, 9, 9, 5]), '<- 9 repeats first');
console.log('[1, 2, 3]     ->', firstDuplicate([1, 2, 3]));
console.log('all duplicates:', allDuplicates([1, 2, 2, 3, 3, 3]));
console.log('any duplicate?', hasDuplicate([1, 2, 3]), hasDuplicate([1, 1]));
console.log('NaN           ->', firstDuplicate([NaN, NaN]), '<- a Set can hold it');
console.log('objects       ->', firstDuplicate([shared, {}, shared]) === shared);

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.

9 checks

test('a repeat is found', firstDuplicate([1, 2, 3, 2]), 2);
test('the FIRST repeat wins, not the smallest', firstDuplicate([5, 9, 9, 5]), 9);
test('no repeat gives undefined', firstDuplicate([1, 2, 3]), undefined);
test('an empty array is fine', firstDuplicate([]), undefined);
test('NaN is caught, because a Set compares with SameValueZero', Number.isNaN(firstDuplicate([NaN, NaN])), true);
test('every duplicated value, once each', allDuplicates([1, 2, 2, 3, 3, 3]), [2, 3]);
test('the size comparison answers the yes/no question', [hasDuplicate([1, 2, 3]), hasDuplicate([1, 1])], [false, true]);
test('objects repeat only by identity', firstDuplicate([shared, {}, shared]) === shared, true);
test('so two lookalike objects are not a duplicate', firstDuplicate([{ id: 1 }, { id: 1 }]), undefined);

The definition is the exercise. "First duplicate" almost always means the first position at which a value is seen for the second time, which is why [5, 9, 9, 5] answers 9 — the second 9 arrives at index 2, the second 5 at index 3. Sorting first, or scanning for the smallest repeated value, answers a different question.

One pass with a Set is O(n) and stops as early as possible. The indexOf-based alternative is O(n²) and, as exercise 37 showed, silently misses NaN. The last two assertions are the honest limit of all of this: duplicates are identity-based, so records that are "the same" by id need a key function.

Answers JS Array 39 in the original set.

40 Comparing two arrays

fair

[1, 2] === [1, 2] is false. Explain that, then write an equality check that works — including for nested arrays.

Stuck? Three hints, smallest first
  1. 1. === on two arrays compares references, not contents. Two literals are two objects.
  2. 2. A shallow check compares lengths and then each element with ===.
  3. 3. For nesting, recurse when both elements are arrays.

The answer

const shallowEqual = (a, b) =>
  a.length === b.length && a.every((v, i) => v === b[i]);

function deepEqual(a, b) {
  if (Array.isArray(a) && Array.isArray(b)) {
    return a.length === b.length && a.every((v, i) => deepEqual(v, b[i]));
  }
  return a === b;
}

const one = [1, 2];
const alsoOne = one;

console.log('[1, 2] === [1, 2] :', [1, 2] === [1, 2], '<- two different objects');
console.log('one === alsoOne   :', one === alsoOne, '<- one object, two names');
console.log('shallowEqual      :', shallowEqual([1, 2], [1, 2]));
console.log('order matters     :', shallowEqual([1, 2], [2, 1]));
console.log('--- nesting ---');
console.log('shallow on nested :', shallowEqual([[1]], [[1]]), '<- inner arrays are different objects');
console.log('deep on nested    :', deepEqual([[1]], [[1]]));
console.log('--- the JSON shortcut ---');
console.log('JSON of [undefined]:', JSON.stringify([undefined]));
console.log('JSON of [null]     :', JSON.stringify([null]), '<- identical, so JSON cannot tell them apart');

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.

9 checks

test('two arrays with equal contents are not ===', [1, 2] === [1, 2], false);
test('but an array is === itself', one === alsoOne, true);
test('a shallow check compares contents', shallowEqual([1, 2], [1, 2]), true);
test('order matters', shallowEqual([1, 2], [2, 1]), false);
test('so does length', shallowEqual([1], [1, 2]), false);
test('a shallow check fails on nesting', shallowEqual([[1]], [[1]]), false);
test('a recursive one handles it', deepEqual([[1]], [[1]]), true);
test('the JSON shortcut agrees for plain data', JSON.stringify([1, [2]]) === JSON.stringify([1, [2]]), true);
test('but it cannot tell undefined from null', JSON.stringify([undefined]) === JSON.stringify([null]), true);

Also Basic 27. The angle here is why the naive check fails: arrays are objects, and === on objects asks "the same object?", not "the same contents?". Every literal creates a new one, so [1, 2] === [1, 2] is false and always will be.

JSON.stringify(a) === JSON.stringify(b) is the one-line version, and it is what the test helper on this page uses. It is fine for plain data and wrong in three ways worth remembering: it cannot distinguish undefined from null inside an array, it throws on a cycle, and it turns a Date into a string. Write the recursive version when any of those could be in your data.

Answers JS Array 40 in the original set.

The exercise list follows w3resource's JavaScript exercises (CC BY-NC 4.0), and each exercise names the one it answers. The wording, hints, solutions and explanations here are my own.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.