Emmanuel Akinfulubi

JavaScript: Basic, 1–40

The on-ramp: numbers, text, dates, conditionals and loops.

Exercises 1 to 40 of 150

1 Today’s day and time, formatted

easy

Produce the current day name and the time in a readable form, like Today is Wednesday. Current time is 10 PM : 30 : 38.

Stuck? Three hints, smallest first
  1. 1. getDay() gives 0 to 6 with 0 meaning Sunday, so index into an array of names.
  2. 2. For 12-hour time, hours % 12 gives 0 for both midnight and noon; fall back to 12.
  3. 3. Pass the date in as an argument rather than calling new Date() inside, or you cannot test it.

The answer

const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];

// The date is an argument, which is the only way this can be asserted.
function dayAndTime(date) {
  const day = DAYS[date.getDay()];

  const h24 = date.getHours();
  const suffix = h24 >= 12 ? 'PM' : 'AM';
  const h12 = h24 % 12 === 0 ? 12 : h24 % 12;   // 0 and 12 both mean 12

  const pad = (n) => String(n).padStart(2, '0');

  return `Today is ${day}. Current time is ${h12} ${suffix} : ${pad(date.getMinutes())} : ${pad(date.getSeconds())}`;
}

// A fixed date so the output is predictable. Wednesday, 22:30:38.
const sample = new Date(2026, 8, 2, 22, 30, 38);
console.log(dayAndTime(sample));

// And the real use, which cannot be asserted because it changes every second:
console.log('right now:', dayAndTime(new Date()));

Output


      
5 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 5 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

5 checks

test('a full line', dayAndTime(sample), 'Today is Wednesday. Current time is 10 PM : 30 : 38');
test('midnight reads as 12 AM', dayAndTime(new Date(2026, 8, 2, 0, 5, 0)), 'Today is Wednesday. Current time is 12 AM : 05 : 00');
test('noon reads as 12 PM', dayAndTime(new Date(2026, 8, 2, 12, 0, 0)).includes('12 PM'), true);
test('single digits are padded', dayAndTime(new Date(2026, 8, 2, 9, 4, 5)).endsWith('9 AM : 04 : 05'), true);
test('and the day name is indexed by getDay, where Sunday is 0', [new Date(2026, 8, 6).getDay(), dayAndTime(new Date(2026, 8, 6, 1, 2, 3))], [0, 'Today is Sunday. Current time is 1 AM : 02 : 03']);

The two traps are both in the 12-hour conversion. hours % 12 gives 0 at midnight and at noon, so both need the fallback to 12 — the assertions cover each. And taking the date as an argument instead of calling new Date() inside is what makes any of this testable; a function that reads the clock itself can only ever be eyeballed.

Answers JS Basic 1 in the original set.

2 Report what the page is showing

easy

Report the current window's contents. Adapted: the original calls window.print(), which opens a print dialogue and cannot be tested. This gathers the same information and returns it.

Stuck? Three hints, smallest first
  1. 1. document.title and document.body are the two things worth reporting.
  2. 2. Guard for the case where there is no document at all, so the function also works outside a browser.
  3. 3. typeof document === 'undefined' is the check.

The answer

// The original is one line:
//
//   window.print();
//
// That opens the browser's print dialogue. It cannot be asserted, and running it
// here would interrupt your reading. So instead: gather what is on the page.

function describePage(doc) {
  if (!doc) return 'no document available';

  const title = doc.title || '(untitled)';
  const headings = doc.body ? doc.body.getElementsByTagName('h1').length : 0;
  const links = doc.body ? doc.body.getElementsByTagName('a').length : 0;

  return `"${title}" with ${headings} h1 and ${links} link(s)`;
}

// A stand-in document, so the function can be tested without a browser.
const fakeDoc = {
  title: 'Exercises',
  body: {
    getElementsByTagName(tag) {
      const counts = { h1: 1, a: 4 };
      return { length: counts[tag] ?? 0 };
    },
  },
};

console.log(describePage(fakeDoc));
console.log(describePage(null));

// In a real browser you would call it with the real thing:
const real = typeof document === 'undefined' ? 'not in a browser' : describePage(document);
console.log('real document:', real);

Output


      
5 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 5 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

5 checks

test('a page is described', describePage(fakeDoc), '"Exercises" with 1 h1 and 4 link(s)');
test('no document is handled', describePage(null), 'no document available');
test('a missing title falls back', describePage({ title: '', body: null }), '"(untitled)" with 0 h1 and 0 link(s)');
test('the counts are read from the document, not assumed', describePage({ title: 'Blog', body: { getElementsByTagName: (tag) => ({ length: tag === 'h1' ? 3 : 0 }) } }), '"Blog" with 3 h1 and 0 link(s)');
test('undefined is no document either', describePage(undefined), 'no document available');

Adapted from the original, which is just window.print(). Two habits worth taking from the rewrite: pass the document in rather than reaching for the global, which is what lets a stand-in be substituted in a test; and guard for its absence so the same function runs under Node.

Answers JS Basic 2 in the original set.

3 The same date in four formats

easy

Take a date and produce it as mm-dd-yyyy, mm/dd/yyyy, dd-mm-yyyy and dd/mm/yyyy.

Stuck? Three hints, smallest first
  1. 1. getMonth() is zero-based, so January is 0. Add one.
  2. 2. Pad the month and day to two digits with padStart(2, '0').
  3. 3. Build the parts once, then join them in the four orders rather than repeating the work.

The answer

function formats(date) {
  const pad = (n) => String(n).padStart(2, '0');

  const yyyy = date.getFullYear();
  const mm = pad(date.getMonth() + 1);   // zero-based, so +1
  const dd = pad(date.getDate());        // getDate is the day of the month

  // Build the pieces once, then arrange them.
  return {
    'mm-dd-yyyy': [mm, dd, yyyy].join('-'),
    'mm/dd/yyyy': [mm, dd, yyyy].join('/'),
    'dd-mm-yyyy': [dd, mm, yyyy].join('-'),
    'dd/mm/yyyy': [dd, mm, yyyy].join('/'),
  };
}

const out = formats(new Date(2026, 8, 2));   // 2 September 2026
for (const [label, value] of Object.entries(out)) console.log(label, '->', value);

// A single-digit day and month, to prove the padding.
const padded = formats(new Date(2026, 0, 5));
console.log('5 January:', padded['dd/mm/yyyy']);

Output


      
6 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 6 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

6 checks

test('month first with dashes', out['mm-dd-yyyy'], '09-02-2026');
test('month first with slashes', out['mm/dd/yyyy'], '09/02/2026');
test('day first with dashes', out['dd-mm-yyyy'], '02-09-2026');
test('day first with slashes', out['dd/mm/yyyy'], '02/09/2026');
test('single digits padded', padded['dd/mm/yyyy'], '05/01/2026');
test('all four formats returned', Object.keys(out).length, 4);

getMonth() counting from zero while getDate() counts from one is one of the oldest inconsistencies in the language, and it is the single most common date bug. Note also that 09-02-2026 and 02-09-2026 are the same day: this is exactly why an API should send ISO 2026-09-02 and let the display layer decide.

Answers JS Basic 3 in the original set.

4 Area of a triangle from three sides

easy

Find the area of a triangle whose sides are 5, 6 and 7.

Stuck? Three hints, smallest first
  1. 1. You have no height, so use Heron’s formula.
  2. 2. Half the perimeter is s = (a + b + c) / 2, then the area is Math.sqrt(s * (s-a) * (s-b) * (s-c)).
  3. 3. Not every three numbers make a triangle. Check that each pair sums to more than the third.

The answer

function triangleArea(a, b, c) {
  // Any two sides must together exceed the third, or there is no triangle.
  if (a + b <= c || b + c <= a || a + c <= b) {
    throw new RangeError(`no triangle has sides ${a}, ${b}, ${c}`);
  }
  const s = (a + b + c) / 2;               // half the perimeter
  return Math.sqrt(s * (s - a) * (s - b) * (s - c));
}

const area = triangleArea(5, 6, 7);
console.log('sides 5, 6, 7 ->', area);
console.log('rounded       ->', area.toFixed(4));

// A right triangle, where the answer is easy to check by hand: 3*4/2 = 6.
console.log('sides 3, 4, 5 ->', triangleArea(3, 4, 5));

let refused = '';
try { triangleArea(1, 2, 10); } catch (err) { refused = err.message; }
console.log('refused:', refused);

Output


      
5 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 5 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

5 checks

test('the asked-for triangle', Number(area.toFixed(4)), 14.6969);
test('a right triangle checks out by hand', triangleArea(3, 4, 5), 6);
test('an equilateral one', Number(triangleArea(2, 2, 2).toFixed(4)), 1.7321);
test('an impossible one is refused', refused.includes('no triangle'), true);
test('a degenerate flat one is refused too', (() => { try { triangleArea(1, 2, 3); return false; } catch { return true; } })(), true);

The validation is the part the original skips, and it matters: without it triangleArea(1, 2, 10) returns NaN, which then spreads silently through any arithmetic downstream. Note the last assertion — sides 1, 2 and 3 form a flat line rather than a triangle, and <= rather than < is what catches that case.

Answers JS Basic 4 in the original set.

5 Rotate a string one letter at a time

fair

Rotate the text w3resource to the right by repeatedly taking the last letter and putting it at the front. Show each step.

Stuck? Three hints, smallest first
  1. 1. One rotation is text.slice(-1) + text.slice(0, -1).
  2. 2. After as many rotations as there are letters, you are back where you started.
  3. 3. The original animates this with a timer. Return the sequence instead, so it can be checked.

The answer

function rotateRight(text) {
  if (text.length < 2) return text;
  return text.slice(-1) + text.slice(0, -1);
}

function rotations(text) {
  const steps = [];
  let current = text;
  for (let i = 0; i < text.length; i++) {
    current = rotateRight(current);
    steps.push(current);
  }
  return steps;
}

const steps = rotations('w3resource');
for (const s of steps) console.log(s);

// The original uses setInterval to animate this in the page. Same sequence,
// but returning it means it can be asserted:
//   setInterval(() => { el.textContent = rotateRight(el.textContent); }, 200);

Output


      
6 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 6 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

6 checks

test('one rotation', rotateRight('w3resource'), 'ew3resourc');
test('two rotations', rotateRight(rotateRight('w3resource')), 'cew3resourc'.slice(0, 10));
test('a full cycle returns to the start', steps[steps.length - 1], 'w3resource');
test('there is one step per letter', steps.length, 10);
test('a single character is unchanged', rotateRight('a'), 'a');
test('empty text is unchanged', rotateRight(''), '');

slice(-1) takes the last character and slice(0, -1) takes everything but the last; together they are the whole rotation. The guard for length under 2 is not decoration — without it the empty string would still work by luck, but returning early makes the intent obvious. Rotating a ten-letter word ten times returns it exactly, which is what the third assertion pins down.

Answers JS Basic 5 in the original set.

6 How many days until a given date

fair

Work out the number of days between today and a target date, such as a birthday later in the year.

Stuck? Three hints, smallest first
  1. 1. Subtracting two dates gives milliseconds. Divide by 86,400,000 to get days.
  2. 2. Zero the time on both dates first, or a few hours’ difference will round the answer wrongly.
  3. 3. Use Math.round rather than Math.floor, so daylight-saving shifts of an hour do not lose a day.

The answer

const MS_PER_DAY = 24 * 60 * 60 * 1000;

// Strip the time so we compare whole days, not moments.
function atMidnight(date) {
  return new Date(date.getFullYear(), date.getMonth(), date.getDate());
}

function daysBetween(from, to) {
  const a = atMidnight(from);
  const b = atMidnight(to);
  // Math.round, not floor: a daylight-saving change makes some days 23 hours.
  return Math.round((b - a) / MS_PER_DAY);
}

const today = new Date(2026, 8, 2);          // 2 September 2026
const christmas = new Date(2026, 11, 25);    // 25 December 2026

console.log('days to Christmas:', daysBetween(today, christmas));
console.log('days since new year:', daysBetween(new Date(2026, 0, 1), today));

// Why the time must be stripped: without it, these two would differ.
const morning = new Date(2026, 8, 2, 9, 0);
const evening = new Date(2026, 8, 2, 23, 0);
console.log('same day, different times ->', daysBetween(morning, evening));

Output


      
6 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 6 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

6 checks

test('to Christmas', daysBetween(today, christmas), 114);
test('same day is zero', daysBetween(morning, evening), 0);
test('tomorrow is one', daysBetween(today, new Date(2026, 8, 3)), 1);
test('backwards is negative', daysBetween(christmas, today), -114);
test('across a leap day', daysBetween(new Date(2024, 1, 28), new Date(2024, 2, 1)), 2);
test('across a year boundary', daysBetween(new Date(2026, 11, 31), new Date(2027, 0, 1)), 1);

Two details make this correct rather than nearly correct. Zeroing the time means 9am today to 11pm today is 0 days, not a fraction. And Math.round instead of Math.floor survives daylight saving, where one day of the year is 23 hours long and would otherwise come out as 0.958 and floor to zero. The leap-day assertion confirms February 2024 had a 29th.

Answers JS Basic 6 in the original set.

7 Compare two dates for order

easy

Take two dates and report whether the first is earlier than, later than, or the same as the second.

Stuck? Three hints, smallest first
  1. 1. Comparing dates with < and > works, because they convert to numbers.
  2. 2. But == and === do not work: two Date objects are never the same object.
  3. 3. Compare getTime() values for equality.

The answer

function compareDates(a, b) {
  // Less-than and greater-than work, because a Date converts to a number.
  if (a < b) return 'earlier';
  if (a > b) return 'later';
  return 'the same';
}

const first = new Date(2026, 8, 2);
const second = new Date(2026, 11, 25);
const duplicate = new Date(2026, 8, 2);

console.log(compareDates(first, second));
console.log(compareDates(second, first));
console.log(compareDates(first, duplicate));

// The trap: equality does NOT work on the objects themselves.
console.log('=== on two equal dates :', first === duplicate);   // false
console.log('getTime comparison     :', first.getTime() === duplicate.getTime());

// Which is why sorting needs a subtraction, not a comparison of objects:
const dates = [second, first, new Date(2026, 5, 1)];
const sorted = dates.slice().sort((x, y) => x - y).map((d) => d.getMonth() + 1);
console.log('sorted months:', sorted);

Output


      
6 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 6 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

6 checks

test('earlier', compareDates(first, second), 'earlier');
test('later', compareDates(second, first), 'later');
test('equal by value', compareDates(first, duplicate), 'the same');
test('but not equal by identity', first === duplicate, false);
test('getTime is how you test equality', first.getTime() === duplicate.getTime(), true);
test('sorting by subtraction works', sorted, [6, 9, 12]);

The asymmetry is the lesson. < and > convert both dates to numbers and work fine, but === compares object identity, so two dates for the same instant are never equal. Anyone who has written if (dateA === dateB) and watched it always be false has met this. Compare getTime(), or subtract.

Answers JS Basic 7 in the original set.

8 Add up a shopping list

easy

Given a list of item prices, produce the subtotal, a tax amount and the total. Keep the money arithmetic honest.

Stuck? Three hints, smallest first
  1. 1. Floating-point addition is not exact: 0.1 + 0.2 is not 0.3.
  2. 2. Work in the smallest unit — kobo, or cents — as whole numbers, and divide only for display.
  3. 3. reduce is the natural way to sum an array.

The answer

// The problem, first. This is real and it bites people constantly:
console.log('0.1 + 0.2 =', 0.1 + 0.2);
console.log('is that 0.3?', 0.1 + 0.2 === 0.3);

// So work in whole kobo and divide only at the very end.
function bill(pricesInKobo, taxRate = 0.075) {
  const subtotal = pricesInKobo.reduce((n, p) => n + p, 0);
  const tax = Math.round(subtotal * taxRate);      // round once, to a whole unit
  return {
    subtotal,
    tax,
    total: subtotal + tax,
    display: `₦${((subtotal + tax) / 100).toFixed(2)}`,
  };
}

// Three items at 1200.00, 850.50 and 99.99 naira, written in kobo.
const out = bill([120000, 85050, 9999]);

console.log('subtotal kobo:', out.subtotal);
console.log('tax kobo     :', out.tax);
console.log('total kobo   :', out.total);
console.log('for the eye  :', out.display);

Output


      
6 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 6 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

6 checks

test('floating point really is inexact', 0.1 + 0.2 === 0.3, false);
test('subtotal is exact in kobo', out.subtotal, 215049);
test('tax rounded to a whole kobo', out.tax, 16129);
test('total', out.total, 231178);
test('displayed once, at the end', out.display, '₦2311.78');
test('an empty list totals zero', bill([]).total, 0);

The first two lines are not a digression. 0.1 + 0.2 === 0.3 is false in every language that uses binary floating point, which is nearly all of them. Adding prices as decimals accumulates that error until a total is a kobo out and nobody can explain why. Store money as whole minor units, do all arithmetic on integers, and divide once for display.

Answers JS Basic 8 in the original set.

9 Is it a leap year?

easy

Decide whether a given year is a leap year.

Stuck? Three hints, smallest first
  1. 1. Divisible by 4 is the start, but not the whole rule.
  2. 2. Century years are not leap years unless they are divisible by 400.
  3. 3. 1900 was not a leap year; 2000 was. Any answer that gets both wrong is the naive version.

The answer

function isLeapYear(year) {
  // Divisible by 4, except centuries, except centuries divisible by 400.
  return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
}

// The naive version, for comparison.
function naive(year) {
  return year % 4 === 0;
}

for (const y of [2024, 2025, 1900, 2000, 2100]) {
  console.log(y, isLeapYear(y) ? 'is a leap year' : 'is not');
}

console.log('where the naive version goes wrong:');
console.log('  1900 correct:', isLeapYear(1900), ' naive:', naive(1900));
console.log('  2000 correct:', isLeapYear(2000), ' naive:', naive(2000));

// A cross-check against the calendar itself: February has 29 days in a leap year.
function februaryDays(year) {
  return new Date(year, 2, 0).getDate();   // day 0 of March is the last of February
}

Output


      
8 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 8 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

8 checks

test('2024 is a leap year', isLeapYear(2024), true);
test('2025 is not', isLeapYear(2025), false);
test('1900 is not, despite dividing by 4', isLeapYear(1900), false);
test('2000 is, because it divides by 400', isLeapYear(2000), true);
test('2100 is not', isLeapYear(2100), false);
test('the naive version gets 1900 wrong', naive(1900), true);
test('the calendar agrees for 2024', februaryDays(2024), 29);
test('and for 1900', februaryDays(1900), 28);

The februaryDays cross-check is worth copying as a technique: rather than trusting the formula, ask the platform. new Date(year, 2, 0) means "day zero of March", which JavaScript resolves to the last day of February. Two independent routes to the same answer is much stronger evidence than one.

Answers JS Basic 9 in the original set.

10 Find a date’s day of the week

easy

Given a date, produce the name of its day. Handle the numbering correctly.

Stuck? Three hints, smallest first
  1. 1. getDay() returns 0 to 6 with Sunday as 0, which is not what most people expect.
  2. 2. getDate() is a completely different thing: the day of the month.
  3. 3. Mixing up the two is a classic bug. Name your variables so it cannot happen.

The answer

const DAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];

function dayName(date) {
  return DAY_NAMES[date.getDay()];      // getDay: 0..6, Sunday first
}

// Whereas getDate is the day of the MONTH. Different question entirely.
function describe(date) {
  return `${dayName(date)} the ${date.getDate()}`;
}

const dates = [
  new Date(2026, 8, 2),     // Wednesday 2 September 2026
  new Date(2026, 0, 1),     // Thursday 1 January 2026
  new Date(2000, 0, 1),     // Saturday 1 January 2000
];

for (const d of dates) console.log(describe(d));

// Weekday or weekend, using the numbering directly.
function isWeekend(date) {
  const n = date.getDay();
  return n === 0 || n === 6;
}

console.log('2 Sep 2026 a weekend?', isWeekend(dates[0]));
console.log('5 Sep 2026 a weekend?', isWeekend(new Date(2026, 8, 5)));

Output


      
7 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 7 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

7 checks

test('a known Wednesday', dayName(dates[0]), 'Wednesday');
test('a known Thursday', dayName(dates[1]), 'Thursday');
test('the millennium was a Saturday', dayName(dates[2]), 'Saturday');
test('getDay and getDate are different', dates[0].getDay() === dates[0].getDate(), false);
test('describe uses both correctly', describe(dates[1]), 'Thursday the 1');
test('Saturday counts as the weekend', isWeekend(new Date(2026, 8, 5)), true);
test('Wednesday does not', isWeekend(dates[0]), false);

getDay() and getDate() are one letter apart and answer entirely different questions — day of the week versus day of the month. The fourth assertion exists purely to make that concrete. Note also that getDay() puts Sunday at 0, whereas the ISO standard puts Monday at 1, so any code sharing weekday numbers with a backend needs to agree which convention it is using.

Answers JS Basic 10 in the original set.

11 Convert between Celsius and Fahrenheit

easy

Convert a temperature from Celsius to Fahrenheit and back. Show that a round trip returns the original value.

Stuck? Three hints, smallest first
  1. 1. Fahrenheit is c * 9 / 5 + 32; Celsius is (f - 32) * 5 / 9.
  2. 2. A round trip may not be exactly equal because of floating point. Compare with a small tolerance.
  3. 3. -40 is the one temperature where both scales read the same. It makes a good test.

The answer

const toFahrenheit = (c) => (c * 9) / 5 + 32;
const toCelsius = (f) => ((f - 32) * 5) / 9;

for (const c of [-40, 0, 37, 100]) {
  console.log(`${c}°C = ${toFahrenheit(c)}°F`);
}

// A round trip should return the original, but floating point means "should"
// needs a tolerance rather than an equals sign.
const close = (a, b, tolerance = 1e-9) => Math.abs(a - b) < tolerance;

const roundTrip = toCelsius(toFahrenheit(36.6));
console.log('36.6 there and back:', roundTrip);
console.log('exactly equal?', roundTrip === 36.6);
console.log('close enough?  ', close(roundTrip, 36.6));

Output


      
6 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 6 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

6 checks

test('freezing', toFahrenheit(0), 32);
test('boiling', toFahrenheit(100), 212);
test('body temperature', Number(toFahrenheit(37).toFixed(1)), 98.6);
test('the scales meet at -40', toFahrenheit(-40), -40);
test('back the other way', toCelsius(212), 100);
test('a round trip is close, not exact', close(roundTrip, 36.6), true);

The round-trip assertion is the interesting one. toCelsius(toFahrenheit(36.6)) is not exactly 36.6, because dividing by 5 and 9 cannot be done exactly in binary. That is why the close helper exists: for any calculation involving division, compare within a tolerance rather than with ===.

Answers JS Basic 11 in the original set.

12 Get a random number in a range

easy

Produce a random whole number between two bounds, inclusive of both. Show that the bounds really are reachable.

Stuck? Three hints, smallest first
  1. 1. Math.random() gives 0 up to but never including 1.
  2. 2. For an inclusive range you need Math.floor(Math.random() * (max - min + 1)) + min. The + 1 is what includes the top.
  3. 3. You cannot assert a random value, so assert the range and the distribution instead.

The answer

function randomBetween(min, max) {
  if (min > max) throw new RangeError(`min ${min} is above max ${max}`);
  // The + 1 is what makes max reachable. Without it, max never appears.
  return Math.floor(Math.random() * (max - min + 1)) + min;
}

// The common wrong version, for comparison.
function exclusiveOfMax(min, max) {
  return Math.floor(Math.random() * (max - min)) + min;
}

const rolls = Array.from({ length: 600 }, () => randomBetween(1, 6));
const seen = new Set(rolls);

console.log('first ten rolls:', rolls.slice(0, 10).join(', '));
console.log('distinct values:', [...seen].sort().join(', '));

// Prove the wrong version never reaches the top of the range.
const wrong = new Set(Array.from({ length: 600 }, () => exclusiveOfMax(1, 6)));
console.log('the buggy version produced:', [...wrong].sort().join(', '));

Output


      
6 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 6 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

6 checks

test('every roll is within the range', rolls.every((n) => n >= 1 && n <= 6), true);
test('every roll is a whole number', rolls.every(Number.isInteger), true);
test('all six faces appeared', seen.size, 6);
test('the top of the range is reachable', seen.has(6), true);
test('the buggy version never reaches 6', wrong.has(6), false);
test('min above max is refused', (() => { try { randomBetween(9, 2); return false; } catch { return true; } })(), true);

You cannot assert a random number, so assert the properties instead: every value in range, every value a whole number, and all six faces appearing across 600 rolls. The last one is the real content — the version without + 1 looks right and silently never returns the maximum, which is a bug you will not notice by eye.

Answers JS Basic 12 in the original set.

13 Reverse a number

easy

Take a whole number and produce it with its digits reversed. Handle negatives sensibly.

Stuck? Three hints, smallest first
  1. 1. Turning it into text, reversing, and turning it back is the readable way.
  2. 2. A negative sign needs handling separately, or it ends up at the wrong end.
  3. 3. Reversing a number ending in zero loses that zero, because 021 is just 21. That is arithmetic, not a bug.

The answer

function reverseNumber(n) {
  const sign = n < 0 ? -1 : 1;
  const digits = String(Math.abs(n)).split('').reverse().join('');
  return sign * Number(digits);
}

// The arithmetic version, with no text conversion at all.
function reverseByMaths(n) {
  const sign = n < 0 ? -1 : 1;
  let left = Math.abs(n);
  let out = 0;
  while (left > 0) {
    out = out * 10 + (left % 10);
    left = Math.floor(left / 10);
  }
  return sign * out;
}

for (const n of [32243, -1234, 1200, 7, 0]) {
  console.log(n, '->', reverseNumber(n), '| by maths:', reverseByMaths(n));
}

Output


      
7 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 7 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

7 checks

test('a plain number', reverseNumber(32243), 34223);
test('a negative keeps its sign', reverseNumber(-1234), -4321);
test('trailing zeros vanish, correctly', reverseNumber(1200), 21);
test('a single digit is itself', reverseNumber(7), 7);
test('zero is zero', reverseNumber(0), 0);
test('both methods agree', reverseByMaths(32243), reverseNumber(32243));
test('and agree on negatives', reverseByMaths(-1234), reverseNumber(-1234));

1200 reversing to 21 rather than 0021 is correct: leading zeros do not exist in a number. If you needed them kept, the answer is text, not a number, and that is a different function. The two implementations are shown because they agree on every case, which is a stronger check than either one alone.

Answers JS Basic 13 in the original set.

14 Check for a palindrome

fair

Decide whether a piece of text reads the same backwards, ignoring case, spaces and punctuation.

Stuck? Three hints, smallest first
  1. 1. Normalise first: lowercase it and strip everything that is not a letter or digit.
  2. 2. replace(/[^a-z0-9]/g, '') after lowercasing does the stripping.
  3. 3. Comparing against the reversed copy is the simplest check; two pointers is the version without the extra allocation.

The answer

function normalise(text) {
  return text.toLowerCase().replace(/[^a-z0-9]/g, '');
}

function isPalindrome(text) {
  const clean = normalise(text);
  return clean === clean.split('').reverse().join('');
}

// The two-pointer version: no reversed copy, so no extra allocation.
function isPalindromeTwoPointer(text) {
  const clean = normalise(text);
  let left = 0;
  let right = clean.length - 1;
  while (left < right) {
    if (clean[left] !== clean[right]) return false;
    left++;
    right--;
  }
  return true;
}

const samples = [
  'madam',
  'A man, a plan, a canal: Panama',
  'No lemon, no melon',
  'hello',
  '12321',
];

for (const s of samples) {
  console.log(JSON.stringify(s), '->', isPalindrome(s));
}

Output


      
7 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 7 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

7 checks

test('a simple one', isPalindrome('madam'), true);
test('punctuation and case ignored', isPalindrome('A man, a plan, a canal: Panama'), true);
test('spaces ignored', isPalindrome('No lemon, no melon'), true);
test('a non-palindrome', isPalindrome('hello'), false);
test('digits work too', isPalindrome('12321'), true);
test('empty text counts as one', isPalindrome(''), true);
test('the two-pointer version agrees everywhere', samples.every((s) => isPalindrome(s) === isPalindromeTwoPointer(s)), true);

Almost all the work is in normalise, and that is the real lesson: most string problems become easy once the input is in a known shape. The two-pointer version does the same job walking inward from both ends, which avoids building a reversed copy — irrelevant for a word, worth having for a very long string.

Answers JS Basic 14 in the original set.

15 Count vowels in a string

easy

Count how many vowels appear in a piece of text.

Stuck? Three hints, smallest first
  1. 1. Decide first whether y counts. Say so in a comment either way.
  2. 2. A Set of vowels makes the check clear and fast.
  3. 3. A regular expression with the g flag plus match is the one-liner, but remember match returns null when nothing matches.

The answer

const VOWELS = new Set(['a', 'e', 'i', 'o', 'u']);   // y is not counted here

function countVowels(text) {
  let n = 0;
  for (const ch of text.toLowerCase()) {
    if (VOWELS.has(ch)) n++;
  }
  return n;
}

// The one-liner. Note the ?? 0: match returns null, not [], when nothing hits.
function countVowelsRegex(text) {
  return (text.match(/[aeiou]/gi) ?? []).length;
}

const samples = ['w3resource', 'JavaScript is fun', 'rhythm', ''];

for (const s of samples) {
  console.log(JSON.stringify(s), '->', countVowels(s));
}

// The trap in the one-liner, made visible:
console.log("'rhythm'.match(/[aeiou]/gi) is", 'rhythm'.match(/[aeiou]/gi));

Output


      
6 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 6 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

6 checks

test('a mixed word', countVowels('w3resource'), 4);
test('a sentence', countVowels('JavaScript is fun'), 5);
test('no vowels at all', countVowels('rhythm'), 0);
test('empty text', countVowels(''), 0);
test('case does not matter', countVowels('AEIOU'), 5);
test('both methods agree', samples.every((s) => countVowels(s) === countVowelsRegex(s)), true);

The ?? 0 — or ?? [] here — is the detail worth remembering. String.prototype.match with the g flag returns null rather than an empty array when there is no match, so text.match(...).length throws on exactly the input you would least expect to be a problem. The rhythm line prints that null so you see it.

Answers JS Basic 15 in the original set.

16 Is a number prime?

fair

Decide whether a whole number is prime, without checking every number below it.

Stuck? Three hints, smallest first
  1. 1. You only need to test divisors up to the square root. A factor above it pairs with one below it.
  2. 2. Handle 0, 1 and 2 before the loop; 1 is not prime and 2 is the only even prime.
  3. 3. After 2, skip all the even numbers and step by 2.

The answer

function isPrime(n) {
  if (!Number.isInteger(n) || n < 2) return false;   // 0, 1 and negatives are not prime
  if (n === 2) return true;
  if (n % 2 === 0) return false;                     // every other even number is out

  // Only up to the square root: any factor above it has a partner below it.
  const limit = Math.sqrt(n);
  for (let d = 3; d <= limit; d += 2) {
    if (n % d === 0) return false;
  }
  return true;
}

const upTo30 = [];
for (let n = 0; n <= 30; n++) if (isPrime(n)) upTo30.push(n);

console.log('primes to 30:', upTo30.join(', '));
console.log('97 prime?', isPrime(97));
console.log('91 prime?', isPrime(91), '(7 x 13)');

// Why the square root is enough: 91's factors are 7 and 13, and 7 < sqrt(91) < 13.
console.log('sqrt(91) is about', Math.sqrt(91).toFixed(2));

Output


      
8 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 8 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

8 checks

test('the primes below 30', upTo30, [2, 3, 5, 7, 11, 13, 17, 19, 23, 29]);
test('1 is not prime', isPrime(1), false);
test('0 is not prime', isPrime(0), false);
test('2 is prime', isPrime(2), true);
test('a larger prime', isPrime(97), true);
test('91 is not, despite looking it', isPrime(91), false);
test('negatives are not prime', isPrime(-7), false);
test('fractions are not prime', isPrime(2.5), false);

Stopping at the square root is the whole optimisation, and the reason is worth internalising: if n = a * b then one of a and b is at or below √n. For 91 that is 7, comfortably below 9.54, so the loop finds it. Checking to n instead would do the same work for no benefit.

Answers JS Basic 16 in the original set.

17 Sort three numbers

easy

Take three numbers and return them in ascending order. Then note what goes wrong if you sort numbers the obvious way.

Stuck? Three hints, smallest first
  1. 1. [3, 1, 2].sort() works. [10, 9].sort() does not.
  2. 2. sort() with no argument compares values as text, so 10 sorts before 9.
  3. 3. Always pass a comparator for numbers: sort((a, b) => a - b).

The answer

function sortThree(a, b, c) {
  return [a, b, c].sort((x, y) => x - y);     // the comparator is not optional
}

console.log(sortThree(3, 1, 2));
console.log(sortThree(10, 9, 100));

// The default sort compares as text, which is almost never what you want:
const numbers = [10, 9, 100, 1];
console.log('with a comparator:', numbers.slice().sort((a, b) => a - b));
console.log('without one      :', numbers.slice().sort());

// And sort mutates, which surprises people. slice() above is what protects it.
const original = [3, 1, 2];
const sorted = original.sort((a, b) => a - b);
console.log('same array?', original === sorted);

Output


      
6 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 6 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

6 checks

test('three in order', sortThree(3, 1, 2), [1, 2, 3]);
test('two digits sort correctly with a comparator', sortThree(10, 9, 100), [9, 10, 100]);
test('the default sort gets it wrong', [10, 9, 100, 1].sort(), [1, 10, 100, 9]);
test('negatives', sortThree(0, -5, 5), [-5, 0, 5]);
test('duplicates are kept', sortThree(2, 2, 1), [1, 2, 2]);
test('sort returns the same array it mutated', original === sorted, true);

Two things bite here. sort() with no comparator converts everything to text, so [10, 9] becomes [10, 9] because "10" < "9" — the third assertion pins that down. And sort changes the array in place and returns the very same array, which is why the examples above use slice() first. Both are decades-old design choices you simply have to know.

Answers JS Basic 17 in the original set.

18 Largest of three numbers

easy

Return the largest of three numbers. Then show the version that works for any count.

Stuck? Three hints, smallest first
  1. 1. Math.max(a, b, c) does it directly.
  2. 2. For an array, spread it: Math.max(...values).
  3. 3. Math.max() with no arguments returns -Infinity, which is correct but rarely what a caller wants.

The answer

const largestOfThree = (a, b, c) => Math.max(a, b, c);

// For any number of values, spread the array in.
function largest(values) {
  if (values.length === 0) throw new RangeError('largest needs at least one value');
  return Math.max(...values);
}

console.log(largestOfThree(3, 9, 5));
console.log(largest([3, 9, 5, 22, 7]));

// The edge nobody expects:
console.log('Math.max() with nothing:', Math.max());

// And the limit of spreading: a very large array can exceed the argument limit,
// so reduce is the safe choice for those.
const bigSafe = [5, 1, 9, 3].reduce((best, n) => (n > best ? n : best), -Infinity);
console.log('via reduce:', bigSafe);

Output


      
8 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 8 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

8 checks

test('three numbers', largestOfThree(3, 9, 5), 9);
test('the largest is first', largestOfThree(9, 3, 5), 9);
test('negatives', largestOfThree(-3, -9, -5), -3);
test('any length', largest([3, 9, 5, 22, 7]), 22);
test('a single value', largest([42]), 42);
test('empty is refused', (() => { try { largest([]); return false; } catch { return true; } })(), true);
test('Math.max with no arguments is -Infinity', Math.max(), -Infinity);
test('reduce agrees', bigSafe, 9);

Math.max() returning -Infinity for no arguments is mathematically right — it is the identity for maximum — and is almost always a bug in practice, because it means an empty array silently produces a number instead of an error. Hence the explicit guard. The reduce version also matters for genuinely large arrays, where spreading hundreds of thousands of arguments can exceed the engine's limit.

Answers JS Basic 18 in the original set.

19 FizzBuzz, and why it is asked

easy

For the numbers 1 to 20, produce Fizz for multiples of 3, Buzz for multiples of 5, FizzBuzz for multiples of both, and the number otherwise.

Stuck? Three hints, smallest first
  1. 1. Test for both first. Testing for 3 first means 15 never reaches the both-case.
  2. 2. Building the word by appending avoids the ordering problem entirely.
  3. 3. Return an array rather than logging, so it can be asserted.

The answer

// The version where order matters, and the both-case must come first.
function fizzbuzzOrdered(n) {
  if (n % 15 === 0) return 'FizzBuzz';
  if (n % 3 === 0) return 'Fizz';
  if (n % 5 === 0) return 'Buzz';
  return String(n);
}

// The version where order cannot go wrong, because the word is built up.
function fizzbuzz(n) {
  let out = '';
  if (n % 3 === 0) out += 'Fizz';
  if (n % 5 === 0) out += 'Buzz';
  return out || String(n);
}

const upTo20 = Array.from({ length: 20 }, (_, i) => fizzbuzz(i + 1));
console.log(upTo20.join(' '));

// The classic mistake: checking 3 before 15, so 15 comes out as just Fizz.
function buggy(n) {
  if (n % 3 === 0) return 'Fizz';
  if (n % 15 === 0) return 'FizzBuzz';
  return String(n);
}
console.log('buggy version on 15:', buggy(15));

Output


      
7 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 7 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

7 checks

test('3 is Fizz', fizzbuzz(3), 'Fizz');
test('5 is Buzz', fizzbuzz(5), 'Buzz');
test('15 is FizzBuzz', fizzbuzz(15), 'FizzBuzz');
test('1 is just the number', fizzbuzz(1), '1');
test('the first twenty', upTo20.slice(0, 5), ['1', '2', 'Fizz', '4', 'Buzz']);
test('both versions agree everywhere', Array.from({ length: 100 }, (_, i) => i + 1).every((n) => fizzbuzz(n) === fizzbuzzOrdered(n)), true);
test('the buggy ordering loses FizzBuzz', buggy(15), 'Fizz');

The second version is the better answer and not because it is shorter. Building the word by appending means there is no ordering to get wrong — the 15 case falls out of the other two rather than needing its own branch. Whenever a branch exists only to catch a combination of the other branches, that is usually a sign the logic can be restructured.

Answers JS Basic 19 in the original set.

20 Multiplication table for a number

easy

Produce the multiplication table for a given number, from 1 up to a limit. Return it in a form that can be checked as well as printed.

Stuck? Three hints, smallest first
  1. 1. Build an array of rows, then join it for display. Do not build a single string.
  2. 2. padStart lines the columns up when the numbers vary in width.
  3. 3. Returning structured data and formatting separately is what makes it testable.

The answer

// Return the data...
function table(n, upTo = 10) {
  return Array.from({ length: upTo }, (_, i) => ({
    multiplier: i + 1,
    product: n * (i + 1),
  }));
}

// ...and format it separately.
function formatTable(n, upTo = 10) {
  return table(n, upTo)
    .map((row) => `${String(n).padStart(2)} x ${String(row.multiplier).padStart(2)} = ${String(row.product).padStart(3)}`)
    .join('\n');
}

console.log(formatTable(7));
console.log('');
console.log(formatTable(12, 5));

const seven = table(7);
const products = seven.map((r) => r.product);

Output


      
7 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 7 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

7 checks

test('ten rows by default', seven.length, 10);
test('the products', products, [7, 14, 21, 28, 35, 42, 49, 56, 63, 70]);
test('the first row', seven[0], { multiplier: 1, product: 7 });
test('the last row', seven[9], { multiplier: 10, product: 70 });
test('a custom limit', table(12, 5).length, 5);
test('formatting lines up the columns', formatTable(7, 1), ' 7 x  1 =   7');
test('a table of zero is all zeros', table(0, 3).map((r) => r.product), [0, 0, 0]);

Splitting table from formatTable is the habit worth taking from this one. A function that builds a formatted string can only be tested by comparing strings, which breaks the moment you change the spacing. A function that returns data can be asserted on meaning, and the formatting becomes a separate, trivial concern.

Answers JS Basic 20 in the original set.

21 Swap two variables without a third

easy

Exchange the values of two variables. Do it with destructuring, and also with arithmetic, and say which you would actually ship.

Stuck? Three hints, smallest first
  1. 1. Destructuring does it in one line: [a, b] = [b, a].
  2. 2. The arithmetic trick, a = a + b then b = a - b then a = a - b, needs no extra variable but only works for numbers.
  3. 3. The arithmetic version can lose precision on very large numbers. That is why nobody ships it.

The answer

// The one you should write.
function swapModern(a, b) {
  [a, b] = [b, a];
  return [a, b];
}

// The interview-trivia version. Numbers only, and it can overflow.
function swapArithmetic(a, b) {
  a = a + b;
  b = a - b;
  a = a - b;
  return [a, b];
}

console.log('destructuring:', swapModern(1, 2));
console.log('arithmetic   :', swapArithmetic(1, 2));

// Works for anything, because it does not do maths:
console.log('text  :', swapModern('first', 'second'));
console.log('mixed :', swapModern(1, 'one'));

// Where the arithmetic version quietly goes wrong:
const big = Number.MAX_SAFE_INTEGER;
const [x, y] = swapArithmetic(big, 2);
console.log('big numbers swapped:', x, y, '-- expected 2 and', big);

Output


      
6 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 6 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

6 checks

test('numbers swap', swapModern(1, 2), [2, 1]);
test('text swaps', swapModern('first', 'second'), ['second', 'first']);
test('mixed types swap', swapModern(1, 'one'), ['one', 1]);
test('arithmetic works for small numbers', swapArithmetic(1, 2), [2, 1]);
test('but loses precision at the safe-integer limit', y === big, false);
test('destructuring handles the same case fine', swapModern(big, 2)[1], big);

The last two assertions are the argument. Beyond Number.MAX_SAFE_INTEGER, a + b cannot be represented exactly, so the arithmetic swap returns a number that is close but wrong — silently. Destructuring moves references and cannot have that problem, works for any type, and reads as what it means.

Answers JS Basic 21 in the original set.

22 Sum and product of an array

easy

Given a list of numbers, produce their sum and their product. Decide what each should be for an empty list and justify it.

Stuck? Three hints, smallest first
  1. 1. reduce takes a starting value. For a sum that is 0; for a product it is 1.
  2. 2. Those starting values are the identity for each operation: adding 0 or multiplying by 1 changes nothing.
  3. 3. Leave the starting value out and reduce throws on an empty array. Try it.

The answer

// The starting value is the identity: 0 for addition, 1 for multiplication.
const sum = (values) => values.reduce((total, n) => total + n, 0);
const product = (values) => values.reduce((total, n) => total * n, 1);

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

console.log('sum    :', sum(nums));
console.log('product:', product(nums));
console.log('empty sum    :', sum([]));
console.log('empty product:', product([]));

// Without a starting value, reduce throws on an empty array:
let threw = false;
try {
  [].reduce((a, b) => a + b);
} catch (err) {
  threw = true;
  console.log('no starting value on []:', err.constructor.name);
}

Output


      
7 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 7 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

7 checks

test('sum of 1..5', sum(nums), 15);
test('product of 1..5', product(nums), 120);
test('empty sum is 0', sum([]), 0);
test('empty product is 1', product([]), 1);
test('a single value', sum([7]), 7);
test('a zero in the list makes the product zero', product([1, 2, 0, 4]), 0);
test('reduce with no starting value throws on empty', threw, true);

An empty product being 1 looks odd and is right. The starting value has to be the identity for the operation, the value that changes nothing, so that product([2, 3]) equals 1 * 2 * 3. Use 0 there and every product would be zero. And always pass the starting value: without it, reduce throws on an empty array, which is a crash waiting for the day your list happens to be empty.

Answers JS Basic 22 in the original set.

23 Longest word in a sentence

easy

Find the longest word in a sentence. Decide what to do about punctuation and about ties.

Stuck? Three hints, smallest first
  1. 1. Splitting on a single space breaks on double spaces and on newlines. Split on /\s+/ instead.
  2. 2. Strip punctuation before measuring, or "sentence." counts one character too many.
  3. 3. On a tie, reduce with > keeps the first; with >= it keeps the last. Pick deliberately.

The answer

function words(sentence) {
  return sentence
    .split(/\s+/)                      // any run of whitespace, not just one space
    .map((w) => w.replace(/[^\p{L}\p{N}'-]/gu, ''))   // drop punctuation, keep hyphens
    .filter((w) => w.length > 0);
}

// > keeps the FIRST of equal-length words. >= would keep the last.
function longestWord(sentence) {
  return words(sentence).reduce((best, w) => (w.length > best.length ? w : best), '');
}

function allLongest(sentence) {
  const list = words(sentence);
  const max = Math.max(0, ...list.map((w) => w.length));
  return list.filter((w) => w.length === max);
}

const s = 'Web  development  tutorial, and  well-being.';

console.log('words        :', words(s));
console.log('longest      :', longestWord(s));
console.log('all longest  :', allLongest('one three four'));

Output


      
8 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 8 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

8 checks

test('longest word found', longestWord(s), 'development');
test('punctuation stripped', words('tutorial,')[0], 'tutorial');
test('hyphenated words survive', words('well-being.')[0], 'well-being');
test('double spaces do not create empty words', words('a  b').length, 2);
test('a tie keeps the first', longestWord('one three four'), 'three');
test('or report every tie', allLongest('one three four'), ['three']);
test('a real tie', allLongest('cat dog bird'), ['bird']);
test('empty input', longestWord(''), '');

Three decisions hide in this small problem, and the original states none of them. Splitting on /\s+/ rather than ' ' survives double spaces and newlines. Stripping punctuation stops "tutorial," measuring 9. And on a tie, > versus >= silently changes which word you get — so allLongest is often the more honest answer.

Answers JS Basic 23 in the original set.

24 Capitalise the first letter of each word

easy

Turn a sentence into title case, with each word starting in upper case. Handle multiple spaces and already-capitalised text.

Stuck? Three hints, smallest first
  1. 1. word[0].toUpperCase() + word.slice(1) capitalises one word.
  2. 2. An empty word would make word[0] undefined, so guard for it.
  3. 3. Lowercase the rest of each word, or "hELLO" stays odd.

The answer

function capitalise(word) {
  if (word.length === 0) return word;         // guard, or word[0] is undefined
  return word[0].toUpperCase() + word.slice(1).toLowerCase();
}

function titleCase(sentence) {
  return sentence.split(' ').map(capitalise).join(' ');
}

// The regex version, which handles any whitespace and keeps it intact.
function titleCaseRegex(sentence) {
  return sentence.replace(/\S+/g, capitalise);
}

console.log(titleCase('the quick brown fox'));
console.log(titleCase('hELLO wORLD'));
console.log(titleCaseRegex('spaced   out   words'));

Output


      
7 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 7 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

7 checks

test('a plain sentence', titleCase('the quick brown fox'), 'The Quick Brown Fox');
test('odd casing is normalised', titleCase('hELLO wORLD'), 'Hello World');
test('one word', capitalise('lagos'), 'Lagos');
test('empty text does not throw', capitalise(''), '');
test('the regex version keeps the spacing', titleCaseRegex('spaced   out'), 'Spaced   Out');
test('the split version collapses nothing but needs even spacing', titleCase('a b'), 'A B');
test('already correct stays correct', titleCase('Lagos Abuja'), 'Lagos Abuja');

capitalise('') is the case that matters: without the length guard, word[0] is undefined and .toUpperCase() throws. That happens as soon as your input has a double space, because split(' ') produces an empty string between them. The regex version sidesteps it by only matching runs of non-space, which also preserves the original spacing.

Answers JS Basic 24 in the original set.

25 Count how often each character appears

fair

Given a piece of text, count how many times each character occurs. Return something a caller can inspect.

Stuck? Three hints, smallest first
  1. 1. A plain object works, but keys like constructor and toString already exist on it.
  2. 2. new Map() or Object.create(null) avoids that problem entirely.
  3. 3. Map also keeps insertion order and lets you use non-string keys.

The answer

// A Map has no inherited keys, so nothing can collide.
function countChars(text) {
  const counts = new Map();
  for (const ch of text) {
    counts.set(ch, (counts.get(ch) ?? 0) + 1);
  }
  return counts;
}

// The plain-object version, and the trap it carries.
function countCharsObject(text) {
  const counts = {};
  for (const ch of text) counts[ch] = (counts[ch] ?? 0) + 1;
  return counts;
}

const counts = countChars('hello world');
console.log([...counts.entries()]);

// Why a bare object is risky: these keys already exist on Object.prototype.
const bare = {};
console.log('bare.constructor is', typeof bare.constructor);
console.log('so "constructor" in {} is', 'constructor' in bare);

// Object.create(null) has no prototype, so it behaves like a Map here.
const safe = Object.create(null);
console.log('safe.constructor is', safe.constructor);

const most = [...counts.entries()].reduce((best, e) => (e[1] > best[1] ? e : best));

Output


      
8 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 8 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

8 checks

test('l appears three times', counts.get('l'), 3);
test('o appears twice', counts.get('o'), 2);
test('the space is counted', counts.get(' '), 1);
test('an absent character is undefined', counts.get('z'), undefined);
test('distinct characters', counts.size, 8);
test('the most common one', most, ['l', 3]);
test('the object version agrees on real characters', countCharsObject('hello').l, 2);
test('but a bare object already has a constructor key', 'constructor' in {}, true);

The last assertion is the reason to reach for Map. With a plain object, 'constructor' in counts is true before you have counted anything, because it is inherited from Object.prototype. For character counting you would probably never notice; for counting user-supplied words you eventually will. Map and Object.create(null) both start genuinely empty.

Answers JS Basic 25 in the original set.

26 Remove duplicates from a list

easy

Given a list with repeats, produce one with each value appearing once, keeping the original order. Note what "the same" means here.

Stuck? Three hints, smallest first
  1. 1. [...new Set(values)] is the whole answer for primitives.
  2. 2. Set compares by identity, so two objects with identical contents both survive.
  3. 3. Set treats NaN as equal to itself, which === does not.

The answer

const unique = (values) => [...new Set(values)];

console.log(unique([1, 2, 2, 3, 1, 4]));
console.log(unique(['a', 'b', 'a']));

// Order is preserved: a Set iterates in insertion order.
console.log('order kept:', unique([3, 1, 3, 2]));

// Objects are compared by identity, so look-alikes are not duplicates.
const shared = { id: 1 };
console.log('same object twice :', unique([shared, shared]).length);
console.log('two equal objects :', unique([{ id: 1 }, { id: 1 }]).length);

// To dedupe objects by content, pick a key.
function uniqueBy(values, keyOf) {
  const seen = new Map();
  for (const v of values) if (!seen.has(keyOf(v))) seen.set(keyOf(v), v);
  return [...seen.values()];
}

console.log('deduped by id:', uniqueBy([{ id: 1 }, { id: 1 }, { id: 2 }], (o) => o.id));

// The NaN oddity: Set considers it equal to itself, === does not.
console.log('NaN === NaN     :', NaN === NaN);
console.log('unique([NaN,NaN]):', unique([NaN, NaN]).length);

Output


      
7 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 7 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

7 checks

test('numbers deduped', unique([1, 2, 2, 3, 1, 4]), [1, 2, 3, 4]);
test('order is preserved', unique([3, 1, 3, 2]), [3, 1, 2]);
test('the same object twice collapses', unique([shared, shared]).length, 1);
test('two equal objects do not', unique([{ id: 1 }, { id: 1 }]).length, 2);
test('but they do when you name the key', uniqueBy([{ id: 1 }, { id: 1 }, { id: 2 }], (o) => o.id).length, 2);
test('Set treats NaN as one value', unique([NaN, NaN]).length, 1);
test('even though === disagrees', NaN === NaN, false);

Two things worth carrying away. Set deduplicates by identity, so [{id:1}, {id:1}] keeps both — if you meant "same contents" you have to say which field decides, as uniqueBy does. And Set uses SameValueZero, under which NaN equals itself; that is the one place its notion of equality differs from ===.

Answers JS Basic 26 in the original set.

27 Check whether two arrays hold the same values

fair

Decide whether two arrays contain the same values. Handle order-sensitive and order-insensitive comparison, and explain why === is no use.

Stuck? Three hints, smallest first
  1. 1. Two arrays are never === equal, even with identical contents. They are different objects.
  2. 2. For order-sensitive comparison, check the lengths then compare index by index.
  3. 3. For order-insensitive, sort copies first, or count occurrences with a Map.

The answer

// Order matters.
function sameOrder(a, b) {
  if (a.length !== b.length) return false;
  return a.every((v, i) => v === b[i]);
}

// Order does not matter. Sort copies, never the originals.
function sameContents(a, b) {
  if (a.length !== b.length) return false;
  const x = [...a].sort();
  const y = [...b].sort();
  return x.every((v, i) => v === y[i]);
}

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

console.log('=== on two equal arrays:', a === b);
console.log('sameOrder(a, b)   :', sameOrder(a, b));
console.log('sameOrder(a, c)   :', sameOrder(a, c));
console.log('sameContents(a, c):', sameContents(a, c));

// The JSON shortcut works but is order-sensitive and slow on big arrays:
console.log('via JSON:', JSON.stringify(a) === JSON.stringify(b));

// sameContents sorts copies, so the inputs are untouched:
sameContents(a, c);
console.log('c after comparing:', c);

Output


      
7 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 7 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

7 checks

test('two arrays are never === equal', a === b, false);
test('same order, same values', sameOrder(a, b), true);
test('different order fails the strict check', sameOrder(a, c), false);
test('but passes the contents check', sameContents(a, c), true);
test('different lengths fail both', sameOrder([1], [1, 2]), false);
test('empty arrays match', sameOrder([], []), true);
test('the inputs were not reordered', c, [3, 2, 1]);

The last assertion is the habit worth keeping. sort() mutates, so a naive a.sort() inside a comparison function quietly reorders the caller's array — a bug that shows up somewhere else entirely. Spreading into a copy first costs one character and removes the whole class of problem.

Answers JS Basic 27 in the original set.

28 Find the second largest value

fair

Find the second largest number in a list. Decide what counts as second when the largest value appears more than once.

Stuck? Three hints, smallest first
  1. 1. Sorting descending and taking index 1 gives the second element, not the second distinct value.
  2. 2. For distinct values, dedupe first with a Set.
  3. 3. A single pass tracking the best two is possible and does not need sorting at all.

The answer

// Second distinct value.
function secondLargest(values) {
  const distinct = [...new Set(values)].sort((a, b) => b - a);
  if (distinct.length < 2) return undefined;
  return distinct[1];
}

// One pass, no sorting, no extra array.
function secondLargestOnePass(values) {
  let best = -Infinity;
  let second = -Infinity;
  for (const n of values) {
    if (n > best) {
      second = best;
      best = n;
    } else if (n < best && n > second) {
      second = n;
    }
  }
  return second === -Infinity ? undefined : second;
}

console.log(secondLargest([10, 5, 8, 10, 3]));
console.log('one pass agrees:', secondLargestOnePass([10, 5, 8, 10, 3]));

// The trap: sorting without deduping gives 10 again.
const naive = [...[10, 5, 8, 10, 3]].sort((a, b) => b - a)[1];
console.log('without deduping:', naive);

Output


      
7 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 7 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

7 checks

test('second distinct value', secondLargest([10, 5, 8, 10, 3]), 8);
test('the naive version returns the duplicate', naive, 10);
test('one pass agrees', secondLargestOnePass([10, 5, 8, 10, 3]), 8);
test('all values identical has no second', secondLargest([4, 4, 4]), undefined);
test('a single value has no second', secondLargest([4]), undefined);
test('negatives work', secondLargest([-1, -5, -3]), -3);
test('both methods agree on a plain list', secondLargestOnePass([1, 2, 3]), secondLargest([1, 2, 3]));

The ambiguity is the exercise. In [10, 5, 8, 10, 3] the second element after sorting is 10, but the second distinct value is 8, and the original prompt does not say which it wants. Deciding that explicitly, and returning undefined rather than a wrong number when there is no answer, is most of the work.

Answers JS Basic 28 in the original set.

29 Sum the digits of a number

easy

Add up the digits of a whole number. Then reduce it repeatedly until a single digit is left.

Stuck? Three hints, smallest first
  1. 1. Split the text form and add, or use % 10 and integer division in a loop.
  2. 2. For the repeated version, keep going while the value is 10 or more.
  3. 3. The single-digit result equals 1 + (n - 1) % 9 for positive n, which is a neat cross-check.

The answer

function digitSum(n) {
  return String(Math.abs(n))
    .split('')
    .reduce((total, d) => total + Number(d), 0);
}

function digitalRoot(n) {
  let value = Math.abs(n);
  while (value >= 10) value = digitSum(value);
  return value;
}

// The closed-form shortcut, for cross-checking.
const rootFormula = (n) => (n === 0 ? 0 : 1 + ((Math.abs(n) - 1) % 9));

for (const n of [1234, 99, 9875, 0, -456]) {
  console.log(n, 'digits sum to', digitSum(n), '-> root', digitalRoot(n));
}

Output


      
8 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 8 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

8 checks

test('1234', digitSum(1234), 10);
test('99', digitSum(99), 18);
test('a negative uses its digits', digitSum(-456), 15);
test('zero', digitSum(0), 0);
test('root of 1234', digitalRoot(1234), 1);
test('root of 99', digitalRoot(99), 9);
test('the formula agrees for 1..50', Array.from({ length: 50 }, (_, i) => i + 1).every((n) => digitalRoot(n) === rootFormula(n)), true);
test('and for zero', rootFormula(0), 0);

The cross-check is the interesting part. The digital root of any positive number is 1 + (n - 1) % 9, which follows from the fact that a number and its digit sum leave the same remainder when divided by 9. Checking the loop against the formula for fifty values is far stronger evidence than a handful of hand-picked cases.

Answers JS Basic 29 in the original set.

30 Factorial, three ways

fair

Compute the factorial of a number. Write it as a loop and as a recursion, and show where ordinary numbers stop being accurate.

Stuck? Three hints, smallest first
  1. 1. 0! is 1 by definition. Handle it before anything else.
  2. 2. Factorials grow fast: 21! already exceeds Number.MAX_SAFE_INTEGER.
  3. 3. BigInt stays exact. Write 1n and use BigInt(n) to convert.

The answer

function factorialLoop(n) {
  if (!Number.isInteger(n) || n < 0) throw new RangeError(`factorial needs a whole number 0 or above, got ${n}`);
  let out = 1;
  for (let i = 2; i <= n; i++) out *= i;
  return out;
}

function factorialRecursive(n) {
  if (n < 0) throw new RangeError('no factorial for negatives');
  return n <= 1 ? 1 : n * factorialRecursive(n - 1);
}

// Exact for any size, because BigInt does not lose precision.
function factorialBig(n) {
  let out = 1n;
  for (let i = 2n; i <= BigInt(n); i++) out *= i;
  return out;
}

console.log('5! =', factorialLoop(5));
console.log('0! =', factorialLoop(0));
console.log('20! =', factorialLoop(20), '(still exact)');
console.log('21! =', factorialLoop(21), '(no longer exact)');
console.log('21! =', factorialBig(21).toString(), '(BigInt, exact)');

console.log('is 21! safe as a Number?', factorialLoop(21) <= Number.MAX_SAFE_INTEGER);

Output


      
8 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 8 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

8 checks

test('5 factorial', factorialLoop(5), 120);
test('0 factorial is 1', factorialLoop(0), 1);
test('1 factorial is 1', factorialLoop(1), 1);
test('recursion agrees up to 15', Array.from({ length: 16 }, (_, i) => i).every((n) => factorialLoop(n) === factorialRecursive(n)), true);
test('20 factorial is still exact', factorialLoop(20), 2432902008176640000);
test('21 factorial exceeds safe integers', factorialLoop(21) > Number.MAX_SAFE_INTEGER, true);
test('BigInt keeps it exact', factorialBig(21).toString(), '51090942171709440000');
test('negatives are refused', (() => { try { factorialLoop(-1); return false; } catch { return true; } })(), true);

20! is the last factorial an ordinary JavaScript number holds exactly. From 21! upward the result looks fine and is wrong in its low digits, with no warning at all — which is exactly the kind of failure that survives into production. BigInt is the fix, and the n suffix on literals is how you write one.

Answers JS Basic 30 in the original set.

31 Fibonacci numbers, and why the naive recursion is a trap

fair

Produce the first several Fibonacci numbers. Write the plain recursion, then show why it is unusable and fix it.

Stuck? Three hints, smallest first
  1. 1. The naive recursion recomputes the same values exponentially many times.
  2. 2. Remembering answers in a Map turns it from exponential into linear.
  3. 3. The loop version needs no memory at all beyond the last two values.

The answer

let naiveCalls = 0;

function fibNaive(n) {
  naiveCalls++;
  return n < 2 ? n : fibNaive(n - 1) + fibNaive(n - 2);
}

// Remember what you have already worked out.
function fibMemo(n, seen = new Map()) {
  if (n < 2) return n;
  const cached = seen.get(n);
  if (cached !== undefined) return cached;
  const value = fibMemo(n - 1, seen) + fibMemo(n - 2, seen);
  seen.set(n, value);
  return value;
}

// No recursion, no memory: keep the last two and walk forward.
function fibLoop(n) {
  let [a, b] = [0, 1];
  for (let i = 0; i < n; i++) [a, b] = [b, a + b];
  return a;
}

const first10 = Array.from({ length: 10 }, (_, i) => fibLoop(i));
console.log('first ten:', first10.join(', '));

naiveCalls = 0;
fibNaive(25);
console.log('fibNaive(25) made', naiveCalls, 'calls');
console.log('fibLoop(25) is', fibLoop(25), 'with 25 steps');

Output


      
6 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 6 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

6 checks

test('the first ten', first10, [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]);
test('memo agrees with the loop', Array.from({ length: 20 }, (_, i) => fibMemo(i)), Array.from({ length: 20 }, (_, i) => fibLoop(i)));
test('a larger value', fibLoop(50), 12586269025);
test('the naive version needs over 100,000 calls for n=25', naiveCalls > 100000, true);
test('memoised version is exact too', fibMemo(30), fibLoop(30));
test('fib(0) is 0', fibLoop(0), 0);

The call count is the point. fibNaive(25) makes over 240,000 calls to compute a number the loop reaches in 25 steps, because it recalculates fib(20) thousands of times. This is the standard illustration of why memoisation exists, and why "it is recursive so it must be elegant" is not an argument.

Answers JS Basic 31 in the original set.

32 Greatest common divisor and lowest common multiple

fair

Find the greatest common divisor of two numbers, then use it to find their lowest common multiple.

Stuck? Three hints, smallest first
  1. 1. Euclid’s algorithm: replace the pair with (b, a % b) until b is zero.
  2. 2. Once you have the GCD, the LCM is a * b / gcd(a, b).
  3. 3. Divide before multiplying to avoid overflowing on large inputs.

The answer

function gcd(a, b) {
  a = Math.abs(a);
  b = Math.abs(b);
  while (b !== 0) [a, b] = [b, a % b];
  return a;
}

// Divide first, then multiply, so the intermediate value stays smaller.
function lcm(a, b) {
  if (a === 0 || b === 0) return 0;
  return Math.abs(a / gcd(a, b) * b);
}

console.log('gcd(12, 18) =', gcd(12, 18));
console.log('gcd(17, 5)  =', gcd(17, 5), '(coprime)');
console.log('lcm(4, 6)   =', lcm(4, 6));

// Works for a whole list by folding.
const gcdAll = (values) => values.reduce(gcd);
const lcmAll = (values) => values.reduce(lcm);

console.log('gcd of [12, 18, 24] =', gcdAll([12, 18, 24]));
console.log('lcm of [2, 3, 4]    =', lcmAll([2, 3, 4]));

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('gcd of 12 and 18', gcd(12, 18), 6);
test('coprime numbers give 1', gcd(17, 5), 1);
test('gcd with zero is the other number', gcd(0, 7), 7);
test('order does not matter', gcd(18, 12), gcd(12, 18));
test('lcm of 4 and 6', lcm(4, 6), 12);
test('lcm with zero is zero', lcm(0, 5), 0);
test('gcd across a list', gcdAll([12, 18, 24]), 6);
test('lcm across a list', lcmAll([2, 3, 4]), 12);
test('gcd times lcm equals the product', gcd(12, 18) * lcm(12, 18), 12 * 18);

The last assertion is a genuine identity worth knowing: for any two numbers, gcd(a,b) * lcm(a,b) === a * b. It makes a good self-check on any GCD implementation. Note also the ordering in lcm — dividing before multiplying keeps the intermediate result small, which matters once the inputs get large.

Answers JS Basic 32 in the original set.

33 Convert between number bases

fair

Convert a decimal number to binary, octal and hexadecimal, and read each back again.

Stuck? Three hints, smallest first
  1. 1. n.toString(2) gives binary; the argument is the base, from 2 to 36.
  2. 2. parseInt(text, 2) reads it back. Always pass the base.
  3. 3. Leaving the base off parseInt is a classic bug: a leading zero used to mean octal.

The answer

function bases(n) {
  return {
    binary: n.toString(2),
    octal: n.toString(8),
    hex: n.toString(16).toUpperCase(),
  };
}

const out = bases(255);
console.log('255 in binary:', out.binary);
console.log('255 in octal :', out.octal);
console.log('255 in hex   :', out.hex);

// Reading back. The base argument is not optional in practice.
console.log('parseInt("11111111", 2) =', parseInt('11111111', 2));
console.log('parseInt("FF", 16)      =', parseInt('FF', 16));

// Why the base matters: without it, behaviour depends on the text.
console.log('parseInt("0x1A")     =', parseInt('0x1A'));      // hex, guessed
console.log('parseInt("0x1A", 10) =', parseInt('0x1A', 10));  // stops at the x

// And Number() is stricter than parseInt: it refuses partial input.
console.log('Number("12abc") =', Number('12abc'));
console.log('parseInt("12abc", 10) =', parseInt('12abc', 10));

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('255 in binary', out.binary, '11111111');
test('255 in octal', out.octal, '377');
test('255 in hex', out.hex, 'FF');
test('read binary back', parseInt(out.binary, 2), 255);
test('read hex back', parseInt(out.hex, 16), 255);
test('a round trip through base 36', parseInt((12345).toString(36), 36), 12345);
test('parseInt guesses hex from a 0x prefix', parseInt('0x1A'), 26);
test('but stops at the x when told base 10', parseInt('0x1A', 10), 0);
test('Number refuses partial input', Number.isNaN(Number('12abc')), true);

parseInt without a base is one of the oldest footguns in the language: it inspects the text and guesses, so '0x1A' becomes 26 while '08' once became 0 in older engines. Always pass the radix. And note the difference from Number(), which refuses '12abc' outright rather than reading the leading digits.

Answers JS Basic 33 in the original set.

34 Check whether a value is an array

easy

Decide reliably whether a value is an array. Show why typeof is no help.

Stuck? Three hints, smallest first
  1. 1. typeof [] is "object", same as for a plain object and for null.
  2. 2. Array.isArray() is the correct answer and has been since ES5.
  3. 3. Object.prototype.toString.call(v) is the older trick and still useful for distinguishing other built-ins.

The answer

const values = [[], {}, 'text', 42, null, undefined, new Date(), /re/];

// typeof cannot tell an array from an object, or from null.
for (const v of values) {
  console.log(String(typeof v).padEnd(10), Array.isArray(v) ? 'array' : 'not an array');
}

// The general-purpose version, for when you need the exact built-in.
const kindOf = (v) => Object.prototype.toString.call(v).slice(8, -1).toLowerCase();

console.log('');
for (const v of values) console.log(kindOf(v));

Output


      
10 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 10 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

10 checks

test('typeof cannot distinguish an array', typeof [], 'object');
test('nor null', typeof null, 'object');
test('isArray gets it right', Array.isArray([]), true);
test('and rejects an object', Array.isArray({}), false);
test('and rejects a string', Array.isArray('abc'), false);
test('and rejects null', Array.isArray(null), false);
test('kindOf names an array', kindOf([]), 'array');
test('kindOf names a date', kindOf(new Date()), 'date');
test('kindOf names null', kindOf(null), 'null');
test('kindOf names a regexp', kindOf(/re/), 'regexp');

typeof null === 'object' is a bug from 1995 that can never be fixed without breaking the web, and typeof [] being 'object' follows from arrays genuinely being objects. Array.isArray exists precisely because neither is useful. kindOf is worth keeping in your toolkit for the cases isArray does not cover.

Answers JS Basic 34 in the original set.

35 Flatten a nested array

fair

Turn an array containing arrays into a single flat array. Handle nesting of any depth, and note the difference from flattening one level.

Stuck? Three hints, smallest first
  1. 1. flat() flattens one level by default; flat(Infinity) goes all the way down.
  2. 2. Writing it yourself needs recursion and Array.isArray per element.
  3. 3. flatMap is map followed by one level of flattening, which is handy in its own right.

The answer

const nested = [1, [2, 3], [4, [5, [6, 7]]], 8];

console.log('one level  :', nested.flat());
console.log('all the way:', nested.flat(Infinity));

// The hand-written version, so the mechanism is visible.
function flatten(values) {
  const out = [];
  for (const v of values) {
    if (Array.isArray(v)) out.push(...flatten(v));
    else out.push(v);
  }
  return out;
}

console.log('by hand    :', flatten(nested));

// flatMap: map then flatten one level. Useful for one-to-many.
const words = ['hello world', 'good morning'];
console.log('flatMap:', words.flatMap((s) => s.split(' ')));

// Empty slots vanish, which is occasionally surprising:
console.log('flat removes holes:', [1, , 3].flat());

Output


      
8 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 8 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

8 checks

test('one level only', nested.flat(), [1, 2, 3, 4, [5, [6, 7]], 8]);
test('fully flattened', nested.flat(Infinity), [1, 2, 3, 4, 5, 6, 7, 8]);
test('the hand-written version agrees', flatten(nested), nested.flat(Infinity));
test('an already flat array is unchanged', flatten([1, 2, 3]), [1, 2, 3]);
test('an empty array', flatten([]), []);
test('deeply nested single value', flatten([[[[42]]]]), [42]);
test('flatMap splits one to many', words.flatMap((s) => s.split(' ')), ['hello', 'world', 'good', 'morning']);
test('flat drops holes', [1, , 3].flat().length, 2);

The default depth of 1 catches people out: flat() on a three-deep array leaves arrays inside. Pass Infinity when you mean all of it. The last assertion is a smaller surprise — flat also removes empty slots, so [1, , 3] comes back with two elements rather than three.

Answers JS Basic 35 in the original set.

36 Shuffle an array fairly

hard

Put an array in random order. Use a shuffle that is actually uniform, and show why the popular one-liner is not.

Stuck? Three hints, smallest first
  1. 1. sort(() => Math.random() - 0.5) is the popular version and it is biased.
  2. 2. Fisher-Yates walks from the end, swapping each element with a random earlier one.
  3. 3. Test a shuffle by running it many times and checking the distribution, not by looking at one result.

The answer

// Fisher-Yates. Walk backwards, swap with a random index at or before i.
function shuffle(values) {
  const out = [...values];
  for (let i = out.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [out[i], out[j]] = [out[j], out[i]];
  }
  return out;
}

// The popular one-liner. Looks fine, is not uniform.
const badShuffle = (values) => [...values].sort(() => Math.random() - 0.5);

console.log('shuffled:', shuffle([1, 2, 3, 4, 5]).join(', '));

/* Measuring the bias needs care. With five elements the sort version is only a
   few points off, which hides in the noise of a few thousand runs. With TEN it
   keeps the first element first about twice as often as it should, which is
   unmistakable. So the measurement uses ten. */
const TEN = Array.from({ length: 10 }, (_, i) => i + 1);
const RUNS = 20000;

function firstStaysFirst(fn) {
  let count = 0;
  for (let i = 0; i < RUNS; i++) if (fn(TEN)[0] === 1) count++;
  return count / RUNS;
}

// Stronger still: where does element 1 land? Every position should be 1 in 10.
function positionSpread(fn) {
  const seen = Array(10).fill(0);
  for (let i = 0; i < RUNS; i++) seen[fn(TEN).indexOf(1)]++;
  return Math.max(...seen.map((n) => Math.abs(n / RUNS - 0.1)));
}

const fair = firstStaysFirst(shuffle);
const biased = firstStaysFirst(badShuffle);
const fairSpread = positionSpread(shuffle);
const biasedSpread = positionSpread(badShuffle);

console.log('with ten elements, every position should be 10.0%');
console.log('fair shuffle keeps 1 first', (fair * 100).toFixed(1) + '% of the time');
console.log('sort shuffle keeps 1 first', (biased * 100).toFixed(1) + '% of the time  <- about double');
console.log('worst position error, fair:', (fairSpread * 100).toFixed(1) + ' points');
console.log('worst position error, sort:', (biasedSpread * 100).toFixed(1) + ' points');

Output


      
11 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 11 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

11 checks

test('the shuffle keeps every element', shuffle([1, 2, 3, 4, 5]).sort((a, b) => a - b), [1, 2, 3, 4, 5]);
test('and the same length', shuffle([1, 2, 3]).length, 3);
test('the original is untouched', (() => { const a = [1, 2, 3]; shuffle(a); return a; })(), [1, 2, 3]);
test('a single element is fine', shuffle([9]), [9]);
test('an empty array is fine', shuffle([]), []);
test('the fair shuffle leaves 1 first about a tenth of the time', Math.abs(fair - 0.1) < 0.02, true);
test('the sort version does it far too often', biased > 0.15, true);
test('roughly twice as often as it should', biased / fair > 1.5, true);
test('every position is near even for the fair shuffle', fairSpread < 0.02, true);
test('and badly uneven for the sort one', biasedSpread > 0.05, true);
test('so the sort version is off by an order of magnitude more', biasedSpread / fairSpread > 5, true);

The measurement is the exercise. With ten elements a uniform shuffle leaves the first element in place one time in ten; the sort one-liner leaves it there about twice as often, and the assertions check both the rate and the spread across every position.

The reason is that sort assumes its comparator is consistent — if it says a comes before b once, it will say so again. A comparator returning random values breaks that assumption, so the outcome depends on which algorithm the engine happens to use and which elements it happens to compare. It is not merely imperfect; it is undefined behaviour that looks like it works. Fisher-Yates is five lines and provably uniform.

Worth noting how easy this was to under-test. An earlier version of this exercise measured five elements, where the bias is only a few points — small enough that it passed or failed depending on the run. Ten makes it unmistakable. When testing a statistical claim, pick the case where the effect is large, and assert a ratio rather than a narrow threshold.

Answers JS Basic 36 in the original set.

37 Truncate text to a length

easy

Shorten text to a maximum length, adding an ellipsis when you cut it. Do not produce something longer than the limit.

Stuck? Three hints, smallest first
  1. 1. The ellipsis counts towards the limit, or your "20 characters" comes out as 23.
  2. 2. Return the text unchanged when it already fits.
  3. 3. Cutting mid-word is ugly; cutting at the last space is nicer. Offer both.

The answer

function truncate(text, max, suffix = '\u2026') {
  if (text.length <= max) return text;
  if (max <= suffix.length) return suffix.slice(0, max);
  // The suffix has to fit inside the limit, not be added on top of it.
  return text.slice(0, max - suffix.length) + suffix;
}

// A tidier cut: back up to the last space so words stay whole.
function truncateWords(text, max, suffix = '\u2026') {
  if (text.length <= max) return text;
  const cut = text.slice(0, max - suffix.length);
  const lastSpace = cut.lastIndexOf(' ');
  const base = lastSpace > 0 ? cut.slice(0, lastSpace) : cut;
  return base + suffix;
}

const long = 'The quick brown fox jumps over the lazy dog';

console.log(truncate(long, 20));
console.log(truncateWords(long, 20));
console.log('short text:', truncate('hi', 20));

Output


      
8 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 8 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

8 checks

test('never exceeds the limit', truncate(long, 20).length <= 20, true);
test('the cut version', truncate(long, 20), 'The quick brown fox\u2026');
test('short text is unchanged', truncate('hi', 20), 'hi');
test('exactly at the limit is unchanged', truncate('12345', 5), '12345');
test('one over the limit is cut', truncate('123456', 5).length, 5);
test('word-safe version does not split a word', truncateWords(long, 20), 'The quick brown\u2026');
test('and it also respects the limit', truncateWords(long, 20).length <= 20, true);
test('a tiny limit still works', truncate('hello', 1), '\u2026');

The first assertion is the one that matters, and the one most implementations get wrong: if the caller says 20 characters, the result must be at most 20 including the ellipsis. Appending it afterwards gives 21 and breaks whatever layout the limit existed for.

Answers JS Basic 37 in the original set.

38 Group a list by a key

fair

Take a list of records and group them by one of their fields, so you can look up all the records sharing a value.

Stuck? Three hints, smallest first
  1. 1. A Map keyed by the field value, holding arrays, is the natural shape.
  2. 2. map.get(key) ?? [] then push is the usual idiom for building it.
  3. 3. Modern engines have Object.groupBy and Map.groupBy; write it yourself first.

The answer

const buses = [
  { plate: 'LAG-441-XA', route: 'Lagos - Abuja', tier: 'luxury' },
  { plate: 'ABJ-772-KM', route: 'Lagos - Abuja', tier: 'economy' },
  { plate: 'ENU-318-PH', route: 'Lagos - Kano', tier: 'luxury' },
  { plate: 'KAN-905-BD', route: 'Lagos - Kano', tier: 'economy' },
];

function groupBy(items, keyOf) {
  const out = new Map();
  for (const item of items) {
    const key = keyOf(item);
    const bucket = out.get(key) ?? [];
    bucket.push(item);
    out.set(key, bucket);
  }
  return out;
}

const byRoute = groupBy(buses, (b) => b.route);
const byTier = groupBy(buses, (b) => b.tier);

for (const [route, list] of byRoute) {
  console.log(route, '->', list.map((b) => b.plate).join(', '));
}

// Counting is the same shape with a number instead of an array.
function countBy(items, keyOf) {
  const out = new Map();
  for (const item of items) out.set(keyOf(item), (out.get(keyOf(item)) ?? 0) + 1);
  return out;
}

console.log('per tier:', [...countBy(buses, (b) => b.tier).entries()]);

Output


      
7 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 7 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

7 checks

test('two routes', byRoute.size, 2);
test('two buses on the Abuja run', byRoute.get('Lagos - Abuja').length, 2);
test('the right plates', byRoute.get('Lagos - Kano').map((b) => b.plate), ['ENU-318-PH', 'KAN-905-BD']);
test('grouped by tier instead', byTier.get('luxury').length, 2);
test('an unknown key is undefined', byRoute.get('Lagos - Jos'), undefined);
test('counting by tier', [...countBy(buses, (b) => b.tier).entries()], [['luxury', 2], ['economy', 2]]);
test('an empty list groups to nothing', groupBy([], (x) => x).size, 0);

groupBy and countBy are the same five lines with an array or a number in the bucket, and between them they cover a large share of everyday data work. Passing the key as a function rather than a field name is what makes it reusable: you can group by anything computed, not just by a property that already exists.

Answers JS Basic 38 in the original set.

39 Debounce a repeated call

hard

Wrap a function so that calling it repeatedly in quick succession only runs it once, after things settle. Adapted: uses a controllable clock so the behaviour can be asserted instead of waited for.

Stuck? Three hints, smallest first
  1. 1. Keep the pending timer id and clear it every time a new call arrives.
  2. 2. Real code uses setTimeout. A test needs a clock you control.
  3. 3. Debounce waits for quiet; throttle runs at most once per interval. They are not the same.

The answer

// A clock we drive by hand, so the test does not have to wait.
function makeClock() {
  let now = 0;
  let next = 1;
  const jobs = new Map();
  return {
    setTimeout(fn, delay) {
      const id = next++;
      jobs.set(id, { at: now + delay, fn });
      return id;
    },
    clearTimeout(id) { jobs.delete(id); },
    advance(ms) {
      now += ms;
      for (const [id, job] of [...jobs.entries()]) {
        if (job.at <= now) { jobs.delete(id); job.fn(); }
      }
    },
  };
}

function debounce(fn, wait, clock) {
  let timer = null;
  return (...args) => {
    if (timer !== null) clock.clearTimeout(timer);   // cancel the previous one
    timer = clock.setTimeout(() => fn(...args), wait);
  };
}

const clock = makeClock();
const calls = [];
const save = debounce((value) => calls.push(value), 100, clock);

save('a');
save('b');
save('c');           // only this one should survive

// Snapshots have to be taken at the moment they describe, not at the end.
const immediately = calls.length;

clock.advance(99);
const justBefore = calls.length;

clock.advance(1);
const afterWait = [...calls];

save('d');
clock.advance(100);

console.log('immediately        :', immediately);
console.log('after 99ms         :', justBefore);
console.log('after the full wait:', afterWait);
console.log('and a later call   :', calls);

Output


      
7 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 7 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

7 checks

test('nothing runs immediately', immediately, 0);
test('nothing has run one tick early either', justBefore, 0);
test('one call after the wait, not three', afterWait.length, 1);
test('and it carried the last argument', afterWait[0], 'c');
test('a later call runs separately', calls[1], 'd');
test('exactly two runs came from four calls', calls.length, 2);
test('the real version is this code with the global clock', typeof setTimeout, 'function');

Adapted from the original, which uses the real setTimeout and therefore can only be watched, not asserted. Injecting a clock is the standard fix for testing anything time-based, and it is the whole reason this exercise can prove that three rapid calls produce exactly one run with the last argument. In production you pass the real window timers instead.

Answers JS Basic 39 in the original set.

40 Deep clone an object

hard

Copy a nested object so that changing the copy cannot affect the original. Show what the shallow and JSON approaches lose.

Stuck? Three hints, smallest first
  1. 1. Spreading copies one level only: nested objects stay shared.
  2. 2. JSON.parse(JSON.stringify(x)) goes deep but destroys dates, maps, undefined and functions.
  3. 3. structuredClone is the built-in that handles most of it.

The answer

const original = {
  plate: 'LAG-441-XA',
  seats: { total: 14, sold: 9 },
  serviced: new Date('2026-01-15T00:00:00Z'),
  tags: ['aircon', 'wifi'],
  notes: undefined,
};

// One level only. The nested object is shared.
const shallow = { ...original };
shallow.seats.sold = 999;
console.log('after editing the shallow copy, original sold =', original.seats.sold);
original.seats.sold = 9;                 // put it back

// Deep, but lossy.
const viaJson = JSON.parse(JSON.stringify(original));
console.log('date became a', typeof viaJson.serviced);
console.log('undefined key survived?', 'notes' in viaJson);

// Deep and faithful.
const deep = structuredClone(original);
deep.seats.sold = 1;
deep.tags.push('tv');

console.log('original sold after deep edit:', original.seats.sold);
console.log('original tags after deep edit:', original.tags);
console.log('date is still a Date:', deep.serviced instanceof Date);

Output


      
8 checks that prove this answer

You do not have to read these. They are part of the answer panel above, so pressing Run there executes all 8 of them and prints ok for each. That output is the proof, and the build fails if a single check fails.

8 checks

test('the shallow copy shared the nested object', original.seats.sold, 9);
test('JSON turns a Date into text', typeof viaJson.serviced, 'string');
test('JSON drops undefined keys', 'notes' in viaJson, false);
test('structuredClone keeps the Date', deep.serviced instanceof Date, true);
test('and keeps the undefined key', 'notes' in deep, true);
test('a deep edit leaves the original alone', original.seats.sold, 9);
test('and so does a nested array push', original.tags, ['aircon', 'wifi']);
test('but it is a different object', deep === original, false);

Three tools, three different failure modes. Spreading is fine when the object is one level deep and a trap otherwise. The JSON round trip is deep but silently converts dates to strings and deletes undefined values, which is the sort of thing that surfaces days later. structuredClone is built in, handles dates, maps, sets and cycles, and cannot copy functions — which is usually what you want anyway.

Answers JS Basic 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.