JavaScript: String, 41–63
Cutting, joining, padding, casing and checking text.
Exercises 41 to 63 of 63
41 Are two strings anagrams?
fairDecide whether 'Dormitory' and 'dirty room' are anagrams. Ignore case, spaces and punctuation.
Stuck? Three hints, smallest first
- 1. Normalise both strings the same way, then compare.
- 2. Sorting the characters is the shortest normal form.
- 3. Equal length is necessary but not sufficient, so do not stop there.
const normalise = (text) =>
[...text.toLowerCase().replace(/[^a-z0-9]/g, '')].sort().join('');
const isAnagram = (a, b) => normalise(a) === normalise(b);
// Counting is the other standard answer, and it is linear rather than n log n.
function isAnagramByCount(a, b) {
const x = normalise(a);
const y = normalise(b);
if (x.length !== y.length) return false;
const counts = new Map();
for (const c of x) counts.set(c, (counts.get(c) ?? 0) + 1);
for (const c of y) {
if (!counts.has(c)) return false;
counts.set(c, counts.get(c) - 1);
if (counts.get(c) === 0) counts.delete(c);
}
return counts.size === 0;
}
console.log("normalise('Dormitory') :", normalise('Dormitory'));
console.log("normalise('dirty room'):", normalise('dirty room'));
console.log('anagrams?', isAnagram('Dormitory', 'dirty room'));
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.
test('listen and silent', isAnagram('listen', 'silent'), true);
test('case is folded', isAnagram('Listen', 'SILENT'), true);
test('spaces and punctuation are ignored', isAnagram('Dormitory', 'dirty room'), true);
test('different letters are not anagrams', isAnagram('lagos', 'kano'), false);
test('the same letters in different amounts are not either', isAnagram('aab', 'abb'), false);
test('a word is an anagram of itself', isAnagram('lagos', 'lagos'), true);
test('two empty strings are anagrams', isAnagram('', ''), true);
test('sorting is what makes the comparison work', normalise('silent'), 'eilnst');
test('sort compares code units, so digits come before letters', normalise('a1'), '1a');
test('equal length is not enough on its own', isAnagram('abc', 'abd'), false);
test('the counting version agrees everywhere', [['listen', 'silent'], ['aab', 'abb'], ['abc', 'abd'], ['', '']].every(([a, b]) => isAnagramByCount(a, b) === isAnagram(a, b)), true);
Sort-and-compare is the answer to give: it is one line, obviously correct, and n log n on string lengths that are almost always tiny. The counting version is linear and worth knowing for when the strings are not tiny.
sort() with no comparator compares UTF-16 code units, which is exactly what is wanted here — any consistent total order works, because the sorted form is only ever compared against another sorted form. That is also why normalise('a1') being '1a' is harmless rather than a bug.
42 Is it an isogram?
fairAn isogram has no repeated letters. Check 'lumberjack' and 'lagos state', ignoring case, spaces and hyphens.
Stuck? Three hints, smallest first
- 1. A
Setdrops duplicates, so compare its size against the length. - 2. Strip everything that is not a letter before counting.
- 3. Decide whether a repeated digit should disqualify the word, and say so in the code.
const isIsogram = (text) => {
const letters = text.toLowerCase().replace(/[^a-z]/g, '');
return new Set(letters).size === letters.length;
};
// Which letter spoiled it, if any.
const firstRepeat = (text) => {
const seen = new Set();
for (const c of text.toLowerCase().replace(/[^a-z]/g, '')) {
if (seen.has(c)) return c;
seen.add(c);
}
return null;
};
for (const w of ['lumberjack', 'lagos state', 'six-year-old', 'Abba']) {
console.log(w.padEnd(14), isIsogram(w), firstRepeat(w) === null ? '' : '(repeat: ' + firstRepeat(w) + ')');
}
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.
test('a word with no repeats', isIsogram('lumberjack'), true);
test('a repeat is caught', isIsogram('lagos state'), false);
test('and it can be named', firstRepeat('lagos state'), 's');
test('case does not hide a repeat', isIsogram('Abba'), false);
test('spaces and hyphens are stripped, not counted', isIsogram('six-year-old'), true);
test('the empty string is an isogram', isIsogram(''), true);
test('and so is a single letter', isIsogram('a'), true);
test('a Set drops duplicates, which is the whole trick', new Set('abba').size, 2);
test('while length keeps them', 'abba'.length, 4);
test('digits are stripped, so a repeated digit does not disqualify it', isIsogram('a11'), true);
test('the long standard example', isIsogram('dermatoglyphics'), true);
new Set(text).size === text.length is the whole idea, and it generalises: the same one-liner answers "are these values all distinct?" for any array.
Note that new Set('abba') works directly on the string, because a Set constructor takes any iterable and a string iterates its code points. That also means an emoji counts once here, unlike anything measured with length.
Returning which letter repeated costs three more lines and makes the function useful for an error message rather than only for a yes or no. Worth doing whenever the caller is a person.
43 Code units against code points
fairShow that '👍'.length is 2, and work out which operations on a string are counting the wrong thing because of it.
Stuck? Three hints, smallest first
- 1. A JavaScript string is a sequence of UTF-16 code units, and
lengthcounts those. - 2. Anything above U+FFFF needs two units, called a surrogate pair.
- 3. Spread,
Array.fromandfor...ofall iterate code points instead.
const thumbsUp = '👍';
const mixed = 'Lagos 👍';
console.log('thumbsUp.length :', thumbsUp.length, '<- code units');
console.log('[...thumbsUp].length:', [...thumbsUp].length, '<- code points');
console.log('thumbsUp[0] :', JSON.stringify(thumbsUp[0]), '<- half a character');
console.log('mixed.length :', mixed.length, ' by code point:', [...mixed].length);
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.
test('a thumbs-up is one character but length 2', thumbsUp.length, 2);
test('because length counts UTF-16 code units', [...thumbsUp].length, 1);
test('its code point is above the 16-bit range', thumbsUp.codePointAt(0), 128077);
test('so charCodeAt and codePointAt disagree', thumbsUp.charCodeAt(0) !== thumbsUp.codePointAt(0), true);
test('indexing gives you half a character', thumbsUp[0] === thumbsUp, false);
test('and that half is one code unit long', thumbsUp[0].length, 1);
test('slice(0, 1) cuts it in half too', thumbsUp.slice(0, 1) === thumbsUp, false);
test('while spreading and re-joining is safe', [...thumbsUp].join(''), thumbsUp);
test('a mixed string over-counts with length', mixed.length, 8);
test('and counts correctly by code point', [...mixed].length, 7);
test('so a length limit has to be measured in the unit you meant', [...mixed].slice(0, 7).join(''), 'Lagos 👍');
This one fact explains most of the surprises in this section. length, charAt, charCodeAt, slice, substring, split('') and array index access all work in code units. for...of, spread, Array.from, codePointAt and matchAll work in code points.
Where it actually costs money: a 140-character limit enforced with length rejects text a user can see is short enough, and a slice at a fixed offset can end a string with half a surrogate pair, which serialises to a replacement character and cannot be recovered.
Code points are still not characters. 'e' + U+0301 is two code points and one letter, which is exercise 44.
44 normalize, for accents that look identical
fairTwo strings both read café and are not equal. Explain why, make them compare equal, and then strip the accent entirely.
Stuck? Three hints, smallest first
- 1. An accented letter can be one code point, or a base letter plus a combining mark.
- 2.
normalize(‘NFC’)composes them;normalize(‘NFD’)takes them apart. - 3. Stripping accents is NFD followed by removing everything in the mark category.
const composed = 'caf\u00e9'; // one code point for é
const decomposed = 'cafe\u0301'; // e, then a combining acute accent
const strip = (text) => text.normalize('NFD').replace(/\p{M}/gu, '');
console.log('they print the same :', composed, decomposed);
console.log('but they are equal? :', composed === decomposed);
console.log('lengths :', composed.length, decomposed.length);
console.log('after NFC :', decomposed.normalize('NFC') === composed);
console.log('accent stripped :', strip(composed), strip(decomposed));
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.
test('the two look the same and are not equal', composed === decomposed, false);
test('because they are different lengths', [composed.length, decomposed.length], [4, 5]);
test('NFC composes the mark into one character', decomposed.normalize('NFC'), composed);
test('NFD takes it back apart', composed.normalize('NFD'), decomposed);
test('NFC is the default form', decomposed.normalize(), composed);
test('so normalising both sides makes the comparison work', composed.normalize('NFC') === decomposed.normalize('NFC'), true);
test('the composed form is four code units long', decomposed.normalize('NFC').length, 4);
test('includes fails on the decomposed form', decomposed.includes('\u00e9'), false);
test('and works once it is normalised', decomposed.normalize('NFC').includes('\u00e9'), true);
test('stripping accents is NFD then dropping the marks', strip(decomposed), 'cafe');
test('which works on the composed form too', strip(composed), 'cafe');
Text arriving from different places arrives in different normal forms. macOS filenames are NFD, most web input is NFC, and the two are indistinguishable on screen. Any string used as a key, compared for equality, or looked up in a database needs normalising at the boundary, once, on the way in.
The two forms are not the only ones. NFKC and NFKD also fold compatibility characters, so the ligature fi becomes fi and a full-width digit becomes an ordinary one. Useful for search, destructive for anything you intend to display back.
strip is the accent-folding function worth keeping. It is how 'café' is found by someone typing cafe, and it is a building block of slugification in exercise 48.
45 Sorting text a reader would recognise
fairSort ['Ölaf', 'Zoe', 'Ada', 'ándre'] twice: with the default comparison and with localeCompare. Explain the difference.
Stuck? Three hints, smallest first
- 1.
sort()with no comparator compares UTF-16 code units. - 2. Accented letters have high code points, so they land after Z.
- 3.
localeCompareuses collation rules, which put each accented letter next to its base letter.
const names = ['Ölaf', 'Zoe', 'Ada', 'ándre'];
const byCodeUnit = [...names].sort();
const byLocale = [...names].sort((a, b) => a.localeCompare(b, 'en'));
console.log('by code unit :', byCodeUnit, '<- accents pushed to the end');
console.log('by locale :', byLocale);
console.log("'10' vs '9' as text :", '10' < '9');
console.log("'10' vs '9', numeric :", '10'.localeCompare('9', 'en', { numeric: true }) > 0);
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.
test('the default sort uses code units, so accents land at the end', byCodeUnit, ['Ada', 'Zoe', 'Ölaf', 'ándre']);
test('localeCompare puts them where a reader expects', byLocale, ['Ada', 'ándre', 'Ölaf', 'Zoe']);
test('code-unit sorting also separates the cases', ['b', 'A', 'a', 'B'].sort(), ['A', 'B', 'a', 'b']);
test('while locale sorting keeps each letter together', ['b', 'A', 'a', 'B'].sort((x, y) => x.localeCompare(y, 'en')).map((s) => s.toLowerCase()), ['a', 'a', 'b', 'b']);
test('localeCompare returns 0 for equal strings', 'a'.localeCompare('a'), 0);
test('an accent is a difference at the default sensitivity', 'e'.localeCompare('é', 'en') !== 0, true);
test('but not at sensitivity base', 'e'.localeCompare('é', 'en', { sensitivity: 'base' }), 0);
test('numeric collation compares digit runs as numbers', '10'.localeCompare('9', 'en', { numeric: true }) > 0, true);
test('without it, 10 sorts before 9', '10'.localeCompare('9', 'en') < 0, true);
test('which is the same answer the < operator gives', '10' < '9', true);
test('sorting a copy leaves the original alone', names[0], 'Ölaf');
The default sort is not "alphabetical", it is "by code unit", and the two agree only for unaccented ASCII of a single case. Anything shown to a user should go through localeCompare or an Intl.Collator.
Use a collator rather than localeCompare when sorting a list. localeCompare may construct the collation data on every call; new Intl.Collator('en').compare builds it once and is a drop-in comparator. Exercise 52 uses it.
46 Mask a card number
fairHide all but the last four digits of a card number, keeping the width the same. Then ask for zero visible digits and see what you get.
Stuck? Three hints, smallest first
- 1. Take the tail you want to keep, then
padStartit back to the original length. - 2. Strip spaces before masking, or the mask comes out too wide.
- 3.
slice(-0)isslice(0), because-0 === 0.
const mask = (digits, keep = 4) => digits.slice(-keep).padStart(digits.length, '*');
// slice(-0) is slice(0), so keep = 0 returns the WHOLE string. An explicit
// check is the only fix.
const maskSafe = (digits, keep = 4) =>
(keep <= 0 ? '' : digits.slice(-keep)).padStart(digits.length, '*');
console.log('mask :', mask('4111111111111111'));
console.log('keep 2 :', mask('987654', 2));
console.log('keep 0 :', mask('9876', 0), '<- nothing hidden at all');
console.log('keep 0, fixed :', maskSafe('9876', 0));
console.log('with spaces :', mask('4111 1111 1111 1111'), '<- padded to 19, not 16');
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.
test('all but the last four are hidden', mask('4111111111111111'), '************1111');
test('the width is unchanged', mask('4111111111111111').length, 16);
test('keeping two instead of four', mask('987654', 2), '****54');
test('a number shorter than the tail is returned whole', mask('123'), '123');
test('keeping none does NOT hide everything, because slice(-0) is slice(0)', mask('9876', 0), '9876');
test('an explicit check is the fix', maskSafe('9876', 0), '****');
test('spaces in the input inflate the mask', mask('4111 1111 1111 1111').length, 19);
test('so strip them first', mask('4111 1111 1111 1111'.replace(/\s/g, '')), '************1111');
test('the visible tail is exactly the last four characters', mask('4111111111111111').slice(-4), '1111');
test('and nothing but stars comes before it', /^[*]+$/.test(mask('4111111111111111').slice(0, -4)), true);
slice(-0) is the trap, and it is the same -0 === 0 fact that made at(-0) return the first character in exercise 3. A "keep" count that could arrive as zero — from a configuration value, or from a subtraction — turns a masking function into an identity function, silently, in production.
Masking is a display concern. The value being masked here is a full card number held in memory, which is what PCI rules are about; the safe pattern is to store only the last four and never have the rest to mask. Treat this as string work, not as a security control.
47 Truncate with an ellipsis at a word boundary
fairShorten a sentence to at most n characters, ending with an ellipsis, without cutting a word in half. The ellipsis counts towards the limit.
Stuck? Three hints, smallest first
- 1. Budget one character for the ellipsis, so the body gets
max - 1. - 2. Look at the character just past the cut to tell whether you landed mid-word.
- 3. A single word longer than the limit has no space to step back to, so it must be cut.
function truncate(text, max) {
if (text.length <= max) return text;
const budget = max - 1; // one character goes to the ellipsis
let body = text.slice(0, budget);
if (text[budget] !== ' ') { // the cut landed mid-word, so step back
const at = body.lastIndexOf(' ');
if (at !== -1) body = body.slice(0, at);
}
return body.trimEnd() + '…';
}
const line = 'The quick brown fox jumps';
for (const n of [30, 25, 12, 10, 4, 3]) {
console.log(String(n).padStart(2), JSON.stringify(truncate(line, n)));
}
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.
test('a short string is returned untouched', truncate(line, 30), line);
test('exactly the limit is untouched too', truncate(line, 25), line);
test('a cut that lands on a space keeps the whole word', truncate(line, 10), 'The quick…');
test('a cut mid-word steps back to the space', truncate(line, 12), 'The quick…');
test('so the result can be shorter than the limit', truncate(line, 12).length <= 12, true);
test('the ellipsis is ONE character, not three dots', '…'.length, 1);
test('a single long word is cut hard, having no space to step back to', truncate('Antidisestablishmentarianism', 10), 'Antidises…');
test('a double space leaves no gap before the ellipsis', truncate('The quick brown', 8), 'The…');
test('and the tiniest limit still fits', truncate(line, 3), 'Th…');
test('every result respects the limit', [30, 25, 12, 10, 4, 3].every((n) => truncate(line, n).length <= n), true);
The ellipsis has to come out of the budget, or "truncate to 140" produces 142 characters and the API rejects it. Using '...' instead costs three, which is the main practical reason to prefer the single … character.
trimEnd() at the end is not decoration. Stepping back to the last space can leave the body ending in whitespace when the text had a double space, and 'The …' looks like a mistake.
This measures in code units, so a limit near an emoji can still cut a surrogate pair. Where that matters, walk [...text] or segment it, as in exercise 39.
48 Slugify a heading for a URL
fairTurn 'Hello, World!' into 'hello-world'. Fold accents, collapse runs of punctuation into one dash, and leave no dash at either end.
Stuck? Three hints, smallest first
- 1. Fold the accents first, with NFD and a mark strip.
- 2. Replace a run of unwanted characters with one dash, not each character.
- 3. Then trim the dashes off both ends, and decide what to do when nothing is left.
function slugify(text) {
return text
.normalize('NFD') // split accents off their letters
.replace(/\p{M}/gu, '') // and drop the marks
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-') // any RUN of other characters becomes one dash
.replace(/^-+|-+$/g, ''); // no leading or trailing dash
}
console.log("'Hello, World!' ->", slugify('Hello, World!'));
console.log("'C++ & C#' ->", slugify('C++ & C#'));
console.log("'Café Déjà Vu' ->", slugify('Café Déjà Vu'));
console.log("'Лагос' ->", JSON.stringify(slugify('Лагос')), '<- everything filtered away');
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.
test('the classic example', slugify('Hello, World!'), 'hello-world');
test('a run of punctuation and space collapses to one dash', slugify('C++ & C#'), 'c-c');
test('leading and trailing dashes are trimmed', slugify(' Spaced Out '), 'spaced-out');
test('accents are folded to their base letters', slugify('Café Déjà Vu'), 'cafe-deja-vu');
test('digits survive', slugify('2025 Q1 Report'), '2025-q1-report');
test('a slug is already a slug, so it is idempotent', slugify(slugify('Hello, World!')), 'hello-world');
test('a heading of only punctuation slugs to nothing', slugify('---'), '');
test('which is why an empty slug needs a fallback', slugify('---') || 'untitled', 'untitled');
test('a non-Latin heading is DESTROYED by the a-z filter', slugify('Лагос'), '');
test('so filter on the letter category instead when that matters', 'Лагос'.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '-'), 'лагос');
Two decisions are doing the work. Replacing a run rather than each character is why 'C++ & C#' is 'c-c' and not 'c-----c-'. And trimming the dashes last, after the replacement, is why a trailing '!' does not leave one behind.
The [^a-z0-9] filter is an English assumption. It deletes Cyrillic, Greek, Arabic and CJK entirely, giving an empty slug — so a fallback is not optional, it is the difference between a URL and a 404. If your headings are not all Latin, filter on \p{L} and \p{N} and let the URL carry percent-encoded characters.
Idempotence is worth asserting on any normalising function. If f(f(x)) !== f(x), re-saving a record changes its slug and breaks every existing link.
49 Initials from a name
fairTurn 'Ada Lovelace' into 'AL'. Handle extra whitespace, an empty name, and a name whose first character is an emoji.
Stuck? Three hints, smallest first
- 1. Trim, split on whitespace, take the first character of each part.
- 2. Splitting an empty string gives one empty part, and indexing that gives
undefined. - 3.
word[0]takes one code unit, so spread the word if it might start with an emoji.
const initials = (name) =>
name.trim().split(/\s+/).filter(Boolean).map((w) => w[0].toUpperCase()).join('');
// [...w][0] takes a whole code point rather than one code unit.
const initialsSafe = (name) =>
name.trim().split(/\s+/).filter(Boolean).map((w) => [...w][0].toUpperCase()).join('');
console.log("'Ada Lovelace' ->", initials('Ada Lovelace'));
console.log("' ada lovelace ' ->", initials(' ada lovelace '));
console.log("'Chimamanda Ngozi Adichie' ->", initials('Chimamanda Ngozi Adichie'));
console.log("'👍 ok', unsafe ->", JSON.stringify(initials('👍 ok')), '<- half a character');
console.log("'👍 ok', safe ->", initialsSafe('👍 ok'));
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.
test('two names give two letters', initials('Ada Lovelace'), 'AL');
test('extra whitespace is ignored', initials(' ada lovelace '), 'AL');
test('three names give three', initials('Chimamanda Ngozi Adichie'), 'CNA');
test('an empty name gives an empty string rather than throwing', initials(''), '');
test('the filter is what prevents that, because splitting "" gives one empty field', ''.split(/\s+/), ['']);
test('a hyphenated surname counts as one word', initials('Ama Ata-Aidoo'), 'AA');
test('splitting on the hyphen too would give three', 'Ama Ata-Aidoo'.split(/[\s-]+/).map((w) => w[0]).join(''), 'AAA');
test('capping at two is a slice, not a different algorithm', initials('Chimamanda Ngozi Adichie').slice(0, 2), 'CN');
test('w[0] takes half an astral character', initials('👍 ok')[0] === '👍', false);
test('spreading the word first fixes it', initialsSafe('👍 ok'), '👍O');
filter(Boolean) is the load-bearing line. Without it, initials('') reaches ''[0].toUpperCase() and throws a TypeError — and an empty name field is not an exotic input, it is the default state of every form.
Names are the classic example of data that resists rules. 'Ata-Aidoo', 'van der Berg', 'O'Brien' and mononyms all have a defensible answer that this function gets wrong. Where initials are shown to the person they belong to, let them be edited.
50 Thousands separators, by hand
fairFormat 4500 as '4,500' and 1000000 as '1,000,000' without using toLocaleString. Then check what your answer does to a decimal.
Stuck? Three hints, smallest first
- 1. Separate the whole part from the fraction before you start grouping.
- 2. A comma goes before every position whose distance from the end is a multiple of three.
- 3. The popular regex one-liner has a bug, and a decimal fraction is where it shows.
function group(n) {
const [whole, fraction] = String(n).split('.');
const sign = whole.startsWith('-') ? '-' : '';
const digits = sign ? whole.slice(1) : whole;
let out = '';
for (let i = 0; i < digits.length; i++) {
if (i > 0 && (digits.length - i) % 3 === 0) out += ',';
out += digits[i];
}
return sign + out + (fraction ? '.' + fraction : '');
}
// The one-liner everybody copies. It groups every run of digits, including the
// ones after the decimal point.
const byRegex = (n) => String(n).replace(/\B(?=([0-9]{3})+(?![0-9]))/g, ',');
console.log('group(4500) :', group(4500));
console.log('group(1000000) :', group(1000000));
console.log('group(1234.5678) :', group(1234.5678));
console.log('byRegex(1234.5678) :', byRegex(1234.5678), '<- the decimals were grouped too');
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.
test('four digits gain one comma', group(4500), '4,500');
test('seven digits gain two', group(1000000), '1,000,000');
test('three or fewer gain none', group(999), '999');
test('zero is left alone', group(0), '0');
test('a negative keeps its sign outside the grouping', group(-4500), '-4,500');
test('a decimal part is left ungrouped', group(1234.5678), '1,234.5678');
test('the built-in agrees on the whole numbers', (4500).toLocaleString('en-US'), '4,500');
test('the popular one-liner also works, up to a point', byRegex(4500), '4,500');
test('but it groups the DECIMALS, which is wrong', byRegex(1234.5678), '1,234.5,678');
test('splitting on the dot first is the fix', group(1234.5678).split('.')[1], '5678');
toLocaleString or Intl.NumberFormat is the right answer in real code: they know that not every locale groups in threes, and not every locale uses a comma. Writing it by hand is worth doing once to see where the popular shortcut fails.
That failure is instructive. \B(?=([0-9]{3})+(?![0-9])) means "a position inside a word, followed by some multiple of three digits and then a non-digit" — and the digits after a decimal point satisfy that perfectly. It passes every test written with an integer.
51 Comparing strings, and why 10 is less than 9
easyCompare strings for equality and for order. Explain why '10' < '9' is true.
Stuck? Three hints, smallest first
- 1. Strings have value semantics, so
===compares contents, not identity. - 2.
<and>compare code unit by code unit, stopping at the first difference. - 3. Between a string and a number,
==converts and===does not.
console.log("'la' + 'gos' === 'lagos' :", 'la' + 'gos' === 'lagos');
console.log("'apple' < 'banana' :", 'apple' < 'banana');
console.log("'10' < '9' :", '10' < '9', "<- '1' comes before '9'");
console.log('10 < 9 :', 10 < 9);
console.log("'10' == 10 :", '10' == 10, " '10' === 10 :", '10' === 10);
Output
12 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 12 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('two identical strings are equal', 'lagos' === 'lagos', true);
test('there is no separate identity to worry about', 'la' + 'gos' === 'lagos', true);
test('case matters', 'Lagos' === 'lagos', false);
test('a trailing space matters', 'lagos ' === 'lagos', false);
test('< compares code unit by code unit, like a dictionary', 'apple' < 'banana', true);
test("so '10' is less than '9', because '1' sorts before '9'", '10' < '9', true);
test('while the numbers compare the other way', 10 < 9, false);
test('== between a string and a number converts the string', '10' == 10, true);
test('=== does not, so it is false', '10' === 10, false);
test('a prefix sorts before the longer string', 'lag' < 'lagos', true);
test('and the empty string sorts before everything', '' < 'a', true);
test('every capital sorts before every lower-case letter', 'Z' < 'a', true);
'10' < '9' is the bug behind most mis-sorted lists: the comparison is correct, the values are the wrong type. It appears whenever numbers arrive as text — from a form, a CSV, a URL, a JSON field that should have been a number — and it looks right for single digits, which is how it survives testing.
'Z' < 'a' is the same mechanism, and it is why a default sort separates the cases. Both are fixed by comparing the thing you actually meant: numbers with a - b, text with a collator.
52 Sorting file names naturally
fairSort ['file10.txt', 'file9.txt', 'file1.txt'] so that 9 comes before 10. Then show the trick that makes the plain sort correct without a collator.
Stuck? Three hints, smallest first
- 1. The default sort is lexical, so
file10precedesfile9. - 2.
localeComparewith{ numeric: true }reads digit runs as numbers. - 3. Zero-padding the numbers makes the lexical order match the numeric one.
const files = ['file10.txt', 'file9.txt', 'file1.txt'];
const lexical = [...files].sort();
const natural = [...files].sort((a, b) => a.localeCompare(b, 'en', { numeric: true }));
// A collator built once is the tidier form for a list.
const naturally = new Intl.Collator('en', { numeric: true }).compare;
console.log('lexical :', lexical);
console.log('natural :', natural);
console.log('[10, 9, 1].sort() :', [10, 9, 1].sort(), '<- numbers, converted to strings first');
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.
test('the default sort is lexical, so 10 comes before 9', lexical, ['file1.txt', 'file10.txt', 'file9.txt']);
test('numeric collation reads the digits as a number', natural, ['file1.txt', 'file9.txt', 'file10.txt']);
test('a reusable collator gives the same order', ['b10', 'b9'].sort(naturally), ['b9', 'b10']);
test('sort with no comparator converts to strings, even for numbers', [10, 9, 1].sort(), [1, 10, 9]);
test('which is why numbers need one', [10, 9, 1].sort((a, b) => a - b), [1, 9, 10]);
test('sort mutates, so the original list was copied', files, ['file10.txt', 'file9.txt', 'file1.txt']);
test('zero-padding makes the lexical sort correct with no collator', ['file09', 'file10'].sort(), ['file09', 'file10']);
test('which is the whole reason for naming things 001, 002', ['002', '001', '010'].sort(), ['001', '002', '010']);
test('case-insensitive sorting is a sensitivity, not a toLowerCase', ['b', 'A'].sort(new Intl.Collator('en', { sensitivity: 'base' }).compare), ['A', 'b']);
test('and numeric collation leaves letters alone', ['b', 'a'].sort(naturally), ['a', 'b']);
[10, 9, 1].sort() returning [1, 10, 9] is the same lexical rule applied to numbers, because sort with no comparator converts every element to a string first. It is the most reliably surprising line in the language.
Zero-padding is the pragmatic answer when you control the names: pad to a fixed width and every plain sort, in every tool, gets the order right without knowing anything about collation. That is why log files, exports and image sequences are numbered 001.
53 Longest common prefix
hardGiven a list of strings, find the longest prefix they all share. ['flower', 'flow', 'flight'] gives 'fl'.
Stuck? Three hints, smallest first
- 1. Start with the first word as a candidate and shorten it until every word starts with it.
- 2.
startsWithsaves you comparing character by character. - 3. Sorting the list means only the first and last entries need comparing.
function longestCommonPrefix(words) {
if (words.length === 0) return '';
let prefix = words[0];
for (const word of words.slice(1)) {
while (prefix !== '' && !word.startsWith(prefix)) prefix = prefix.slice(0, -1);
if (prefix === '') return '';
}
return prefix;
}
// Sorted lexically, the two most different strings are the first and the last,
// so comparing only those two is enough.
function byExtremes(words) {
if (words.length === 0) return '';
const sorted = [...words].sort();
const first = sorted[0];
const last = sorted.at(-1);
let i = 0;
while (i < first.length && first[i] === last[i]) i++;
return first.slice(0, i);
}
console.log("['flower', 'flow', 'flight'] ->", longestCommonPrefix(['flower', 'flow', 'flight']));
console.log("['dog', 'racecar', 'car'] ->", JSON.stringify(longestCommonPrefix(['dog', 'racecar', 'car'])));
console.log('paths ->', longestCommonPrefix(['/usr/local/bin', '/usr/local/lib', '/usr/lib']));
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.
test('the classic example', longestCommonPrefix(['flower', 'flow', 'flight']), 'fl');
test('no shared start gives an empty string', longestCommonPrefix(['dog', 'racecar', 'car']), '');
test('one word is its own prefix', longestCommonPrefix(['single']), 'single');
test('an empty list has no prefix', longestCommonPrefix([]), '');
test('an empty word in the list forces an empty prefix', longestCommonPrefix(['abc', '']), '');
test('identical words give the whole word', longestCommonPrefix(['abc', 'abc']), 'abc');
test('the comparison is case sensitive', longestCommonPrefix(['Flow', 'flower']), '');
test('a shared path prefix, which is what this is usually for', longestCommonPrefix(['/usr/local/bin', '/usr/local/lib', '/usr/lib']), '/usr/l');
test('the sorted two-extremes version gives the same answer', byExtremes(['flower', 'flow', 'flight']), 'fl');
test('and agrees on every case above', [['flower', 'flow', 'flight'], ['dog', 'racecar', 'car'], ['abc', 'abc'], ['abc', ''], ['single'], []].every((list) => byExtremes(list) === longestCommonPrefix(list)), true);
Both versions are worth having in your head. The first shortens a candidate and stops early on the first word that shares nothing — good when a mismatch is likely. The second sorts, which costs more up front, and then does a single character walk.
The insight behind byExtremes is that lexical order puts the two least similar strings at the ends, so their common prefix is the common prefix of the whole set. It is a neat argument and it generalises badly — it depends on the order being lexical, so a collator would break it.
Note what '/usr/l' means: this is a string prefix, not a path prefix. If you want the common directory, split on / and compare segments, or you will produce paths that do not exist.
54 Longest substring without repeating characters
hardFind the longest run of characters in a string with no repeats. 'abcabcbb' gives 'abc'; 'pwwkew' gives 'wke'.
Stuck? Three hints, smallest first
- 1. Keep a window with a start and an end, and remember where each character was last seen.
- 2. On a repeat, move the start to just after the previous sighting, and never backwards.
- 3. The
>= startcheck is what stops a stale index from dragging the window back.
function longestUnique(text) {
const lastSeen = new Map();
let start = 0;
let best = '';
for (let i = 0; i < text.length; i++) {
const c = text[i];
// Only move the start if the previous sighting is inside the window.
if (lastSeen.has(c) && lastSeen.get(c) >= start) start = lastSeen.get(c) + 1;
lastSeen.set(c, i);
if (i - start + 1 > best.length) best = text.slice(start, i + 1);
}
return best;
}
for (const t of ['abcabcbb', 'bbbbb', 'pwwkew', 'dvdf', 'tmmzuxt', 'abcdef']) {
console.log(t.padEnd(10), '->', longestUnique(t));
}
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.
test('the classic example', longestUnique('abcabcbb'), 'abc');
test('all the same character gives one', longestUnique('bbbbb'), 'b');
test('the window moves past the repeat rather than restarting', longestUnique('pwwkew'), 'wke');
test('a string with no repeats is its own answer', longestUnique('abcdef'), 'abcdef');
test('an empty string gives an empty answer', longestUnique(''), '');
test('the case that catches a naive reset to zero', longestUnique('dvdf'), 'vdf');
test('and the one that catches a stale index', longestUnique('tmmzuxt'), 'mzuxt');
test('the answer never contains a repeat', new Set(longestUnique('tmmzuxt')).size, longestUnique('tmmzuxt').length);
test('the window works in code units, so its length is not a character count', longestUnique('👍👍').length, 2);
test('while the answer itself is one character', [...longestUnique('👍👍')].length, 1);
The lastSeen.get(c) >= start guard is the whole exercise. Without it, 'dvdf' moves the start back to 1 when it meets the second d, then the f is measured against a window that has already been left behind. Both 'dvdf' and 'tmmzuxt' are in the assertions because they fail different naive versions.
It is O(n) with one pass and one map, which is the point of a sliding window: the start only ever moves forward, so no character is examined more than twice.
Indexing with text[i] means the window counts code units. For text with emoji, run it over [...text] and track array indexes instead.
55 Word frequency in a passage
hardCount how often each word appears in a passage and report them commonest first, breaking ties alphabetically.
Stuck? Three hints, smallest first
- 1. Lower-case, then match words rather than splitting, so punctuation is left out.
- 2. Use a
Map: a word could beconstructor. - 3. A two-part comparator sorts by count and then by word.
function wordFrequency(text) {
const counts = new Map();
for (const word of text.toLowerCase().match(/[a-z']+/g) || []) {
counts.set(word, (counts.get(word) ?? 0) + 1);
}
return counts;
}
// Count descending, then alphabetically, so the order is stable and testable.
const ranked = (text) =>
[...wordFrequency(text).entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
const passage = 'The cat sat on the mat. The mat was flat.';
const freq = wordFrequency(passage);
const top = ranked(passage);
for (const [word, n] of top) console.log(word.padEnd(6), n);
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.
test('the commonest word, with its count', top[0], ['the', 3]);
test('and the next', top[1], ['mat', 2]);
test('seven distinct words', freq.size, 7);
test('the full stop is not part of a word', freq.get('mat'), 2);
test('case is folded, so The and the are one word', freq.get('the'), 3);
test('a word that is absent gives undefined, not zero', freq.get('lagos'), undefined);
test('the counts add up to the number of words', [...freq.values()].reduce((a, b) => a + b, 0), 10);
test('an apostrophe is kept inside a word', [...wordFrequency("it's it's").keys()], ["it's"]);
test('a Map is used because a word could be called constructor', wordFrequency('constructor').get('constructor'), 1);
test('where a plain object already has that name on it', typeof {}.constructor, 'function');
test('ties break alphabetically, so the tail is stable', top.slice(2).map(([w]) => w), ['cat', 'flat', 'on', 'sat', 'was']);
Matching words with /[a-z']+/g beats splitting on whitespace, because it drops the punctuation as a side effect instead of needing a second cleaning pass. The cost is that the character class is now a definition of "word" you have to defend — this one keeps apostrophes and loses hyphens and accents.
The tie-break is not cosmetic. Without it the order among equal counts is whatever the Map happened to produce, so the output changes when the input is reordered and the test becomes flaky. b[1] - a[1] || a[0].localeCompare(b[0]) is the general shape: numeric key first, then a deterministic fallback.
The constructor assertion is the reason for the Map. With a plain object, counts['constructor'] starts out as a function, so (counts[w] ?? 0) + 1 produces a string containing a function body.
56 Caesar cipher
hardShift every letter along the alphabet by n places, wrapping round at the end, and leave everything else alone. Decoding must be the same function with the opposite shift.
Stuck? Three hints, smallest first
- 1. Work from the code of
aorA, so the case is preserved. - 2. Wrap with the remainder operator, but remember it can return a negative number.
- 3.
((x % 26) + 26) % 26is the version that handles a negative shift.
function caesar(text, shift) {
return text.replace(/[a-zA-Z]/g, (c) => {
const code = c.charCodeAt(0);
const base = code < 97 ? 65 : 97; // 'A' or 'a'
// % can return a negative number, so add 26 before taking it again.
const moved = (((code - base + shift) % 26) + 26) % 26;
return String.fromCharCode(base + moved);
});
}
console.log("caesar('abc', 3) :", caesar('abc', 3));
console.log("caesar('xyz', 3) :", caesar('xyz', 3), '<- wrapped');
console.log("caesar('Hello, World!', 3):", caesar('Hello, World!', 3));
console.log("caesar('abc', -1) :", caesar('abc', -1), '<- negative, also wrapped');
console.log('ROT13 twice returns the original:', caesar(caesar('Lagos', 13), 13));
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.
test('three places forward', caesar('abc', 3), 'def');
test('the end of the alphabet wraps round', caesar('xyz', 3), 'abc');
test('case is preserved', caesar('Hello, World!', 3), 'Khoor, Zruog!');
test('punctuation and digits are left alone', caesar('a1!', 3), 'd1!');
test('a negative shift goes backwards and wraps', caesar('abc', -1), 'zab');
test('shifting by the alphabet length changes nothing', caesar('abc', 26), 'abc');
test('and 29 is the same as 3', caesar('abc', 29), caesar('abc', 3));
test('decoding is the same function with the opposite shift', caesar(caesar('Lagos 2025', 7), -7), 'Lagos 2025');
test('ROT13 is its own inverse', caesar(caesar('Lagos', 13), 13), 'Lagos');
test('an accented letter is outside a-zA-Z, so it is not shifted', caesar('café', 1), 'dbgé');
test('the length never changes', caesar('Hello, World!', 5).length, 'Hello, World!'.length);
((x % 26) + 26) % 26 is the line worth copying. JavaScript's % keeps the sign of the left operand, so -1 % 26 is -1, not 25. Without the correction a negative shift produces code points below 'a' and the output turns into punctuation. It is the same fix needed for any wrap-around index.
The accent assertion is the honest limit: [a-zA-Z] is a 26-letter assumption, and every character outside it passes through untouched. That is correct behaviour for a cipher defined over the English alphabet, and it is worth stating rather than discovering.
A Caesar cipher is a puzzle, not security. It has 25 possible keys and yields to letter-frequency counting instantly — which is what exercise 34 was building.
57 Run-length encode
hardCompress 'aaabbc' to '3a2bc': a run of two or more gets a count, a single character does not. Then find the input that makes the output ambiguous.
Stuck? Three hints, smallest first
- 1. Walk the string, measure the run at each position, and jump past it.
- 2. Omitting the count for a run of one keeps the output from growing.
- 3. Ask what happens when the text itself contains digits.
function encode(text) {
let out = '';
let i = 0;
while (i < text.length) {
let run = 1;
while (text[i + run] === text[i]) run++;
out += (run > 1 ? String(run) : '') + text[i]; // no count for a run of one
i += run;
}
return out;
}
// The always-count variant. Simpler, unambiguous about where a count starts,
// and able to make the output longer than the input.
const encodeStrict = (text) => text.replace(/(.)\1*/gs, (run, c) => String(run.length) + c);
console.log("encode('aaabbc') :", encode('aaabbc'));
console.log("encode('abc') :", encode('abc'));
console.log("encodeStrict('abc') :", encodeStrict('abc'), '<- longer than the input');
console.log("encode('a3') :", encode('a3'), '<- is that a run, or the text "a3"?');
Output
12 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 12 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('a run of three collapses', encode('aaabbc'), '3a2bc');
test('a single character carries no count', encode('abc'), 'abc');
test('an empty string encodes to nothing', encode(''), '');
test('a run of two', encode('aa'), '2a');
test('a run longer than nine uses two digits', encode('a'.repeat(12)), '12a');
test('the always-count variant writes a count every time', encodeStrict('aaabbc'), '3a2b1c');
test('which makes it longer than the input on text with no runs', encodeStrict('abc'), '1a1b1c');
test('measurably longer', encodeStrict('abc').length > 'abc'.length, true);
test('while the compact version never grows', encode('abc').length <= 'abc'.length, true);
test('but the compact output is AMBIGUOUS when the text has digits', encode('a3'), 'a3');
test('and the strict form does not rescue it either', encodeStrict('a3'), '1a13');
test('so run-length encoding needs an alphabet with no digits in it', /[0-9]/.test('aaabbc'), false);
Run-length encoding is only a compression scheme if the alphabet excludes the count characters. encode('a3') produces 'a3', which is exactly what a run of three would look like without its letter, and encodeStrict('a3') produces '1a13', where a greedy digit reader takes 13 as the count and runs off the end. Neither is recoverable, which is why real formats reserve an escape byte.
The two variants trade different things. Omitting the count for a run of one guarantees the output never grows, at the cost of the ambiguity above. Always writing it makes parsing trivial and makes worst-case output twice the input.
58 Run-length decode
hardExpand '3a2bc' back to 'aaabbc'. Make sure a two-digit count is read as one number, and check what happens to a malformed input.
Stuck? Three hints, smallest first
- 1. A count is a run of digits followed by the character it applies to.
- 2. Reading only one digit breaks the moment a count reaches ten.
- 3.
repeatdoes the expansion once you have the count as a number.
const decode = (encoded) =>
encoded.replace(/([0-9]+)([^0-9])/g, (_, count, c) => c.repeat(Number(count)));
// The same idea reading a single digit. Fine until a count reaches ten.
const decodeOneDigit = (encoded) =>
encoded.replace(/([0-9])([^0-9])/g, (_, count, c) => c.repeat(Number(count)));
// From exercise 57, so the round trip can be checked here.
function encode(text) {
let out = '';
let i = 0;
while (i < text.length) {
let run = 1;
while (text[i + run] === text[i]) run++;
out += (run > 1 ? String(run) : '') + text[i];
i += run;
}
return out;
}
console.log("decode('3a2bc') :", decode('3a2bc'));
console.log("decode('12a') :", decode('12a'));
console.log("decodeOneDigit('12a'):", decodeOneDigit('12a'), '<- read 2, left the 1 behind');
console.log('round trip :', decode(encode('aaabbcccc')));
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.
test('a count expands its character', decode('3a2bc'), 'aaabbc');
test('text with no counts decodes to itself', decode('abc'), 'abc');
test('an empty string decodes to nothing', decode(''), '');
test('a two-digit count is read as one number', decode('12a'), 'a'.repeat(12));
test('reading only one digit gets that badly wrong', decodeOneDigit('12a'), '1aa');
test('encode and decode round-trip on text with runs', decode(encode('aaabbcccc')), 'aaabbcccc');
test('and on text with none', decode(encode('abcdef')), 'abcdef');
test('a count of zero deletes the character', decode('0a'), '');
test('a trailing count has nothing to repeat, so it is left as text', decode('ab3'), 'ab3');
test('which means a malformed input fails silently rather than loudly', decode('3'), '3');
test('so validate the shape first if the input is not yours', /^([0-9]*[^0-9])*$/.test('3a2bc'), true);
([0-9]+) rather than ([0-9]) is the entire difference between a decoder that works and one that works up to nine. decodeOneDigit('12a') returning '1aa' shows the failure mode: the leading digit is not consumed, so it lands in the output as literal text and the result is plausible enough to ship.
The silent failure on malformed input is worth noticing rather than fixing carelessly. decode('3') returns '3' because nothing matched, and decode('ab3') returns 'ab3' for the same reason. If the encoded string came from elsewhere, validate its shape and reject it — a decoder that never fails is a decoder that cannot tell you the data was corrupt.
59 Balanced brackets
hardDecide whether every bracket in a string is closed by the right kind, in the right order. '([)]' must be rejected.
Stuck? Three hints, smallest first
- 1. Push every opener onto a stack and match every closer against the top of it.
- 2. A closer arriving with an empty stack is already a failure.
- 3. A counter cannot do this, because it has no memory of which kind was opened.
function isBalanced(text) {
const closes = { ')': '(', ']': '[', '}': '{' };
const stack = [];
for (const c of text) {
if (c === '(' || c === '[' || c === '{') stack.push(c);
else if (c in closes) {
// pop() on an empty stack gives undefined, which never matches an opener.
if (stack.pop() !== closes[c]) return false;
}
}
return stack.length === 0;
}
// Counting only. Cheaper, and blind to the ORDER of the pairs.
function byCounting(text) {
let depth = 0;
for (const c of text) {
if (c === '(' || c === '[') depth++;
else if (c === ')' || c === ']') depth--;
if (depth < 0) return false;
}
return depth === 0;
}
for (const t of ['(a[b]{c})', '(]', '(', ')(', '([)]', '{[()()]}']) {
console.log(t.padEnd(10), 'stack:', String(isBalanced(t)).padEnd(6), 'counter:', byCounting(t));
}
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.
test('a matched set is balanced', isBalanced('(a[b]{c})'), true);
test('a mismatched pair is not', isBalanced('(]'), false);
test('an unclosed bracket is not', isBalanced('('), false);
test('a closer arriving first is not', isBalanced(')('), false);
test('an empty string is balanced', isBalanced(''), true);
test('text with no brackets is balanced', isBalanced('no brackets here'), true);
test('nesting several levels deep is fine', isBalanced('{[()()]}'), true);
test('one missing closer is caught by the empty-stack check', isBalanced('{[()()]'), false);
test('crossing pairs are NOT balanced', isBalanced('([)]'), false);
test('but a counter thinks they are, because it never checks the kind', byCounting('([)]'), true);
test('the counter does still catch a closer arriving first', byCounting(')('), false);
'([)]' is the input that separates a real answer from a counter. Both have the same number of openers and closers and both are non-negative throughout, so depth counting reports success. Only a stack remembers which bracket is waiting.
stack.pop() returning undefined on an empty stack is what makes the closer-arrives-first case fall out for free: undefined never equals an opener, so the comparison fails without a separate length check.
The obvious extension is the reason real parsers are harder: brackets inside a string literal or a comment do not count. Handling that means tracking whether you are inside quotes, which is the first step towards actually tokenising the language.
60 Wrap text to a column width
hardBreak a paragraph into lines no wider than n characters, without splitting a word. Decide what to do with a word that is wider than the column.
Stuck? Three hints, smallest first
- 1. Build the current line word by word and start a new one when the next word will not fit.
- 2. The fit test has to include the space you are about to add.
- 3. A word longer than the column cannot fit anywhere, so either it overflows or you break it deliberately.
function wrap(text, width) {
const lines = [];
let line = '';
for (const word of text.split(/\s+/).filter(Boolean)) {
if (line === '') line = word; // first word always goes on
else if (line.length + 1 + word.length <= width) line += ' ' + word;
else { lines.push(line); line = word; }
}
if (line !== '') lines.push(line);
return lines;
}
// A word wider than the column has to be cut on purpose, not by accident.
const hardBreak = (word, width) => word.match(new RegExp('.{1,' + width + '}', 'g')) || [];
const line = 'The quick brown fox jumps over the lazy dog';
console.log(wrap(line, 10).join('\n'));
console.log('---');
console.log('at width 20 :', wrap(line, 20).length, 'lines');
console.log('a long word :', wrap('Antidisestablishmentarianism', 10), '<- overflows');
console.log('broken :', hardBreak('Antidisestablishmentarianism', 10));
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.
test('the paragraph breaks into lines that fit', wrap(line, 10), ['The quick', 'brown fox', 'jumps over', 'the lazy', 'dog']);
test('no line is wider than the column', wrap(line, 10).every((l) => l.length <= 10), true);
test('a line that exactly fills the width is allowed', wrap(line, 10)[2].length, 10);
test('a wider column needs fewer lines', wrap(line, 20).length, 3);
test('an empty text wraps to no lines at all', wrap('', 10), []);
test('runs of whitespace are normalised to one space', wrap('a b', 10), ['a b']);
test('a word longer than the column OVERFLOWS rather than breaking', wrap('Antidisestablishmentarianism', 10), ['Antidisestablishmentarianism']);
test('breaking it needs a deliberate hard break', hardBreak('Antidisestablishmentarianism', 10), ['Antidisest', 'ablishment', 'arianism']);
test('rendering is a join, and separate from the wrapping', wrap('a b c', 3).join('\n'), 'a b\nc');
test('no word is lost or duplicated', wrap(line, 10).join(' '), line);
The fit test is line.length + 1 + word.length, and the + 1 is the space. Leaving it out gives lines exactly one character too wide, which passes casual inspection and fails against a hard terminal width.
The overflow case is a design decision, not an oversight. Overflowing keeps every word readable and can break a layout; hard-breaking guarantees the width and produces nonsense like 'ablishment'. Long URLs are the case that forces you to choose.
This wrapper also normalises whitespace, because it splits on /\s+/. Double spaces after a full stop and any deliberate alignment in the input are lost. Returning lines rather than a joined string is what keeps the decision about the line ending with the caller.
61 Levenshtein distance
hardCount the single-character insertions, deletions and substitutions needed to turn one string into another. 'kitten' to 'sitting' is 3.
Stuck? Three hints, smallest first
- 1. Fill a table where cell (i, j) is the distance between the first i and first j characters.
- 2. Each cell is the cheapest of: delete, insert, or substitute.
- 3. Only the previous row is ever needed, so two arrays are enough.
function levenshtein(a, b) {
if (a === b) return 0;
if (a === '') return b.length;
if (b === '') return a.length;
// Row 0: turning '' into the first j characters of b costs j insertions.
let previous = [...Array(b.length + 1).keys()];
for (let i = 1; i <= a.length; i++) {
const current = [i];
for (let j = 1; j <= b.length; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
current[j] = Math.min(
previous[j] + 1, // delete from a
current[j - 1] + 1, // insert into a
previous[j - 1] + cost // substitute
);
}
previous = current; // only the last row matters
}
return previous[b.length];
}
for (const [a, b] of [['kitten', 'sitting'], ['flaw', 'lawn'], ['saturday', 'sunday'], ['abc', 'cba']]) {
console.log((a + ' -> ' + b).padEnd(24), levenshtein(a, b));
}
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.
test('kitten to sitting', levenshtein('kitten', 'sitting'), 3);
test('flaw to lawn', levenshtein('flaw', 'lawn'), 2);
test('identical strings are zero apart', levenshtein('lagos', 'lagos'), 0);
test('an empty string costs one edit per character', levenshtein('', 'abc'), 3);
test('and the same the other way round', levenshtein('abc', ''), 3);
test('saturday to sunday', levenshtein('saturday', 'sunday'), 3);
test('the distance is symmetric', levenshtein('sitting', 'kitten'), levenshtein('kitten', 'sitting'));
test('a reversal costs two substitutions: there is no transpose operation', levenshtein('abc', 'cba'), 2);
test('the distance never exceeds the longer string', levenshtein('abc', 'wxyz') <= 4, true);
test('and the triangle inequality holds', levenshtein('kitten', 'sunday') <= levenshtein('kitten', 'sitting') + levenshtein('sitting', 'sunday'), true);
test('an emoji costs TWO edits, because the walk is over code units', levenshtein('👍', ''), 2);
Keeping only the previous row drops the memory from O(n × m) to O(m) and costs nothing except the ability to reconstruct which edits were needed. If you want the edit script rather than the count, keep the whole table and walk back through it.
No transposition is the property to remember: swapping two adjacent characters costs 2 here, so 'teh' and 'the' are as far apart as 'teh' and 'toa'. For typo correction that is usually wrong, and Damerau-Levenshtein — which adds a swap operation at cost 1 — is the variant you want.
Because it walks code units, an emoji counts as two edits. For user-facing fuzzy matching, run it over grapheme clusters instead, or a one-character difference in an emoji will outrank a real one in a word.
62 Compress whitespace to single spaces
fairFold every run of whitespace in a block of text down to one space and trim the ends. Then check what it did to a non-breaking space.
Stuck? Three hints, smallest first
- 1. One
replacewith/\s+/ghandles spaces, tabs and newlines together. - 2. Trim as well, or a leading run becomes a leading space.
- 3.
\sincludes a non-breaking space, so it gets replaced along with the rest.
const squish = (text) => text.trim().replace(/\s+/g, ' ');
console.log("squish('a b') :", JSON.stringify(squish('a b')));
console.log("squish(' a b ') :", JSON.stringify(squish(' a b ')));
console.log("squish('one\\n\\ntwo') :", JSON.stringify(squish('one\n\ntwo')));
console.log('a non-breaking space is replaced too:', JSON.stringify(squish('a\u00a0b')));
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.
test('a run of spaces becomes one', squish('a b'), 'a b');
test('leading and trailing whitespace goes', squish(' a b '), 'a b');
test('tabs and newlines are whitespace too', squish('a\t\nb'), 'a b');
test('a paragraph folds onto one line', squish('one\n\ntwo\n\nthree'), 'one two three');
test('already-tidy text is unchanged', squish('a b c'), 'a b c');
test('whitespace-only text becomes empty', squish(' \t\n '), '');
test('a non-breaking space matches \\s, so it is REPLACED', squish('a\u00a0b'), 'a b');
test('and is therefore gone from the result', squish('a\u00a0b').includes('\u00a0'), false);
test('an explicit class is how you keep it', 'a\u00a0b'.replace(/[ \t\n]+/g, ' '), 'a\u00a0b');
test('trimming before or after gives the same answer', 'a b '.replace(/\s+/g, ' ').trim(), squish('a b '));
Overlaps Regular expressions 12. That one is about the pattern; this one is about the two decisions around it — trim as well as replace, and know what \s includes.
\s covering U+00A0 is usually what you want and occasionally destructive. A non-breaking space is there on purpose: it holds 10 kg or Fig. 3 together across a line break, and squishing turns it into an ordinary space that can wrap. When that matters, list the characters you mean rather than using the shorthand.
Trim first or last makes no difference to the result, as the last assertion shows. Trimming first is marginally cheaper, since there is less text for the replacement to walk.
63 Format a text table
hardLay out rows of text as aligned columns. Each column is as wide as its widest value, up to a limit; anything over the limit is truncated with an ellipsis; every line comes out the same width.
Stuck? Three hints, smallest first
- 1. Compute the width of each column from the data before rendering anything.
- 2. Cap it with
Math.min, so one long value cannot stretch the table. - 3. Truncating costs one character for the ellipsis, then
padEndbrings the cell back to width.
const rows = [
['Product', 'Qty', 'Price'],
['Rice, 50kg', '2', '45000'],
['Palm oil', '12', '1200'],
['Extremely long product name', '1', '999'],
];
const LIMIT = 14;
// Widest value per column, capped, so one long cell cannot stretch the table.
const columnWidths = (rows, limit) =>
rows[0].map((_, i) => Math.min(limit, Math.max(...rows.map((r) => r[i].length))));
// Truncation spends one character on the ellipsis, then pads back to width.
const cell = (text, width) =>
(text.length > width ? text.slice(0, width - 1) + '…' : text).padEnd(width);
const formatTable = (rows, limit) => {
const widths = columnWidths(rows, limit);
return rows.map((r) => r.map((c, i) => cell(c, widths[i])).join(' | '));
};
const table = formatTable(rows, LIMIT);
const widths = columnWidths(rows, LIMIT);
for (const line of table) console.log(line);
console.log('column widths:', widths);
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.
test('the first column is capped at the limit', widths[0], 14);
test('the others are only as wide as their widest value', widths.slice(1), [3, 5]);
test('every line comes out the same width', new Set(table.map((l) => l.length)).size, 1);
test('which is the columns plus two separators', table[0].length, 14 + 3 + 5 + 6);
test('the header row reads properly', table[0], 'Product | Qty | Price');
test('a short value is padded on the right', table[1], 'Rice, 50kg | 2 | 45000');
test('an over-long value is cut and marked', table[3].slice(0, 14), 'Extremely lon…');
test('the ellipsis comes out of the budget, not on top of it', table[3].slice(0, 14).length, 14);
test('and the rest of that row still lines up', table[3].trimEnd(), 'Extremely lon… | 1 | 999');
test('left alignment is padEnd; a numeric column wants padStart instead', '999'.padStart(5), ' 999');
This is the whole section in one function: Math.max over lengths, Math.min to cap, slice to truncate, … to mark it, padEnd to align, join to assemble. Nothing here is new after the previous sixty-two.
Two decisions are worth stating. Deriving the widths from the data means the table cannot be broken by an unexpectedly long value, only capped. And truncating inside the budget rather than appending the ellipsis afterwards is what keeps every line the same width — the mistake is slice(0, width) + '…', which is one character too wide, every time.
Column alignment counted in characters only works in a monospaced font, and it counts code units, so a cell containing an emoji will be one column narrow on screen. For a terminal that is fine. For anything proportionally spaced, alignment belongs to the layout, not to the string.