JavaScript: String, 1–40
Cutting, joining, padding, casing and checking text.
Exercises 1 to 40 of 63
1 How long is a string?
easyReport the length of a string. Then work out what length is actually counting, and whether you can change it.
Stuck? Three hints, smallest first
- 1.
lengthis a property, not a method: no parentheses. - 2. Spaces, tabs and punctuation are all characters, so they all count.
- 3. Strings are immutable. Try assigning to
lengthand see what happens.
const plate = 'LAG-441-XA';
const size = (text) => text.length;
// Strings are immutable, so length is read-only. In strict mode, which is what
// a module and this panel both run in, assigning to it throws rather than being
// quietly ignored.
const refused = (() => {
const s = 'abc';
try { s.length = 10; return 'changed to ' + s.length; } catch (e) { return e.constructor.name; }
})();
console.log(plate, 'has', size(plate), 'characters');
console.log('the empty string has', size(''), 'characters');
console.log('assigning to length:', refused);
Output
9 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 9 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('the plate is ten characters', size(plate), 10);
test('the empty string is zero', size(''), 0);
test('a single space is one', size(' '), 1);
test('spaces inside a sentence count too', size('a b c'), 5);
test('length is a number, not a function', typeof 'abc'.length, 'number');
test('a tab is one character even though it looks wider', size('\t'), 1);
test('the last valid index is length - 1', 'abc'['abc'.length - 1], 'c');
test('assigning to length throws in strict mode', refused, 'TypeError');
test('so the value never changes', 'abc'.length, 3);
length is the count of UTF-16 code units, not of characters a reader would point at. For plain Latin text those are the same number, which is why the difference stays hidden until an emoji or an accent turns up — exercises 43 and 44 are where it stops being hidden.
The read-only part matters more than it looks. Assigning to length throws in strict mode and is silently ignored outside it, which is the worse of the two outcomes: code that looks like it truncated a string and did nothing at all.
Answers JS String 1 in the original set.
2 Read one character out
easyGet the first and last character of a word, with brackets and with charAt. Then ask both of them for a character that is not there.
Stuck? Three hints, smallest first
- 1. Bracket indexing reads like an array:
text[0]. - 2.
charAtdoes the same job but disagrees when the index is out of range. - 3. There is no character type in JavaScript, so what you get back is a one-character string.
const word = 'Lagos';
const first = (text) => text[0];
const last = (text) => text[text.length - 1];
console.log('first:', first(word), ' last:', last(word));
console.log('--- out of range ---');
console.log('word[99] :', word[99], '<- undefined');
console.log('word.charAt(99):', JSON.stringify(word.charAt(99)), '<- an empty string');
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('brackets give one character', first(word), 'L');
test('charAt gives the same', word.charAt(0), 'L');
test('the last character', last(word), 's');
test('past the end, brackets give undefined', word[99], undefined);
test('but charAt gives an EMPTY STRING', word.charAt(99), '');
test('a negative index with brackets is undefined', word[-1], undefined);
test('and charAt(-1) is empty too, not the last character', word.charAt(-1), '');
test('charAt with no argument reads index 0', word.charAt(), 'L');
test('what comes back is a string of length one', word[0].length, 1);
test('so there is no character type to compare against', typeof word[0], 'string');
The two disagree exactly where it matters. text[99] is undefined; text.charAt(99) is ''. Code that tests the result with if (c) treats both as falsy and gets away with it, but code that does c.toUpperCase() throws on one and not the other.
Neither reads a negative index. That is what at is for, and it is the next exercise.
Answers JS String 2 in the original set.
3 Count back from the end with at
easyGet the last character, and the one before it, without doing arithmetic on length.
Stuck? Three hints, smallest first
- 1.
ataccepts a negative index and counts from the right. - 2.
at(-1)is the last,at(-2)the one before it. - 3. Out of range it returns
undefined, the same way brackets do.
const word = 'Lagos';
const lastChar = (text) => text.at(-1);
console.log('at(-1):', lastChar(word));
console.log('the old way:', word[word.length - 1]);
console.log('at(-0) :', word.at(-0), '<- the FIRST character, because -0 is 0');
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('at(-1) is the last character', word.at(-1), 's');
test('at(-2) is the one before it', word.at(-2), 'o');
test('at(0) is the first', word.at(0), 'L');
test('positive indexes agree with brackets', word.at(3), word[3]);
test('too far back gives undefined', word.at(-99), undefined);
test('and so does too far forward', word.at(99), undefined);
test('it replaces the length arithmetic exactly', word.at(-1), word[word.length - 1]);
test('slice(-1) does the same job but returns a string', ''.slice(-1), '');
test('so on an empty string the two differ', ''.at(-1), undefined);
test('and at(-0) is the FIRST character, because -0 is 0', word.at(-0), 'L');
at exists so that text[text.length - 1] can stop being written. It works on arrays too, with the same rules.
The -0 case is the one to remember. -0 === 0 is true in JavaScript, so at(-0) is at(0) and returns the first character. Any code that computes an offset and might arrive at zero needs an explicit check rather than trusting the sign.
Answers JS String 3 in the original set.
4 Character codes, and the two ways to read them
easyFind the numeric code of a character and build a string back from codes. Then try it on an emoji and see which method tells the truth.
Stuck? Three hints, smallest first
- 1.
charCodeAtreturns one UTF-16 code unit;String.fromCharCodereverses it. - 2.
codePointAtreturns the whole character, even above 65535. - 3. An emoji is stored as two code units, so
charCodeAt(0)only sees the first half.
const codeOf = (c) => c.charCodeAt(0);
console.log("'A' is", codeOf('A'), " 'a' is", codeOf('a'), " gap:", codeOf('a') - codeOf('A'));
console.log('fromCharCode(78, 71) :', String.fromCharCode(78, 71));
console.log('--- an emoji is two code units ---');
console.log('charCodeAt(0) :', '👍'.charCodeAt(0), '<- the high half only');
console.log('codePointAt(0):', '👍'.codePointAt(0), '<- the whole character');
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 is 65', codeOf('A'), 65);
test('the gap between the cases is 32', codeOf('a') - codeOf('A'), 32);
test("the digit '0' is 48, not 0", codeOf('0'), 48);
test('fromCharCode builds a string from codes', String.fromCharCode(78, 71), 'NG');
test('charCodeAt past the end is NaN, so compare with Number.isNaN', Number.isNaN('A'.charCodeAt(9)), true);
test('for an emoji, charCodeAt sees only the first half', '👍'.charCodeAt(0), 55357);
test('codePointAt sees the whole thing', '👍'.codePointAt(0), 128077);
test('and fromCodePoint puts it back', String.fromCodePoint(128077), '👍');
test('fromCharCode cannot, because the number does not fit in 16 bits', String.fromCharCode(128077) === '👍', false);
test('though it can from both halves', String.fromCharCode(55357, 56397), '👍');
test('codePointAt(1) reads the SECOND half on its own', '👍'.codePointAt(1), 56397);
charCodeAt and codePointAt agree for every character below U+10000, which is all of Latin, Greek, Cyrillic and CJK. They part company on emoji and other astral characters, where charCodeAt(0) hands back a lone surrogate — a number that is a valid code unit but not a valid character.
The 32 gap between 'A' and 'a' is a genuine ASCII fact, but do not build case conversion on it. It holds for the 26 unaccented Latin letters and nothing else.
Answers JS String 4 in the original set.
5 Cut a piece out with slice
easyTake the first four characters of 'JavaScript', then the last three, then everything after index 4.
Stuck? Three hints, smallest first
- 1.
slice(start, end)includesstartand excludesend. - 2. Leave the second argument off to run to the end of the string.
- 3. A negative index counts back from the right, so
slice(-3)is the last three.
const text = 'JavaScript';
console.log('slice(0, 4) :', text.slice(0, 4));
console.log('slice(4) :', text.slice(4));
console.log('slice(-3) :', text.slice(-3));
console.log('slice(-6,-3):', text.slice(-6, -3));
console.log('slice(4, 0) :', JSON.stringify(text.slice(4, 0)), '<- end before start, so nothing');
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 four characters', text.slice(0, 4), 'Java');
test('from index 4 to the end', text.slice(4), 'Script');
test('the end index is excluded', text.slice(0, 1), 'J');
test('a negative start counts from the right', text.slice(-6), 'Script');
test('two negatives make a range', text.slice(-6, -3), 'Scr');
test('the last three characters', text.slice(-3), 'ipt');
test('no arguments copies the whole string', text.slice(), 'JavaScript');
test('a start past the end gives an empty string', text.slice(50), '');
test('an end before the start gives an empty string, not a reversed one', text.slice(4, 0), '');
test('slice never changes the original', text, 'JavaScript');
Two rules cover every case: the end index is exclusive, and a negative index means length + index. Everything else follows, including the empty result from slice(4, 0) — index 4 to index 0 is not a range, so there is nothing to return.
slice is the one to reach for by default. The next exercise shows what substring does with the same arguments, and why the difference is worth knowing rather than discovering.
Answers JS String 5 in the original set.
6 slice against substring
fairCall slice and substring with the same arguments and find the two cases where they disagree: reversed arguments, and negative ones.
Stuck? Three hints, smallest first
- 1. On ordinary, ascending, non-negative arguments they are identical.
- 2.
substringswaps its arguments if the first is larger than the second. - 3.
substringclamps a negative index to 0, whereslicecounts from the right.
const text = 'JavaScript';
// Same call, two answers, twice.
console.log('slice(4, 0) :', JSON.stringify(text.slice(4, 0)));
console.log('substring(4, 0) :', JSON.stringify(text.substring(4, 0)), '<- arguments silently swapped');
console.log('slice(-3) :', text.slice(-3));
console.log('substring(-3) :', text.substring(-3), '<- negative clamped to 0');
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('they agree on ordinary arguments', text.slice(0, 4), text.substring(0, 4));
test('slice with the arguments reversed returns nothing', text.slice(4, 0), '');
test('substring silently SWAPS them instead', text.substring(4, 0), 'Java');
test('slice reads a negative index from the right', text.slice(-3), 'ipt');
test('substring clamps it to zero, so it returns everything', text.substring(-3), 'JavaScript');
test('two negatives collapse to an empty range in substring', text.substring(-6, -3), '');
test('while slice reads them as a real range', text.slice(-6, -3), 'Scr');
test('both clamp an end past the length', text.slice(4, 99), text.substring(4, 99));
test('and both agree with one non-negative argument', text.substring(2), text.slice(2));
test('neither touches the original', text, 'JavaScript');
The swap is the dangerous one. substring(a, b) where a > b returns a non-empty string, so a subtraction that came out backwards produces plausible-looking output instead of nothing, and the bug survives review. slice returns '' and the mistake shows up immediately.
Use slice. The only reason to know substring is to read code that already uses it.
Answers JS String 6 in the original set.
7 substr, the legacy one
fairsubstr(start, length) takes a length as its second argument, not an end index. Show what that changes, and write the same call with slice.
Stuck? Three hints, smallest first
- 1. The second argument is how many characters you want, not where to stop.
- 2. So
substr(4, 3)isslice(4, 4 + 3). - 3. It is in Annex B of the spec — kept for compatibility, not recommended for new code.
const text = 'JavaScript';
// The whole difference is in the second argument.
const byLegacy = text.substr(4, 3); // start 4, three characters
const bySlice = text.slice(4, 4 + 3); // start 4, stop before 7
console.log('substr(4, 3) :', byLegacy);
console.log('slice(4, 7) :', bySlice);
console.log('substr(4) :', text.substr(4));
console.log('substr(4, -1):', JSON.stringify(text.substr(4, -1)), '<- a negative length gives nothing');
Output
9 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 9 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('substr takes a LENGTH, not an end index', text.substr(4, 3), 'Scr');
test('the slice equivalent needs start + length', text.slice(4, 4 + 3), 'Scr');
test('so the two agree once you convert', byLegacy, bySlice);
test('with no length it runs to the end', text.substr(4), 'Script');
test('a negative start counts from the right, like slice', text.substr(-6, 3), 'Scr');
test('a zero length gives an empty string', text.substr(4, 0), '');
test('and so does a negative length', text.substr(4, -1), '');
test('substring with the same numbers means something else entirely', text.substring(4, 3), 'a');
test('it is still a real function, just a deprecated one', typeof text.substr, 'function');
Three methods, three meanings for the second argument: slice and substring take an end index, substr takes a count. That is the entire source of the confusion, and it is why text.substring(4, 3) above returns 'a' — it swapped the arguments to (3, 4) and gave you index 3.
substr is not going away, because too much code uses it, but it is in the compatibility annex rather than the main specification. Write slice.
Answers JS String 7 in the original set.
8 Find where something is
easyFind the first and last position of 'the' in a sentence, and say what happens when it is not there.
Stuck? Three hints, smallest first
- 1.
indexOfsearches forwards,lastIndexOfbackwards. - 2. Both return
-1when there is no match, neverundefined. - 3. Both take a second argument saying where to start.
const line = 'the cat sat on the mat';
console.log('indexOf("the") :', line.indexOf('the'));
console.log('lastIndexOf("the") :', line.lastIndexOf('the'));
console.log('indexOf("dog") :', line.indexOf('dog'));
console.log('--- the classic mistake ---');
console.log('if (line.indexOf("the")) would be FALSE, because the answer is 0');
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 "the" is at index 0', line.indexOf('the'), 0);
test('the last one is at index 15', line.lastIndexOf('the'), 15);
test('a missing needle gives -1, not undefined', line.indexOf('dog'), -1);
test('a start position skips the first hit', line.indexOf('the', 1), 15);
test('lastIndexOf searches backwards from its start position', line.lastIndexOf('the', 14), 0);
test('the search is case sensitive', line.indexOf('The'), -1);
test('an empty needle is found at 0', line.indexOf(''), 0);
test('and at the length when searching backwards', 'abc'.lastIndexOf(''), 3);
test('so compare against -1 rather than testing truthiness', line.indexOf('cat') !== -1, true);
test('because index 0 is FALSY and would read as "not found"', Boolean(line.indexOf('the')), false);
The last assertion is the reason includes was added to the language. if (line.indexOf('the')) is false when the match is at the very start, which is the one position a careless test gets wrong and the one position a test string is most likely to use.
lastIndexOf's second argument is not "search from here to the end" but "the match may not start after here", which is why lastIndexOf('the', 14) returns 0 rather than 15.
Answers JS String 8 in the original set.
9 Does it contain, start with, end with?
easyAnswer three yes-or-no questions about a filename without touching an index. Then hand one of them a regular expression and see what happens.
Stuck? Three hints, smallest first
- 1.
includes,startsWithandendsWithall return a boolean. - 2.
startsWithtakes a position;endsWithtakes a length to pretend the string stops at. - 3. All three refuse a regular expression, and they refuse it loudly.
const file = 'invoice-2025-04.pdf';
// includes, startsWith and endsWith all reject a RegExp argument outright,
// rather than converting it to a string and searching for "/2025/".
const withRegex = (() => {
try { return file.includes(/2025/); } catch (e) { return e.constructor.name; }
})();
console.log('includes("2025") :', file.includes('2025'));
console.log('startsWith("invoice") :', file.startsWith('invoice'));
console.log('endsWith(".pdf") :', file.endsWith('.pdf'));
console.log('startsWith("2025", 8) :', file.startsWith('2025', 8), '<- checks mid-string');
console.log('includes(/2025/) :', withRegex);
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('includes answers without any index arithmetic', file.includes('2025'), true);
test('and says no for something absent', file.includes('2024'), false);
test('startsWith checks the front', file.startsWith('invoice'), true);
test('endsWith checks the back', file.endsWith('.pdf'), true);
test('startsWith takes a position, so it can check mid-string', file.startsWith('2025', 8), true);
test('endsWith takes a length, treating the string as if it stopped there', file.endsWith('04', 15), true);
test('all three are case sensitive', file.endsWith('.PDF'), false);
test('an empty needle is always included', file.includes(''), true);
test('a regular expression is REFUSED, not stringified', withRegex, 'TypeError');
test('use search or test for a pattern instead', /2025/.test(file), true);
These three exist so that indexOf(x) !== -1 can stop being written. They read as the question you were asking.
The refusal of a RegExp is deliberate and useful: the specification checks for one and throws, rather than converting it to the string '/2025/' and quietly reporting no match. That is a rare piece of the language choosing to fail loudly, and it saves a genuinely confusing afternoon.
Answers JS String 9 in the original set.
10 search, and how it differs from indexOf
fairFind the position of the first digit in a line of text. Then work out the two things search can do that indexOf cannot, and the one thing it cannot do that indexOf can.
Stuck? Three hints, smallest first
- 1.
searchtakes a pattern, so it can find "a digit" rather than a known string. - 2. A string argument is converted to a regular expression, which makes
.a wildcard. - 3.
searchaccepts no start position at all, so skipping ahead means slicing first.
const line = 'Order 42 shipped on 2025-04-09';
console.log('search(/[0-9]/) :', line.search(/[0-9]/));
console.log('indexOf("42") :', line.indexOf('42'));
console.log("'abc'.search('.') :", 'abc'.search('.'), '<- a string is converted to a regex');
console.log("'abc'.indexOf('.'):", 'abc'.indexOf('.'), '<- a dot is just a dot here');
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('search finds the first digit without knowing which digit', line.search(/[0-9]/), 6);
test('indexOf would have to be told the exact text', line.indexOf('42'), 6);
test('search returns -1 when nothing matches', line.search(/z/), -1);
test('a string argument becomes a regex, so a dot is a wildcard', 'abc'.search('.'), 0);
test('while indexOf treats the dot literally', 'abc'.indexOf('.'), -1);
test('search ignores the g flag and always reports the first match', line.search(/[0-9]+/g), 6);
test('case-insensitivity is a flag rather than a second method', line.search(/order/i), 0);
test('indexOf needs both sides lower-cased instead', line.toLowerCase().indexOf('order'), 0);
test('search takes NO start position, so a second argument is ignored', line.search(/[0-9]/, 10), 6);
test('skipping ahead means slicing first and adding the offset back', 10 + line.slice(10).search(/[0-9]/), 20);
search is indexOf for patterns, with one missing feature: no start position. The last assertion shows the workaround, and the + 10 is the part people forget — the index you get back is relative to the slice, not to the original string.
The string-becomes-a-regex conversion is worth pausing on. 'abc'.search('.') returns 0 because '.' is compiled into a pattern matching any character. If your needle came from user input, search is the wrong method unless you escape it first, which is exercise 30.
Answers JS String 10 in the original set.
11 match, with and without the g flag
fairPull every number out of a line of text, then pull just the first one and its position. The g flag decides which of those you can have.
Stuck? Three hints, smallest first
- 1. Without
g,matchreturns one match withindex,inputandgroupsattached. - 2. With
g, it returns a plain array of matched strings and nothing else. - 3. No match returns
null, so guard before you read a property off it.
const line = 'Order 42 shipped on 2025-04-09';
const one = line.match(/[0-9]+/); // no g: details about the first match
const all = line.match(/[0-9]+/g); // g: every match, as plain strings
console.log('without g:', one[0], 'at index', one.index);
console.log('with g :', all);
console.log('no match :', line.match(/z+/g), '<- null, not []');
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('without g, match returns the first hit', one[0], '42');
test('and tells you where it was', one.index, 6);
test('with g, match returns every matched string', all, ['42', '2025', '04', '09']);
test('but drops the positions entirely', all.index, undefined);
test('no match gives null, not an empty array', line.match(/z+/g), null);
test('so default it before using the result', (line.match(/z+/g) || []).length, 0);
test('capture groups follow the whole match', 'a1'.match(/([a-z])([0-9])/).slice(0, 3), ['a1', 'a', '1']);
test('a named group lands in .groups', 'a1'.match(/(?<letter>[a-z])/).groups.letter, 'a');
test('the input string is carried along without g', one.input, line);
test('and the g result is an ordinary array', Array.isArray(all), true);
One method, two return shapes. That is unusual and it catches people: text.match(/x/g).index is undefined, not an error, so a position read off a global match is silently missing rather than obviously wrong.
The null is the other trap. text.match(re).length throws when nothing matched, which is why (text.match(re) || []) appears everywhere. If you want both the positions and all the matches, use matchAll — that is the next exercise.
Answers JS String 11 in the original set.
12 matchAll, for every match with its details
fairParse 'Lagos 21C, Abuja 33C, Kano 38C' into a list of { city, temp } objects, keeping the position of each match.
Stuck? Three hints, smallest first
- 1.
matchAllreturns an iterator, so spread it or loop over it. - 2. Each item is a full match object, so
groupsandindexare both there. - 3. It requires the
gflag and throws without it.
const line = 'Lagos 21C, Abuja 33C, Kano 38C';
const READING = /(?<city>[A-Z][a-z]+) (?<temp>[0-9]+)C/g;
const readings = [...line.matchAll(READING)].map((m) => ({
city: m.groups.city,
temp: Number(m.groups.temp),
}));
// Without the g flag it refuses, rather than matching once.
const withoutG = (() => {
try { return [...line.matchAll(/[0-9]+/)].length; } catch (e) { return e.constructor.name; }
})();
for (const r of readings) console.log(r.city.padEnd(6), r.temp + 'C');
console.log('matchAll without g:', withoutG);
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('every reading is found', readings.length, 3);
test('the first one is parsed properly', readings[0], { city: 'Lagos', temp: 21 });
test('and the last', readings[2], { city: 'Kano', temp: 38 });
test('matchAll keeps the index that match with g throws away', [...line.matchAll(READING)][0].index, 0);
test('the second match knows where it starts', [...line.matchAll(READING)][1].index, 11);
test('matchAll without the g flag throws', withoutG, 'TypeError');
test('the return value is an iterator, not an array', Array.isArray(line.matchAll(READING)), false);
test('so it has to be spread or looped', Array.isArray([...line.matchAll(READING)]), true);
test('no match gives an empty list rather than null', [...line.matchAll(/z+/g)], []);
test('the hottest city', readings.reduce((a, b) => (b.temp > a.temp ? b : a)).city, 'Kano');
matchAll is what match(/x/g) should have been: every match, each one a full object with its groups and its index. Reaching for it should be the default when a pattern has capture groups.
It gives you an iterator rather than an array, which means it can be looped once without building the whole list — and it means .length and .map are not available until you spread it. It also insists on g, throwing instead of silently behaving like match, which is the right call.
Answers JS String 12 in the original set.
13 Upper case, lower case, and the German sharp s
easyConvert text between cases. Then upper-case 'ß' and check the length of what comes back.
Stuck? Three hints, smallest first
- 1.
toUpperCaseandtoLowerCasereturn new strings; they cannot modify in place. - 2. Digits and punctuation pass through untouched.
- 3. Case conversion is not guaranteed to preserve length, and
ßis the proof.
const shout = (text) => text.toUpperCase();
console.log('ß upper-cased :', 'ß'.toUpperCase(), ' length:', 'ß'.toUpperCase().length);
console.log('Straße ->', 'Straße'.toUpperCase(), '->', 'Straße'.toUpperCase().toLowerCase());
console.log('so the round trip does not return the original:', 'Straße'.toUpperCase().toLowerCase() === 'Straße'.toLowerCase());
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('upper-casing is straightforward', shout('lagos'), 'LAGOS');
test('and so is lower-casing', 'LAGOS'.toLowerCase(), 'lagos');
test('digits and punctuation are untouched', shout('a-1-b'), 'A-1-B');
test('the German sharp s becomes TWO letters', 'ß'.toUpperCase(), 'SS');
test('so the string gets longer', 'ß'.toUpperCase().length, 2);
test('a whole word shows it', 'Straße'.toUpperCase(), 'STRASSE');
test('and the round trip does not come back', 'Straße'.toUpperCase().toLowerCase(), 'strasse');
test('which is not the original lower-cased', 'Straße'.toUpperCase().toLowerCase() === 'Straße'.toLowerCase(), false);
test('so never assume case conversion preserves length', 'Straße'.length !== 'Straße'.toUpperCase().length, true);
test('accents survive in both directions', 'ÉCOLE'.toLowerCase(), 'école');
test('and come back unchanged', 'école'.toUpperCase(), 'ÉCOLE');
Case conversion is a Unicode mapping, not a per-character arithmetic trick, and it is not reversible. 'ß' upper-cases to 'SS', so any code that assumes toUpperCase keeps the length — a fixed-width field, an index into the original, a padded column — is wrong on German text.
That also breaks a common shortcut: comparing a.toUpperCase() === b.toUpperCase() reports 'STRASSE' and 'straße' as equal, while comparing the lower-cased forms reports them as different. Pick one direction and be consistent, or use localeCompare, which is the next exercise.
Answers JS String 13 in the original set.
14 Compare text without caring about case
fairCompare two strings ignoring case. Then do it with localeCompare and choose whether accents should count as a difference.
Stuck? Three hints, smallest first
- 1. Lower-casing both sides is the usual answer and works for plain text.
- 2.
localeComparereturns a negative number, zero or a positive number — not always -1, 0, 1. - 3. The
sensitivityoption decides whether case and accents count:baseignores both,accentignores only case.
const sameText = (a, b) => a.toLowerCase() === b.toLowerCase();
// sensitivity 'accent': case is ignored, accents still count as a difference.
const sameIgnoringCase = (a, b) =>
a.localeCompare(b, 'en', { sensitivity: 'accent' }) === 0;
console.log("sameText('Lagos', 'LAGOS') :", sameText('Lagos', 'LAGOS'));
console.log("sameText('cafe', 'café') :", sameText('cafe', 'café'), '<- accents are a difference');
console.log("base sensitivity says they match :", 'cafe'.localeCompare('café', 'en', { sensitivity: 'base' }) === 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('lower-casing both sides is the usual answer', sameText('Lagos', 'LAGOS'), true);
test('and it still rejects different words', sameText('Lagos', 'Kano'), false);
test('localeCompare returns 0 for equal strings', 'a'.localeCompare('a'), 0);
test('a negative number when the first sorts earlier', 'a'.localeCompare('b') < 0, true);
test('and a positive number the other way', 'b'.localeCompare('a') > 0, true);
test('sensitivity accent ignores case', sameIgnoringCase('Cafe', 'CAFE'), true);
test('but still counts an accent as a difference', sameIgnoringCase('cafe', 'café'), false);
test('sensitivity base ignores both', 'cafe'.localeCompare('café', 'en', { sensitivity: 'base' }), 0);
test('lower-casing does NOT ignore accents', sameText('cafe', 'café'), false);
test('and it disagrees with itself about the sharp s', sameText('STRASSE', 'straße'), false);
test('while the upper-case direction says they are the same', 'STRASSE'.toUpperCase() === 'straße'.toUpperCase(), true);
Never test localeCompare(...) === -1. The specification promises only the sign, so compare with < 0, === 0 or > 0. Engines do return -1 and 1 in practice, which is exactly what makes the assumption survive until it does not.
The last two assertions are the same pair of words getting two different answers depending on which way you fold the case. If you need one true answer, use localeCompare with an explicit sensitivity and stop reasoning about it yourself.
Answers JS String 14 in the original set.
15 Title case a sentence
fairTurn 'the quick brown fox' into 'The Quick Brown Fox'. Decide what an apostrophe should do, and preserve the spacing exactly.
Stuck? Three hints, smallest first
- 1. Split on spaces, capitalise each piece, join back with the same separator.
- 2. The one-line regex version capitalises after every word boundary, and an apostrophe is one.
- 3. Only the first letter changes: the rest of each word keeps whatever case it had.
const titleCase = (text) =>
text.split(' ').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
// The tempting one-liner. \b matches at every word boundary, and an apostrophe
// is not a word character, so there is a boundary right before the t in "don't".
const byWordBoundary = (text) => text.replace(/\b[a-z]/g, (c) => c.toUpperCase());
console.log(titleCase('the quick brown fox'));
console.log('titleCase("don\'t stop") :', titleCase("don't stop"));
console.log('byWordBoundary("don\'t stop") :', byWordBoundary("don't stop"), '<- the apostrophe fooled it');
console.log('titleCase("iPhone repair") :', titleCase('iPhone repair'));
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('each word is capitalised', titleCase('the quick brown fox'), 'The Quick Brown Fox');
test('an empty string stays empty', titleCase(''), '');
test('the gap between words is preserved exactly', titleCase('a b'), 'A B');
test('already-capitalised text is unchanged', titleCase('Hi There'), 'Hi There');
test('an apostrophe does not start a new word', titleCase("don't stop"), "Don't Stop");
test('but the word-boundary version thinks it does', byWordBoundary("don't stop"), "Don'T Stop");
test('only the first letter changes, so camelCase survives oddly', titleCase('iPhone repair'), 'IPhone Repair');
test('and shouting text is left shouting', titleCase('LAGOS'), 'LAGOS');
test('one word is still title cased', titleCase('lagos'), 'Lagos');
test('a leading space produces an empty first word, harmlessly', titleCase(' lagos'), ' Lagos');
Splitting on a single space rather than on /\s+/ is the deliberate choice here: it keeps the original spacing, so 'a b' comes back with both spaces. Splitting on a run of whitespace would collapse them, which is a different job — exercise 62.
The word-boundary one-liner is shorter and wrong for real prose. "Don'T", "O'Brien" becoming "O'Brien" only by luck, and hyphenated names all come out of the same cause. Real title casing also lower-cases small words like "of" and "the" except at the start, which no one-liner does.
Answers JS String 15 in the original set.
16 snake_case and kebab-case into camelCase
fairConvert 'total_price' and 'total-price' to 'totalPrice' with one function. Then find the three inputs it gets wrong.
Stuck? Three hints, smallest first
- 1. Match the separator together with the character after it, and upper-case that character.
- 2. A replacement function receives the capture groups as extra arguments.
- 3. Lower-casing the whole string first handles SHOUTING_SNAKE — and destroys anything already camelCased.
const toCamel = (text) =>
text.toLowerCase().replace(/[-_](.)/g, (_, c) => c.toUpperCase());
console.log("toCamel('total_price') :", toCamel('total_price'));
console.log("toCamel('TOTAL_PRICE') :", toCamel('TOTAL_PRICE'));
console.log('--- the three it gets wrong ---');
console.log("toCamel('price_') :", toCamel('price_'), '<- nothing follows, so the separator stays');
console.log("toCamel('a__b') :", toCamel('a__b'), '<- only one separator is consumed');
console.log("toCamel('totalPrice') :", toCamel('totalPrice'), '<- already camelCase, and now flattened');
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('snake_case becomes camelCase', toCamel('total_price'), 'totalPrice');
test('kebab-case does too', toCamel('total-price'), 'totalPrice');
test('several words in a row', toCamel('a_b_c'), 'aBC');
test('SHOUTING_SNAKE works because of the lower-casing', toCamel('TOTAL_PRICE'), 'totalPrice');
test('a word with no separator is unchanged', toCamel('price'), 'price');
test('the empty string stays empty', toCamel(''), '');
test('a trailing separator has nothing to promote, so it survives', toCamel('price_'), 'price_');
test('a doubled separator only loses one of the two', toCamel('a__b'), 'a_b');
test('and lower-casing first DESTROYS input that was already camelCase', toCamel('totalPrice'), 'totalprice');
test('so only convert when you know what shape the input is in', toCamel('order-id'), 'orderId');
The toLowerCase() at the front is doing two jobs and only one of them is wanted. It makes 'TOTAL_PRICE' work, and it flattens 'totalPrice' to 'totalprice'. If your input might already be camelCase, drop it and handle the shouting case separately.
'a__b' is the honest limitation: [-_](.) consumes one separator and the character after it, so the second underscore is just "the character after it" and gets upper-cased into itself. Matching [-_]+(.) instead fixes that one.
Answers JS String 16 in the original set.
17 camelCase back to snake_case and kebab-case
fairConvert 'totalPrice' to 'total_price' and 'total-price'. Handle a leading capital, and see what happens to an acronym.
Stuck? Three hints, smallest first
- 1. Replace each capital with a separator plus its lower-case form.
- 2. A leading capital would produce a stray separator at the front, so strip it.
- 3. An acronym is a run of capitals, and each one gets its own separator.
const toSnake = (text) =>
text.replace(/[A-Z]/g, (c) => '_' + c.toLowerCase()).replace(/^_/, '');
const toKebab = (text) => toSnake(text).replace(/_/g, '-');
// The reverse direction, without the lower-casing that exercise 16 warned about.
const toCamel = (text) => text.replace(/[-_](.)/g, (_, c) => c.toUpperCase());
console.log("toSnake('totalPrice') :", toSnake('totalPrice'));
console.log("toKebab('totalPrice') :", toKebab('totalPrice'));
console.log("toSnake('TotalPrice') :", toSnake('TotalPrice'), '<- the leading _ was stripped');
console.log("toSnake('orderID') :", toSnake('orderID'), '<- each capital gets its own separator');
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('camelCase becomes snake_case', toSnake('totalPrice'), 'total_price');
test('and kebab-case', toKebab('totalPrice'), 'total-price');
test('a leading capital would leave a stray separator, so it is stripped', toSnake('TotalPrice'), 'total_price');
test('a single word is unchanged', toSnake('price'), 'price');
test('the empty string stays empty', toSnake(''), '');
test('an acronym is split letter by letter, which is rarely wanted', toSnake('orderID'), 'order_i_d');
test('three words come out cleanly', toKebab('shippingAddressLine'), 'shipping-address-line');
test('digits stay where they are', toSnake('line1Total'), 'line1_total');
test('the round trip back to camelCase works', toCamel(toSnake('totalPrice')), 'totalPrice');
test('and it even survives the ugly acronym', toCamel(toSnake('orderID')), 'orderID');
'orderID' becoming 'order_i_d' is the case that decides whether this function is good enough for your data. Fixing it means treating a run of capitals as one unit and deciding where the next word starts, which needs a lookahead: /([A-Z]+)([A-Z][a-z])/ to split 'HTTPServer', then /([a-z0-9])([A-Z])/ for the ordinary case.
The round trip surviving the acronym is a small piece of luck worth noticing: toCamel puts each separated letter back as a capital, so 'order_i_d' returns to 'orderID' even though nobody would have written that intermediate form on purpose.
Answers JS String 17 in the original set.
18 Trim whitespace, from one end or both
easyRemove the whitespace around ' Lagos ', then remove it from only the front, then only the back.
Stuck? Three hints, smallest first
- 1.
trim,trimStartandtrimEndall return new strings. - 2. None of them touches whitespace in the middle.
- 3. Whitespace here means more than the space character: tabs, newlines and a non-breaking space all count.
const messy = ' Lagos ';
console.log('trim :', JSON.stringify(messy.trim()));
console.log('trimStart :', JSON.stringify(messy.trimStart()));
console.log('trimEnd :', JSON.stringify(messy.trimEnd()));
console.log('the original is untouched:', JSON.stringify(messy));
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('trim takes whitespace off both ends', messy.trim(), 'Lagos');
test('trimStart takes it off the front only', messy.trimStart(), 'Lagos ');
test('trimEnd takes it off the back only', messy.trimEnd(), ' Lagos');
test('none of them touch the middle', ' a b '.trim(), 'a b');
test('tabs and newlines count as whitespace', '\t\nLagos\n'.trim(), 'Lagos');
test('a string of only spaces trims to empty', ' '.trim(), '');
test('which is how a blank form field is usually detected', ' '.trim().length === 0, true);
test('a non-breaking space is trimmed too', '\u00a0Lagos'.trim(), 'Lagos');
test('the original is never modified', messy, ' Lagos ');
test('so trimming twice changes nothing further', messy.trim().trim(), 'Lagos');
value.trim() === '' is the check a form needs. value === '' lets a field containing three spaces through, and users produce those constantly by copying text.
The non-breaking space assertion matters more than it looks. Text pasted from a web page or a word processor is full of U+00A0, which looks exactly like a space and is not the same character. trim removes it; a hand-rolled replace(/ /g, '') does not.
Answers JS String 18 in the original set.
19 Pad a string to a width
easyPad '7' out to three characters with zeros, and 'ab' out to five with dots. Then ask for a width shorter than the string.
Stuck? Three hints, smallest first
- 1. The argument is the total length wanted, not the number of characters to add.
- 2.
padStartadds on the left,padEndon the right. - 3. Neither ever truncates, and a multi-character filler is cut to fit.
console.log("'7'.padStart(3, '0') :", '7'.padStart(3, '0'));
console.log("'ab'.padEnd(5, '.') :", 'ab'.padEnd(5, '.'));
console.log("'x'.padEnd(6, 'ab') :", 'x'.padEnd(6, 'ab'), '<- the filler repeats and is cut');
console.log("'lagos'.padStart(2) :", 'lagos'.padStart(2), '<- shorter target, so nothing happens');
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('padStart pads on the left to a total length', '7'.padStart(3, '0'), '007');
test('padEnd pads on the right', 'ab'.padEnd(5, '.'), 'ab...');
test('the default filler is a space', 'ab'.padStart(4), ' ab');
test('the argument is the total length, not the amount added', '7'.padStart(3, '0').length, 3);
test('a target shorter than the string does nothing', 'lagos'.padStart(2, '0'), 'lagos');
test('so padding never truncates', 'lagos'.padStart(2, '0').length, 5);
test('a multi-character filler repeats and is cut to fit', 'x'.padEnd(6, 'ab'), 'xababa');
test('an empty filler pads nothing at all', 'x'.padStart(5, ''), 'x');
test('padding to the same length is a no-op', 'abc'.padStart(3, '0'), 'abc');
test('and the original is never modified', (() => { const s = '7'; s.padStart(3, '0'); return s; })(), '7');
Read the argument as "make it this wide", not "add this many". '7'.padStart(3, '0') is '007', not '0007'.
The non-truncating behaviour is the thing to design around. If a column has a hard width, padding alone will not enforce it — you need slice as well, which exercises 21 and 63 both do.
Answers JS String 19 in the original set.
20 Zero-pad a number
easyFormat hours and minutes as 09:05. Then format an invoice number six digits wide.
Stuck? Three hints, smallest first
- 1.
padStartis a string method, so convert the number first. - 2.
String(n)or`${n}`both do the conversion. - 3. A number cannot store a leading zero, so the padded form has to stay a string.
const pad2 = (n) => String(n).padStart(2, '0');
const clock = (h, m) => `${pad2(h)}:${pad2(m)}`;
console.log('clock(9, 5) :', clock(9, 5));
console.log('clock(0, 0) :', clock(0, 0));
console.log('pad2(123) :', pad2(123), '<- wider than the field, so nothing is cut');
console.log('Number("07") :', Number(pad2(7)), '<- the zero is gone the moment it is a number again');
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 single digit gains a zero', pad2(7), '07');
test('two digits are left alone', pad2(23), '23');
test('zero becomes 00', pad2(0), '00');
test('a clock reads properly', clock(9, 5), '09:05');
test('and at midnight', clock(0, 0), '00:00');
test('padStart is a string method, so the result is a string', typeof pad2(7), 'string');
test('converting back loses the padding, because numbers have no leading zeros', Number(pad2(7)), 7);
test('three digits overflow the field rather than being cut', pad2(123), '123');
test('so widen the field when the range is bigger', String(123).padStart(4, '0'), '0123');
test('an invoice number, six wide', String(42).padStart(6, '0'), '000042');
The padded value has to remain a string all the way to the screen. The moment it becomes a number again the zeros are gone, because 007 and 7 are the same number — that is what the Number(pad2(7)) assertion is there to show.
Beware of parseInt on a zero-padded string in very old code: it used to read a leading zero as octal, so '08' became 0. Modern engines do not, but the habit of passing the radix — parseInt('08', 10) — survives from that era and is still worth keeping.
Answers JS String 20 in the original set.
21 Right-align a column of numbers
fairPrint a small price list so that the names run left and the amounts all end at the same column, whatever their width.
Stuck? Three hints, smallest first
- 1. The column width is the length of the longest value, so compute it first.
- 2. Left-align with
padEnd, right-align withpadStart. - 3. A name longer than its column is not truncated by padding, so slice as well when the width is a hard limit.
const rows = [['Rice', 4500], ['Palm oil', 12000], ['Salt', 300]];
// The widest amount decides the column, so nothing has to be guessed.
const width = Math.max(...rows.map(([, amount]) => String(amount).length));
const render = ([name, amount]) =>
name.padEnd(10, '.') + String(amount).padStart(width + 1);
const table = rows.map(render);
for (const line of table) console.log(line);
console.log('column width from the data:', width);
Output
9 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 9 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('the widest number sets the column', width, 5);
test('the first row', table[0], 'Rice...... 4500');
test('the longest name is padded least', table[1], 'Palm oil.. 12000');
test('every line is the same width', table.map((l) => l.length), [16, 16, 16]);
test('so the amounts all end at the same column', table.every((l) => l.length === table[0].length), true);
test('right alignment is padStart on the number', String(300).padStart(6), ' 300');
test('left alignment is padEnd on the name', 'Salt'.padEnd(10, '.'), 'Salt......');
test('a name longer than the column is NOT cut by padding', 'A very long product'.padEnd(10, '.'), 'A very long product');
test('so slice first when the width is a hard limit', 'A very long product'.slice(0, 10).padEnd(10, '.'), 'A very lon');
Deriving the width from the data rather than hard-coding it is the whole point. A hard-coded padStart(6) works until a five-figure number arrives and the column shifts.
The last two assertions are the pair to remember: padding sets a minimum width, slicing sets a maximum, and a fixed-width column needs both. Note also that alignment measured in characters only lines up in a monospaced font — in proportional text you are counting the wrong thing entirely.
Answers JS String 21 in the original set.
22 Split on a separator
easyTurn 'rice,beans,yam' into an array. Then split on a separator that is not there, on nothing at all, and split an empty string.
Stuck? Three hints, smallest first
- 1.
splitreturns an array; the separator itself is not included. - 2. A separator that never matches gives a one-element array containing the whole string.
- 3. An empty string behaves differently depending on whether the separator is
''or something else.
const csv = 'rice,beans,yam';
console.log("split(',') :", csv.split(','));
console.log("split(';') :", csv.split(';'), '<- no match, so one element');
console.log("''.split(','):", ''.split(','), '<- one EMPTY element');
console.log("''.split(''):", ''.split(''), '<- no elements at all');
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('split on a comma gives the fields', csv.split(','), ['rice', 'beans', 'yam']);
test('a separator that is not there gives one element', csv.split(';'), ['rice,beans,yam']);
test('no separator at all also gives one element', csv.split(), ['rice,beans,yam']);
test('a trailing separator produces an empty last field', 'a,b,'.split(','), ['a', 'b', '']);
test('and a leading one an empty first field', ',a'.split(','), ['', 'a']);
test('consecutive separators leave empties between them', 'a,,b'.split(','), ['a', '', 'b']);
test('splitting an empty string on a comma gives ONE empty field', ''.split(','), ['']);
test('but splitting it on nothing gives no fields at all', ''.split(''), []);
test('a multi-character separator works', 'a -> b -> c'.split(' -> '), ['a', 'b', 'c']);
test('split and join are inverses with the same separator', csv.split(',').join(','), csv);
The two empty-string cases are the ones that bite. ''.split(',') is [''] — length 1 — so a naive "count the fields" or "count the words" function reports 1 for empty input. ''.split('') is []. Exercise 35 handles the consequence.
Empty fields from leading, trailing and doubled separators are not a bug either: they are the honest answer for CSV, where a,,b genuinely has three columns and the middle one is blank. Filter them out only when you know they are noise.
Answers JS String 22 in the original set.
23 Split on a pattern, and split with a limit
fairSplit 'rice , beans,yam' into clean fields with one call, using a pattern that absorbs the surrounding spaces. Then find out what the limit argument really does.
Stuck? Three hints, smallest first
- 1. A regular expression separator can match the comma and any whitespace either side of it.
- 2. The limit caps the number of fields returned; it does not put the rest in the last one.
- 3. A capture group inside the separator is kept in the output, which is occasionally exactly what you want.
const line = 'rice , beans,yam';
console.log('split(/\\s*,\\s*/) :', line.split(/\s*,\s*/));
console.log("split(',') :", line.split(','), '<- the spaces came along');
console.log("'a,b,c,d'.split(',', 2) :", 'a,b,c,d'.split(',', 2), '<- c and d are DISCARDED');
console.log("'a1b2c'.split(/([0-9])/) :", 'a1b2c'.split(/([0-9])/), '<- the group is kept');
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 pattern separator can absorb the spaces around the comma', line.split(/\s*,\s*/), ['rice', 'beans', 'yam']);
test('a plain comma leaves them behind', line.split(','), ['rice ', ' beans', 'yam']);
test('the limit caps how many fields come back', 'a,b,c,d'.split(',', 2), ['a', 'b']);
test('the remainder is DISCARDED, not returned as a tail', 'a,b,c,d'.split(',', 2).length, 2);
test('so slice and rejoin when you want the rest', (() => { const p = 'a,b,c,d'.split(','); return [p[0], p.slice(1).join(',')]; })(), ['a', 'b,c,d']);
test('a capture group in the separator is KEPT in the output', 'a1b2c'.split(/([0-9])/), ['a', '1', 'b', '2', 'c']);
test('without the group the digits vanish', 'a1b2c'.split(/[0-9]/), ['a', 'b', 'c']);
test('splitting on whitespace handles runs of it', 'a b\tc'.split(/\s+/), ['a', 'b', 'c']);
test('but a leading space then gives an empty first field', ' a b'.split(/\s+/), ['', 'a', 'b']);
test('which is why trim comes first', ' a b '.trim().split(/\s+/), ['a', 'b']);
limit is not "split into at most n pieces, keeping the rest together" — that is what it looks like and what people assume. It throws the remainder away. If you want 'key=a=b' split into ['key', 'a=b'], use indexOf and two slices, or split fully and rejoin the tail.
The empty first field from ' a b'.split(/\s+/) is the reason trim() belongs in front of nearly every whitespace split. The separator matched at position 0, and a match at position 0 means there is a field before it.
Answers JS String 23 in the original set.
24 Join an array back into a string
easyTurn ['rice', 'beans', 'yam'] into 'rice, beans, yam'. Then find out what join does with null and with a nested array.
Stuck? Three hints, smallest first
- 1. The default separator is a comma with no space, which is rarely what you want.
- 2.
nullandundefinedbecome empty strings, not the words. - 3. A sentence list needs "and" before the last item, so
joinalone will not do it.
const items = ['rice', 'beans', 'yam'];
// A readable list needs the last separator to be different.
const sentence = (list) =>
list.length < 2 ? list.join('') : list.slice(0, -1).join(', ') + ' and ' + list.at(-1);
console.log("join(', ') :", items.join(', '));
console.log('join() :', items.join(), '<- comma, no space');
console.log('sentence :', sentence(items));
console.log("[null, 'b'].join('-') :", ['a', null, undefined, 'b'].join('-'), '<- holes, not the words');
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('join with a comma and a space', items.join(', '), 'rice, beans, yam');
test('the default separator is a bare comma', items.join(), 'rice,beans,yam');
test('an empty separator glues them together', items.join(''), 'ricebeansyam');
test('one item needs no separator', ['rice'].join(', '), 'rice');
test('an empty array joins to an empty string', [].join(', '), '');
test('null and undefined become EMPTY, not the words', ['a', null, undefined, 'b'].join('-'), 'a---b');
test('even though String(null) is the word', String(null), 'null');
test('numbers are converted for you', [1, 2, 3].join('+'), '1+2+3');
test('a nested array is joined too, with commas', [['a', 'b'], 'c'].join('-'), 'a,b-c');
test('a readable list needs more than join', sentence(items), 'rice, beans and yam');
test('and it still handles a single item', sentence(['rice']), 'rice');
The null behaviour is the one that produces a puzzling gap in output. ['a', null, 'b'].join('-') is 'a--b', so a missing value shows up as a doubled separator rather than as anything you can search for. Filter first: list.filter((x) => x != null).join('-').
The nested-array case is String(['a','b']) at work — an array converts to its own comma-joined form regardless of the separator you asked for. Flatten before joining if the input might be ragged.
Answers JS String 24 in the original set.
25 Split into characters, and why split(‘’) is wrong
fairBreak a string into its characters. Do it with split('') and with spread, then run both over 'a👍b' and see which one is telling the truth.
Stuck? Three hints, smallest first
- 1.
split(‘’)splits into UTF-16 code units, not characters. - 2. Spread and
Array.fromboth iterate code points, which keeps an emoji whole. - 3. An emoji is stored as a surrogate pair, so it is two code units and one code point.
const emoji = 'a👍b';
console.log("'Lagos'.split('') :", 'Lagos'.split(''));
console.log("[...'Lagos'] :", [...'Lagos']);
console.log('--- and now with an emoji ---');
console.log("emoji.split('') :", emoji.split(''), '<- four items, two of them broken halves');
console.log('[...emoji] :', [...emoji], '<- three items');
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('for plain text both give the same characters', 'Lagos'.split(''), ['L', 'a', 'g', 'o', 's']);
test('and spreading agrees', [...'Lagos'], ['L', 'a', 'g', 'o', 's']);
test('the emoji string is three characters', [...emoji].length, 3);
test('but split MANGLES it into four pieces', emoji.split('').length, 4);
test('spreading keeps the emoji whole', [...emoji][1], '👍');
test('split does not', emoji.split('')[1] === '👍', false);
test('because length counts UTF-16 code units', emoji.length, 4);
test('Array.from behaves like spreading', Array.from(emoji).length, 3);
test('joining the split halves straight back does restore the text', emoji.split('').join(''), emoji);
test('but reordering them does not', emoji.split('').reverse().join('') === [...emoji].reverse().join(''), false);
split('') is safe only if you put the pieces back in the same order. The moment you reverse, sort, shuffle or slice them, a surrogate pair gets separated and you have produced text that no font can render.
Spread and Array.from both use the string iterator, which yields code points. That fixes emoji and astral characters. It does not fix combining marks — 'e' + U+0301 is still two code points for one visible letter — which is exercise 39.
Answers JS String 25 in the original set.
26 replace only replaces the first match
easyReplace 'the' in 'the cat sat on the mat' and count how many changed. Then make it change both.
Stuck? Three hints, smallest first
- 1. With a string needle,
replacechanges the first occurrence and stops. - 2. A regular expression with the
gflag changes all of them. - 3. The replacement text is not plain:
$has a special meaning in it.
const line = 'the cat sat on the mat';
console.log("replace('the', 'a') :", line.replace('the', 'a'), '<- one changed');
console.log("replace(/the/g, 'a') :", line.replace(/the/g, 'a'), '<- both changed');
console.log("'price'.replace('price', '$$') :", 'price'.replace('price', '$$'), '<- $$ is one dollar');
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('replace changes only the FIRST match', line.replace('the', 'a'), 'a cat sat on the mat');
test('so the second one is still there', line.replace('the', 'a').includes('the'), true);
test('a regex with g changes them all', line.replace(/the/g, 'a'), 'a cat sat on a mat');
test('a needle that is absent gives the string back unchanged', line.replace('dog', 'cow'), line);
test('the search is case sensitive', line.replace('The', 'a'), line);
test('replacing with an empty string deletes the match', line.replace('the ', ''), 'cat sat on the mat');
test('the replacement is NOT re-scanned, so it cannot loop', 'aaa'.replace('a', 'aa'), 'aaaa');
test('a $ in the replacement is special: $& is the match', 'price'.replace('price', '$&$&'), 'priceprice');
test('so a literal dollar has to be written $$', 'price'.replace('price', '$$'), '$');
test('and the original is unchanged either way', line, 'the cat sat on the mat');
"Replace" reads like "replace them all", and it does not. This is the single most common string bug in JavaScript, and it hides well: it only shows up when the input happens to contain the needle twice, so it passes every test written with a short example.
The $ handling is the second trap. Building a replacement from user input or from a currency string and handing it to replace will silently interpret $&, $1 and $$. If the replacement is data rather than a pattern, use the function form — exercise 29 — where nothing is interpreted.
Answers JS String 26 in the original set.
27 replaceAll, and when it refuses
easyReplace every occurrence without writing a regular expression. Then hand replaceAll a regex without the g flag and see what it does.
Stuck? Three hints, smallest first
- 1.
replaceAllwith a string needle changes every occurrence. - 2. A regex argument must carry the
gflag or it throws. - 3. A string needle needs no escaping, so a dot means a dot.
const line = 'the cat sat on the mat';
const withoutG = (() => {
try { return line.replaceAll(/the/, 'a'); } catch (e) { return e.constructor.name; }
})();
console.log("replaceAll('the', 'a') :", line.replaceAll('the', 'a'));
console.log('replaceAll(/the/, "a") :', withoutG, '<- refused, because there is no g');
console.log("'a.b.c'.replaceAll('.', '-') :", 'a.b.c'.replaceAll('.', '-'), '<- a dot is a dot');
console.log("'a.b.c'.replace(/./g, '-') :", 'a.b.c'.replace(/./g, '-'), '<- a dot is anything');
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('replaceAll changes every occurrence', line.replaceAll('the', 'a'), 'a cat sat on a mat');
test('which matches the regex-with-g version', line.replaceAll('the', 'a'), line.replace(/the/g, 'a'));
test('an absent needle changes nothing', line.replaceAll('dog', 'cow'), line);
test('a non-global regex is REFUSED rather than treated as one match', withoutG, 'TypeError');
test('with the g flag it is fine', line.replaceAll(/the/g, 'a'), 'a cat sat on a mat');
test('a string needle needs no escaping', 'a.b.c'.replaceAll('.', '-'), 'a-b-c');
test('while a regex dot matches every character', 'a.b.c'.replace(/./g, '-'), '-----');
test('an empty needle inserts between every character', 'abc'.replaceAll('', '-'), '-a-b-c-');
test('overlapping matches are not revisited', 'aaaa'.replaceAll('aa', 'a'), 'aa');
test('so run it again to reach a fixed point', 'aaaa'.replaceAll('aa', 'a').replaceAll('aa', 'a'), 'a');
replaceAll with a string needle is the safe default, because nothing in the needle is interpreted. That is the whole reason to prefer it over replace(new RegExp(needle, 'g'), ...) when the needle came from data.
The overlapping case is worth internalising: replacement continues from the end of each match, so 'aaaa' with 'aa' -> 'a' becomes 'aa' in one pass, not 'a'. Collapsing runs in one go needs a pattern like /a+/g instead.
Answers JS String 27 in the original set.
28 The $ patterns in a replacement
fairReorder '2025-04-09' into '09/04/2025' using capture groups in the replacement. Then find out what $&, $$ and $<name> mean.
Stuck? Three hints, smallest first
- 1.
$1,$2and$3refer to the capture groups, in order. - 2.
$&is the whole match;$$is a literal dollar sign. - 3. A group number that does not exist is left in the output as plain text.
const swapDate = (text) => text.replace(/([0-9]{4})-([0-9]{2})-([0-9]{2})/, '$3/$2/$1');
console.log('swapDate:', swapDate('2025-04-09'));
console.log("'lagos'.replace(/lagos/, '[$&]') :", 'lagos'.replace(/lagos/, '[$&]'));
console.log("'a'.replace(/(a)/, '$2') :", 'a'.replace(/(a)/, '$2'), '<- no group 2, so left as text');
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('capture groups can be reordered', swapDate('2025-04-09'), '09/04/2025');
test('$& is the whole match', 'lagos'.replace(/lagos/, '[$&]'), '[lagos]');
test('$` is everything before the match', 'ab'.replace(/b/, '<$`>'), 'a<a>');
test("$' is everything after it", 'ab'.replace(/a/, "<$'>"), '<b>b');
test('$$ is a literal dollar sign', 'x'.replace(/x/, '$$'), '$');
test('a named group is referenced as $<name>', '2025-04'.replace(/(?<y>[0-9]{4})-(?<m>[0-9]{2})/, '$<m> of $<y>'), '04 of 2025');
test('a group number that does not exist stays as text', 'a'.replace(/(a)/, '$2'), '$2');
test('and $0 is not a thing either', 'a'.replace(/a/, '$0'), '$0');
test('grouping a phone number', '08031234567'.replace(/([0-9]{4})([0-9]{3})([0-9]{4})/, '$1 $2 $3'), '0803 123 4567');
test('wrapping every match needs g as well as $&', 'a b'.replace(/[a-z]/g, '[$&]'), '[a] [b]');
$1 is a string, always. There is no way to do arithmetic on a captured group inside a replacement string — for that you need the function form, which is next.
$` and $' (the text before and after the match) are real but almost never useful, and they are worth knowing mainly so that a stray backtick or apostrophe in a replacement does not surprise you. The genuinely important two are $& and $$: the first because wrapping a match is such a common job, the second because forgetting it is how a price ends up as 'the match' instead of '$5'.
Answers JS String 28 in the original set.
29 A function as the replacement
fairAdd one to every number in a line of text. A replacement string cannot do arithmetic, so pass a function instead and work out what arguments it receives.
Stuck? Three hints, smallest first
- 1. The function is called once per match and its return value is used as the replacement.
- 2. The arguments are the match, then each capture group, then the offset, then the whole string.
- 3. With named groups, a final object of groups is appended after those.
const bump = (text) => text.replace(/[0-9]+/g, (d) => String(Number(d) + 1));
const shoutWords = (text) => text.replace(/[a-z]+/g, (w) => w.toUpperCase());
// A counter is easy this way, and impossible with a replacement string.
const numberEach = (text) => {
let i = 0;
return text.replace(/a/g, () => String(++i));
};
console.log('bump :', bump('room 9 and room 10'));
console.log('shoutWords :', shoutWords('a bc'));
console.log('numberEach :', numberEach('a a a'));
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 function receives each match', shoutWords('a bc'), 'A BC');
test('and can compute the replacement', bump('room 9 and room 10'), 'room 10 and room 11');
test('the arguments are match, groups, offset', 'ab'.replace(/(a)(b)/, (m, g1, g2, offset) => [m, g1, g2, offset].join('|')), 'ab|a|b|0');
test('and then the whole input string', 'ab'.replace(/b/, (...args) => String(args.at(-1))), 'aab');
test('named groups arrive as one final object', '2025-04'.replace(/(?<y>[0-9]{4})-(?<m>[0-9]{2})/, (...a) => a.at(-1).m), '04');
test('a counter can number the matches', numberEach('a a a'), '1 2 3');
test('nothing in the returned string is interpreted, so no $ escaping', 'x'.replace(/x/, () => '$&'), '$&');
test('a non-string return value is converted', 'x'.replace(/x/, () => 42), '42');
test('without g the function runs once', (() => { let n = 0; 'aaa'.replace(/a/, () => { n++; return 'b'; }); return n; })(), 1);
test('with g it runs once per match', (() => { let n = 0; 'aaa'.replace(/a/g, () => { n++; return 'b'; }); return n; })(), 3);
The function form is the one to reach for whenever the replacement depends on what was matched, and the "no $ escaping" assertion is the underrated reason: whatever you return is used verbatim. Inserting arbitrary data into text is therefore safer with a function than with a string.
Counting the arguments from the end with args.at(-1) is a practical habit, because the count changes with the number of capture groups and with whether any are named. Positional destructuring of that list breaks as soon as the pattern gains a group.
Answers JS String 29 in the original set.
30 Escape a string for use in a regular expression
hardWrite escapeRegExp so that a needle typed by a user can be searched for as literal text. Then use it to highlight every occurrence of that needle.
Stuck? Three hints, smallest first
- 1. The characters that need escaping are
. * + ? ^ $ { } ( ) | [ ] \. - 2. A character class holds all of them;
$&in the replacement puts the matched one back. - 3. The backslash has to be escaped twice over: once for the string literal, once for the pattern.
// Every regex metacharacter, escaped by putting a backslash in front of it.
// Inside a character class most of them lose their meaning already, but listing
// them all is what makes this readable rather than clever.
const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const highlightAll = (text, needle) =>
text.replace(new RegExp(escapeRegExp(needle), 'g'), '[$&]');
// One character. Written '\\' in source, because a lone backslash escapes
// whatever follows it.
const BACKSLASH = '\\';
console.log("escapeRegExp('a.b') :", escapeRegExp('a.b'));
console.log('does it still match "axb"?', new RegExp(escapeRegExp('a.b')).test('axb'));
console.log("highlightAll('a.b.c', '.') :", highlightAll('a.b.c', '.'));
console.log("unescaped, /./g gives :", 'a.b.c'.replace(/./g, '[$&]'));
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 dot comes back with a backslash in front of it', escapeRegExp('a.b'), 'a' + BACKSLASH + '.b');
test('which is one character longer than the input', escapeRegExp('a.b').length, 4);
test('the escaped pattern matches the literal text', new RegExp(escapeRegExp('a.b')).test('a.b'), true);
test('and nothing else', new RegExp(escapeRegExp('a.b')).test('axb'), false);
test('unescaped, the dot is a wildcard', new RegExp('a.b').test('axb'), true);
test('ordinary text is left completely alone', escapeRegExp('lagos 2025'), 'lagos 2025');
test('every metacharacter is covered', [...'.*+?^${}()|[]'].every((c) => escapeRegExp(c) === BACKSLASH + c), true);
test('including the backslash itself', escapeRegExp(BACKSLASH), BACKSLASH + BACKSLASH);
test('so a needle typed by a user is now safe to search for', highlightAll('a.b.c', '.'), 'a[.]b[.]c');
test('where the unescaped version would have matched everything', 'a.b.c'.replace(/./g, '[$&]'), '[a][.][b][.][c]');
test('and a stray bracket no longer throws a SyntaxError', highlightAll('cost [x]', '['), 'cost [[]x]');
Anything built with new RegExp(userInput) is a bug waiting for a customer to type (. It throws a SyntaxError on unbalanced brackets and silently matches the wrong thing on ., + or * — which is worse, because it looks like it works.
The double-escaping is the part that trips everyone. In the source, '\\$&' is a two-character string: one backslash, then $&. The backslash lands in the output pattern, and $& is expanded to the character that matched. Getting a single backslash into a string literal always costs two.
If a RegExp.escape exists in your runtime, use it. Until then this five-line function is the standard answer.
Answers JS String 30 in the original set.
31 Template literals against concatenation
easyBuild the sentence 'Ada has 3 items' twice: once with + and once with a template literal. Then find the case where + gives a different answer depending on the order.
Stuck? Three hints, smallest first
- 1. A hole in a template literal can hold any expression, including a conditional.
- 2. A template literal keeps a newline exactly as typed; a quoted string needs
\n. - 3.
+adds when both sides are numbers and joins as soon as one side is a string.
const name = 'Ada';
const items = 3;
const byTemplate = `${name} has ${items} item${items === 1 ? '' : 's'}`;
const byConcat = name + ' has ' + items + ' item' + (items === 1 ? '' : 's');
// A template literal spans lines as typed, and the newline is part of the value.
const address = `12 Marina Road
Lagos`;
console.log('template :', byTemplate);
console.log('concat :', byConcat);
console.log('address has', address.split('\n').length, 'lines');
console.log("1 + 2 + 'x' :", 1 + 2 + 'x', " 'x' + 1 + 2 :", 'x' + 1 + 2);
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 two forms produce the same text', byTemplate, byConcat);
test('and it reads properly', byTemplate, 'Ada has 3 items');
test('one item drops the s', `1 item${1 === 1 ? '' : 's'}`, '1 item');
test('a template literal keeps the newline you typed', address.split('\n').length, 2);
test('a quoted string needs the escape instead', 'a\nb'.split('\n').length, 2);
test('a number is converted inside a hole', `n=${42}`, 'n=42');
test('and so is null, as the word', `v=${null}`, 'v=null');
test('+ adds two numbers before it meets the string', 1 + 2 + 'x', '3x');
test('but joins from the start in the other order', 'x' + 1 + 2, 'x12');
test('a hole can hold any expression at all', `${[1, 2, 3].join('-')}`, '1-2-3');
'x' + 1 + 2 being 'x12' while 1 + 2 + 'x' is '3x' is not a quirk, it is left-to-right evaluation meeting one overloaded operator. It is also the strongest practical argument for template literals: a hole is evaluated on its own, so no amount of reordering changes what a number does.
The multi-line form has one catch worth knowing: the indentation of the continuation line is part of the string. A template literal indented to match the surrounding code carries those leading spaces into the value.
Answers JS String 31 in the original set.
32 Repeat a string
easyBuild a thirty-character horizontal rule and a three-level indent. Then ask repeat for a fractional count and a negative one.
Stuck? Three hints, smallest first
- 1.
repeat(n)returns n copies joined together, with no separator. - 2. A fractional count is truncated towards zero rather than rounded.
- 3. A negative count is an error, not an empty string.
const rule = (width) => '-'.repeat(width);
const negative = (() => {
try { return 'ab'.repeat(-1); } catch (e) { return e.constructor.name; }
})();
console.log(rule(30));
console.log("' '.repeat(3) + 'deep' :", JSON.stringify(' '.repeat(3) + 'deep'));
console.log("'ab'.repeat(2.9) :", 'ab'.repeat(2.9), '<- truncated, not rounded');
console.log("'ab'.repeat(-1) :", negative);
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('repeat makes n copies', 'ab'.repeat(3), 'ababab');
test('zero copies is an empty string', 'ab'.repeat(0), '');
test('one copy is the string itself', 'ab'.repeat(1), 'ab');
test('a fractional count is truncated, not rounded', 'ab'.repeat(2.9), 'abab');
test('a negative count throws a RangeError', negative, 'RangeError');
test('a rule of the width asked for', rule(30).length, 30);
test('an indent of two spaces per level', ' '.repeat(3) + 'deep', ' deep');
test('repeating an empty string gives an empty string', ''.repeat(100), '');
test('repeat and padEnd overlap for simple filling', 'x' + '.'.repeat(4), 'x'.padEnd(5, '.'));
test('and the original is unchanged', (() => { const s = 'ab'; s.repeat(3); return s; })(), 'ab');
repeat(2.9) giving 'abab' follows the language's usual integer conversion, which truncates towards zero. That matters when the count came from a division: 'x'.repeat(width / 2) quietly rounds down on an odd width, and the column you were filling comes out one short.
Prefer padEnd when the goal is a total width and repeat when the goal is a count. They can express the same thing, but only one of them says what you meant.
Answers JS String 32 in the original set.
33 concat, plus, and building up in a loop
easyJoin strings four ways: with concat, with +, with += in a loop, and by collecting pieces and joining once. Confirm the original is never modified.
Stuck? Three hints, smallest first
- 1.
concattakes any number of arguments and converts each one. - 2.
+=creates a new string every time round the loop; it never appends in place. - 3. Collecting into an array and joining once is the habit that scales.
const pieces = ['a', 'b', 'c'];
const byLoop = (() => {
let out = '';
for (const c of pieces) out += c.toUpperCase();
return out;
})();
const byJoin = pieces.map((c) => c.toUpperCase()).join('');
console.log("'a'.concat('b', 'c', 'd') :", 'a'.concat('b', 'c', 'd'));
console.log('byLoop :', byLoop, ' byJoin :', byJoin);
console.log('strings are immutable, so every step makes a new one');
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('concat joins strings', 'a'.concat('b'), 'ab');
test('and takes several arguments', 'a'.concat('b', 'c', 'd'), 'abcd');
test('plus does the same and reads better', 'a' + 'b' + 'c' + 'd', 'abcd');
test('concat converts its arguments', 'n='.concat(42), 'n=42');
test('concat with no arguments returns a copy', 'a'.concat(), 'a');
test('an array of pieces is better joined', pieces.join(''), 'abc');
test('+= in a loop works', byLoop, 'ABC');
test('and map-then-join gives the same answer', byJoin, 'ABC');
test('strings are immutable, so concat returns a new one', (() => { const a = 'x'; const b = a.concat('y'); return [a, b]; })(), ['x', 'xy']);
test('and += rebinds rather than modifying', (() => { const a = 'x'; let b = a; b += 'y'; return a; })(), 'x');
All four produce identical strings, so choose on readability. concat is the one with no advantage: it is longer than + and slower to read.
The "+= in a loop is slow" advice is largely obsolete — engines optimise it heavily with rope representations — but map then join is still the better default, because it separates the transformation from the assembly and it works when the separator is not empty.
Answers JS String 33 in the original set.
34 Count how often each character appears
fairCount the letters in 'banana' and report the commonest. Then count '2211' and look at the order of the keys.
Stuck? Three hints, smallest first
- 1. Walk the string with
for...ofand accumulate into an object or aMap. - 2.
for...ofiterates code points, so an emoji is counted once. - 3. Object keys that look like array indexes are reordered; a
Mapnever reorders.
function countCharacters(text) {
const counts = {};
for (const c of text) counts[c] = (counts[c] || 0) + 1;
return counts;
}
function countByMap(text) {
const counts = new Map();
for (const c of text) counts.set(c, (counts.get(c) ?? 0) + 1);
return counts;
}
const mostCommon = (text) =>
Object.entries(countCharacters(text)).sort((a, b) => b[1] - a[1])[0];
console.log('banana :', countCharacters('banana'));
console.log('commonest:', mostCommon('banana'));
console.log("countCharacters('2211') keys :", Object.keys(countCharacters('2211')), '<- reordered');
console.log("countByMap('2211') keys :", [...countByMap('2211').keys()], '<- as they arrived');
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 letters of banana', countCharacters('banana'), { b: 1, a: 3, n: 2 });
test('an empty string has no counts', countCharacters(''), {});
test('spaces are characters too', countCharacters('a a')[' '], 1);
test('case is not folded', countCharacters('Aa'), { A: 1, a: 1 });
test('the commonest letter, with its count', mostCommon('banana'), ['a', 3]);
test('for...of walks code points, so an emoji counts once', countCharacters('a👍👍')['👍'], 2);
test('the totals add up to the number of code points', Object.values(countCharacters('banana')).reduce((a, b) => a + b, 0), 6);
test('digit keys are pulled to the front, because they look like indexes', Object.keys(countCharacters('2211')), ['1', '2']);
test('a Map keeps the order they arrived in', [...countByMap('2211').keys()], ['2', '1']);
test('a plain object already has an inherited constructor property', typeof countCharacters('x').constructor, 'function');
test('which a Map is immune to', countByMap('x').get('constructor'), undefined);
Two reasons to prefer a Map here, and both are asserted. Integer-like keys are re-sorted to the front of a plain object, so counting digits gives you the keys in numeric order rather than in the order they appeared. And a plain object arrives with inherited names already on it, so counts['constructor'] is a function before you have counted anything.
Object.create(null) fixes the second problem and not the first. A Map fixes both, and it also keeps 1 and '1' as distinct keys, which an object cannot.
Answers JS String 34 in the original set.
35 Count the words in a sentence
fairCount the words in a sentence, treating any run of whitespace as one separator. Get it right for an empty string and for a string of only spaces.
Stuck? Three hints, smallest first
- 1. Trim first, then split on
/\s+/. - 2. Splitting an empty or blank string still produces an array with something in it, so guard.
- 3. Decide deliberately whether a hyphenated word is one word or two.
const countWords = (text) => {
const trimmed = text.trim();
return trimmed === '' ? 0 : trimmed.split(/\s+/).length;
};
console.log("countWords('the quick fox') :", countWords('the quick fox'));
console.log("countWords(' ') :", countWords(' '));
console.log("' '.split(/\\s+/) :", ' '.split(/\s+/), '<- length 2, hence the guard');
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('three words', countWords('the quick fox'), 3);
test('extra spaces between words add nothing', countWords('the quick fox'), 3);
test('leading and trailing spaces are trimmed first', countWords(' hi '), 1);
test('an empty string has no words', countWords(''), 0);
test('and neither does a string of only spaces', countWords(' '), 0);
test('without the guard the split would have reported two', ' '.split(/\s+/).length, 2);
test('newlines and tabs separate words too', countWords('a\nb\tc'), 3);
test('a hyphenated word counts as one', countWords('well-known fact'), 2);
test('punctuation does not split a word either', countWords('hi, there!'), 2);
test('so "word" here means a run of non-whitespace', countWords("it's fine"), 2);
The guard is the exercise. ''.split(/\s+/) is [''] and ' '.split(/\s+/) is ['', ''], so the obvious one-liner reports 1 and 2 words for input that has none. Trimming fixes the second case and not the first, which is why the explicit empty check is there.
"Word" is a definition, not a fact. This version counts runs of non-whitespace, so 'well-known' is one word and 'hi,' includes its comma. If you need linguistic words, match /[\p{L}\p{N}']+/gu instead and decide about the apostrophe on purpose.
Answers JS String 35 in the original set.
36 Count the vowels
easyCount the vowels in a word, ignoring case. Then try it on 'café' and explain the answer.
Stuck? Three hints, smallest first
- 1.
matchwith thegandiflags gives every vowel. - 2.
matchreturnsnullrather than an empty array when nothing matches. - 3. An accented vowel is not in
[aeiou], but it is afternormalize(‘NFD’).
const countVowels = (text) => (text.match(/[aeiou]/gi) || []).length;
console.log("countVowels('lagos') :", countVowels('lagos'));
console.log("'rhythm'.match(...) :", 'rhythm'.match(/[aeiou]/gi), '<- null, hence the || []');
console.log("countVowels('café') :", countVowels('café'), '<- é is not in the class');
console.log("after NFD :", countVowels('café'.normalize('NFD')), '<- now it is an e plus a mark');
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 word', countVowels('lagos'), 2);
test('the i flag makes it case-insensitive', countVowels('LAGOS'), 2);
test('no vowels gives zero, not null', countVowels('rhythm'), 0);
test('match itself returns null, which is why the default is there', 'rhythm'.match(/[aeiou]/gi), null);
test('an empty string has none', countVowels(''), 0);
test('y is not counted here', countVowels('sky'), 0);
test('a vowel-only string counts every letter', countVowels('aeiou'), 5);
test('a sentence', countVowels('the quick brown fox'), 5);
test('an accented vowel is NOT in the character class', countVowels('café'), 1);
test('decomposing first separates the e from its accent', countVowels('café'.normalize('NFD')), 2);
(text.match(re) || []).length is the idiom worth memorising. match with g returns null for no matches rather than [], so counting without the default throws on exactly the input you were counting for.
The 'café' pair is the useful bit. [aeiou] is an ASCII assumption hiding inside a task that sounds language-neutral. normalize('NFD') splits é into e plus a combining accent, at which point the e matches — which is also the trick behind accent-insensitive search, in exercise 44.
Answers JS String 36 in the original set.
37 Count occurrences of a substring
fairCount how many times 'the' appears in a sentence. Then count 'aa' in 'aaaa' and decide whether the answer is 2 or 3.
Stuck? Three hints, smallest first
- 1. Loop with
indexOfand a start position that moves forward each time. - 2. Advancing by the needle length counts non-overlapping matches; advancing by one counts overlaps.
- 3. An empty needle would loop forever, so refuse it.
function countOccurrences(text, needle) {
if (needle === '') return 0;
let count = 0;
let at = text.indexOf(needle);
while (at !== -1) {
count++;
at = text.indexOf(needle, at + needle.length); // skip past the whole match
}
return count;
}
function countOverlapping(text, needle) {
if (needle === '') return 0;
let count = 0;
let at = text.indexOf(needle);
while (at !== -1) {
count++;
at = text.indexOf(needle, at + 1); // advance one, so overlaps count
}
return count;
}
console.log("'the' in the sentence :", countOccurrences('the cat sat on the mat', 'the'));
console.log("'aa' in 'aaaa', skipping :", countOccurrences('aaaa', 'aa'));
console.log("'aa' in 'aaaa', overlapping :", countOverlapping('aaaa', 'aa'));
console.log('regex with g agrees with the skipping version:', ('aaaa'.match(/aa/g) || []).length);
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 occurrences in the sentence', countOccurrences('the cat sat on the mat', 'the'), 2);
test('nothing found is zero', countOccurrences('the cat', 'dog'), 0);
test('non-overlapping counting gives 2 for aa in aaaa', countOccurrences('aaaa', 'aa'), 2);
test('counting overlaps gives 3', countOverlapping('aaaa', 'aa'), 3);
test('the split trick agrees with the non-overlapping version', 'aaaa'.split('aa').length - 1, 2);
test('and so does a global regex', ('aaaa'.match(/aa/g) || []).length, 2);
test('so a regex cannot count overlaps either', ('aaaa'.match(/aa/g) || []).length !== countOverlapping('aaaa', 'aa'), true);
test('the empty needle is refused rather than looping forever', countOccurrences('abc', ''), 0);
test('while the split trick would have claimed two', 'abc'.split('').length - 1, 2);
test('counting is case sensitive', countOccurrences('The the', 'the'), 1);
Neither answer for 'aa' in 'aaaa' is wrong; they answer different questions. "How many times could I cut this out?" is 2. "At how many positions does it appear?" is 3. Decide which one your caller wants, and name the function accordingly.
Both the split trick and a global regex count non-overlapping matches, because both consume the match. Overlapping counting needs the manual loop with at + 1, or a lookahead pattern such as /(?=aa)/g.
The empty-needle guard is not defensive padding. indexOf('') returns the start position you gave it, so without the guard at never advances and the loop never ends.
Answers JS String 37 in the original set.
38 Reverse a string
fairReverse 'lagos'. Then reverse 'ab👍' with split('').reverse().join('') and check whether the emoji survived.
Stuck? Three hints, smallest first
- 1. Strings have no
reverse, so go through an array. - 2.
split(‘’)splits into code units, which is where the emoji breaks. - 3. Spreading iterates code points, so it keeps a surrogate pair together.
const reverse = (text) => text.split('').reverse().join('');
const reverseByCodePoint = (text) => [...text].reverse().join('');
console.log("reverse('lagos') :", reverse('lagos'));
console.log("reverse('ab👍') :", reverse('ab👍'), '<- the emoji is now two stray halves');
console.log("reverseByCodePoint('ab👍') :", reverseByCodePoint('ab👍'));
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 plain word reverses', reverse('lagos'), 'sogal');
test('an empty string reverses to itself', reverse(''), '');
test('and so does a single character', reverse('a'), 'a');
test('reversing twice returns the original', reverse(reverse('lagos')), 'lagos');
test('spaces move like anything else', reverse('a b'), 'b a');
test('for plain text both versions agree', reverseByCodePoint('lagos'), 'sogal');
test('but split BREAKS the emoji, because it cuts code units apart', reverse('ab👍') === '👍ba', false);
test('spreading keeps it whole', reverseByCodePoint('ab👍'), '👍ba');
test('the broken result still has the right number of code units', reverse('ab👍').length, 4);
test('it is the ORDER of the two halves that is wrong', reverse('ab👍').slice(0, 2) === '👍', false);
This is the interview question that everybody knows and almost everybody answers with the broken version. split('').reverse().join('') is fine for ASCII and produces unrenderable text for anything above U+FFFF, because the two halves of a surrogate pair come out in the wrong order.
[...text] fixes emoji. It does not fix combining marks, where the accent would end up attached to the wrong letter, and it does not fix ZWJ sequences. Exercise 39 goes after those.
Answers JS String 38 in the original set.
39 Reverse a string without breaking the accents
hardReverse 'cafe' written with a combining acute accent, so the accent stays on its own letter. Then try the same function on a family emoji.
Stuck? Three hints, smallest first
- 1. A combining mark is a separate code point that belongs to the character before it.
- 2. Group each base character with the marks that follow it, then reverse the groups.
- 3.
Intl.Segmenterwith grapheme granularity does this properly, including emoji sequences.
const reverseCodePoints = (text) => [...text].reverse().join('');
// Group every character with any combining marks that follow it. \p{M} is the
// Unicode "mark" category, and it needs the u flag.
const graphemes = (text) => text.match(/[\s\S]\p{M}*/gu) || [];
const reverseGraphemes = (text) => graphemes(text).reverse().join('');
// The real answer, where it is available.
const hasSegmenter = typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function';
const reverseBySegmenter = (text) =>
hasSegmenter
? [...new Intl.Segmenter('en', { granularity: 'grapheme' }).segment(text)]
.map((s) => s.segment)
.reverse()
.join('')
: reverseGraphemes(text);
const decomposed = 'cafe\u0301'; // cafe + combining acute
const family = '\u{1F468}\u200D\u{1F469}\u200D\u{1F467}'; // man + ZWJ + woman + ZWJ + girl
console.log('code points :', decomposed.length, ' graphemes:', graphemes(decomposed).length);
console.log('by code point :', JSON.stringify(reverseCodePoints(decomposed)), '<- the accent moved');
console.log('by grapheme :', JSON.stringify(reverseGraphemes(decomposed)));
console.log('family emoji, marks version :', reverseGraphemes(family) === family);
console.log('family emoji, Segmenter :', reverseBySegmenter(family) === family);
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 word is five code points but four graphemes', [decomposed.length, graphemes(decomposed).length], [5, 4]);
test('reversing by code point puts the accent on the wrong letter', reverseCodePoints(decomposed) === 'e\u0301fac', false);
test('grouping the mark with its letter fixes it', reverseGraphemes(decomposed), 'e\u0301fac');
test('a plain word is unaffected either way', [reverseCodePoints('lagos'), reverseGraphemes('lagos')], ['sogal', 'sogal']);
test('an astral character survives, since both work above code units', reverseGraphemes('ab👍'), '👍ba');
test('unlike split, which works in code units', 'ab👍'.split('').reverse().join('') === '👍ba', false);
test('reversing twice gets the graphemes back', reverseGraphemes(reverseGraphemes(decomposed)), decomposed);
test('a ZWJ emoji is beyond the mark-based version', reverseGraphemes(family) === family, false);
test('because a zero-width joiner is not a combining mark', /\p{M}/u.test('\u200D'), false);
test('Intl.Segmenter handles that one', hasSegmenter ? reverseBySegmenter(family) === family : true, true);
test('and it agrees with the mark version on the accented word', reverseBySegmenter(decomposed), reverseGraphemes(decomposed));
There are three levels of "character" and reversing exposes all of them. Code units break emoji. Code points break combining marks. Only grapheme clusters — what a reader would call a character — survive, and computing those properly needs the Unicode segmentation rules.
The \p{M}* trick handles the common case, which is Latin text with accents, and it fails on emoji built from joined sequences: a family is five code points held together by zero-width joiners, none of which is a mark. Intl.Segmenter knows the real rules and is in every current engine. Use it, and keep the regex version only as a fallback.
The honest conclusion is that reversing text is a display-layer operation that almost never needs doing. If it does, use Intl.Segmenter and do not write your own table.
Answers JS String 39 in the original set.
40 Palindromes, with and without punctuation
fairDecide whether a string reads the same backwards. Make 'A man, a plan, a canal: Panama' count, and be explicit about what you threw away to get there.
Stuck? Three hints, smallest first
- 1. A strict comparison is one line; the work is in deciding what to ignore.
- 2. Lower-case and strip everything that is not a letter or digit first.
- 3. Check what your cleaning function does to a string that is all punctuation.
const isStrictPalindrome = (text) => text === [...text].reverse().join('');
const clean = (text) => text.toLowerCase().replace(/[^a-z0-9]/g, '');
const isPalindrome = (text) => {
const c = clean(text);
return c === [...c].reverse().join('');
};
for (const t of ['racecar', 'Racecar', 'A man, a plan, a canal: Panama', 'lagos', '!!!']) {
console.log(JSON.stringify(t).padEnd(34), 'strict:', String(isStrictPalindrome(t)).padEnd(6), 'cleaned:', isPalindrome(t));
}
console.log("clean('!!!') is", JSON.stringify(clean('!!!')), '<- which is why !!! passes');
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 plain palindrome', isStrictPalindrome('racecar'), true);
test('a capital letter breaks the strict version', isStrictPalindrome('Racecar'), false);
test('cleaning first fixes that', isPalindrome('Racecar'), true);
test('punctuation and spaces are ignored once cleaned', isPalindrome('A man, a plan, a canal: Panama'), true);
test('digits count as characters', isPalindrome('12321'), true);
test('a date-shaped one', isPalindrome('02/02/2020'), true);
test('a non-palindrome is rejected', isPalindrome('lagos'), false);
test('the empty string passes, by convention', isPalindrome(''), true);
test('and so does a string of only punctuation, which may surprise you', isPalindrome('!!!'), true);
test('because cleaning removed everything', clean('!!!'), '');
test('the a-z class also strips accents, changing the meaning of the word', clean('Ésope'), 'sope');
Also in Function 2 and Recursion 10. Those two go after the algorithm — two pointers, and the recursive form. This one is about the cleaning step, which is where the real decisions are.
isPalindrome('!!!') returning true is not a bug, it is the consequence of a definition: after cleaning there is nothing left, and nothing reads the same backwards. If that answer is wrong for your caller, the fix is to reject input that cleans to an empty string, not to change the comparison.
[^a-z0-9] quietly deletes every accented letter, so 'Ésope' becomes 'sope'. For text that is not English, normalise and strip marks — exercise 44 — before filtering, or filter on \p{L} and \p{N} instead.
Answers JS String 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.