Emmanuel Akinfulubi

JavaScript: Regular expressions

Matching, capturing, replacing and validating.

21 exercises

1 Does the string start with a capital letter?

easy

Test whether the first character of a string is uppercase.

Stuck? Three hints, smallest first
  1. 1. /^[A-Z]/ anchors to the start with ^.
  2. 2. [A-Z] covers English only. À, Ć and Ж are uppercase too.
  3. 3. A general test is c === c.toUpperCase() && c !== c.toLowerCase().

The answer

// English letters only.
const startsUpperAscii = (text) => /^[A-Z]/.test(text);

// Any script, using Unicode property escapes. The u flag is required.
const startsUpperUnicode = (text) => /^\p{Lu}/u.test(text);

// No regex at all. Works for any letter, and shows what the test really means:
// a character that has a distinct lower-case form and is already in upper case.
function startsUpperPlain(text) {
  const first = [...text][0];                 // spread, so an astral character stays whole
  if (first === undefined) return false;
  return first === first.toUpperCase() && first !== first.toLowerCase();
}

const cases = ['Lagos', 'lagos', '\u00c9tienne', '\u00e9tienne', '\u0416ukov', '9ja', ' Lagos', '', '\u20a6500'];

for (const t of cases) {
  console.log(JSON.stringify(t).padEnd(10),
    'ascii:', String(startsUpperAscii(t)).padEnd(6),
    'unicode:', String(startsUpperUnicode(t)).padEnd(6),
    'plain:', startsUpperPlain(t));
}

Output


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

13 checks

test('a capital passes', startsUpperAscii('Lagos'), true);
test('lower case fails', startsUpperAscii('lagos'), false);
test('a digit fails', startsUpperAscii('9ja'), false);
test('a leading space fails, because ^ means the very start', startsUpperAscii(' Lagos'), false);
test('an empty string fails', startsUpperAscii(''), false);
test('a currency symbol fails', startsUpperAscii('\u20a6500'), false);
test('the ASCII version misses an accented capital', startsUpperAscii('\u00c9tienne'), false);
test('the Unicode version catches it', startsUpperUnicode('\u00c9tienne'), true);
test('and Cyrillic too', startsUpperUnicode('\u0416ukov'), true);
test('while still rejecting lower case', startsUpperUnicode('\u00e9tienne'), false);
test('the plain version agrees with the Unicode one everywhere', cases.every((t) => startsUpperPlain(t) === startsUpperUnicode(t)), true);
test('and disagrees with the ASCII one', cases.some((t) => startsUpperPlain(t) !== startsUpperAscii(t)), true);
test('a digit has no case, so the plain test rejects it', startsUpperPlain('9'), false);

/^[A-Z]/ is the expected answer and it is wrong for most of the world. É, Ж and Ć are capital letters that [A-Z] does not contain, so a name field guarded by it rejects real names. /^p{Lu}/u means "an uppercase letter in any script" and needs the u flag to work at all.

The regex-free version is worth understanding because it says what the question actually means: a character is upper case if it equals its own upper-cased form and differs from its lower-cased form. The second half is what excludes digits and punctuation, which are equal to both. Note [...text][0] rather than text[0], so a character outside the basic range is not split in half.

Answers JS RegExp 1 in the original set.

2 Check a credit card number

hard

Check a card number. A pattern can identify the brand; use the checksum that actually detects typos as well.

Stuck? Three hints, smallest first
  1. 1. The Luhn algorithm: double every second digit from the right, subtract 9 if over 9, and the total must divide by 10.
  2. 2. Brand prefixes differ in length: Visa starts 4, Mastercard 51–55 or 2221–2720, Amex 34 or 37.
  3. 3. Strip spaces and dashes before doing anything. People type them.

The answer

// The checksum. This is the part that catches a mistyped digit; a regex cannot.
function luhnValid(input) {
  const digits = input.replace(/\D/g, '');
  if (digits.length < 12) return false;
  let sum = 0;
  [...digits].reverse().forEach((char, i) => {
    let n = Number(char);
    if (i % 2 === 1) { n *= 2; if (n > 9) n -= 9; }
    sum += n;
  });
  return sum % 10 === 0;
}

// The brand, from the prefix and length. Shape only: it says which network the
// number belongs to, not that the card exists.
const BRANDS = [
  { name: 'Visa', pattern: /^4\d{12}(\d{3})?(\d{3})?$/ },
  { name: 'Mastercard', pattern: /^(5[1-5]\d{14}|2(22[1-9]|2[3-9]\d|[3-6]\d\d|7[01]\d|720)\d{12})$/ },
  { name: 'American Express', pattern: /^3[47]\d{13}$/ },
  { name: 'Discover', pattern: /^6(?:011|5\d{2})\d{12}$/ },
  { name: 'Diners Club', pattern: /^3(?:0[0-5]|[68]\d)\d{11}$/ },
];

function checkCard(input) {
  const digits = String(input).replace(/[\s-]/g, '');
  if (!/^\d+$/.test(digits)) return { ok: false, why: 'digits, spaces and dashes only' };
  const brand = BRANDS.find((b) => b.pattern.test(digits));
  if (!luhnValid(digits)) return { ok: false, why: 'the checksum does not match: check for a typo', brand: brand?.name ?? null };
  if (!brand) return { ok: false, why: 'the checksum passes but the prefix matches no known network', brand: null };
  return { ok: true, brand: brand.name };
}

// Standard published test numbers. None of these is a real account.
const samples = [
  '4111 1111 1111 1111',
  '5500 0055 5555 5559',
  '3782 822463 10005',
  '6011 1111 1111 1117',
  '3056 930902 5904',
  '4111 1111 1111 1112',      // one digit changed
];

for (const s of samples) {
  const r = checkCard(s);
  console.log(s.padEnd(22), r.ok ? `ok, ${r.brand}` : `refused: ${r.why}`);
}
console.log('a single transposed digit is caught:', checkCard('4111111111111121').ok);

Output


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

16 checks

test('a valid Visa passes', checkCard('4111 1111 1111 1111'), { ok: true, brand: 'Visa' });
test('a valid Mastercard', checkCard('5500 0055 5555 5559').brand, 'Mastercard');
test('a valid Amex', checkCard('3782 822463 10005').brand, 'American Express');
test('a valid Discover', checkCard('6011 1111 1111 1117').brand, 'Discover');
test('a valid Diners Club', checkCard('3056 930902 5904').brand, 'Diners Club');
test('spaces are ignored', checkCard('4111111111111111').ok, true);
test('dashes are ignored too', checkCard('4111-1111-1111-1111').ok, true);
test('one changed digit fails the checksum', checkCard('4111 1111 1111 1112').ok, false);
test('and the reason says so', checkCard('4111 1111 1111 1112').why.includes('checksum'), true);
test('but the brand is still identified', checkCard('4111 1111 1111 1112').brand, 'Visa');
test('letters are refused', checkCard('4111 1111 1111 111a').ok, false);
test('an empty string is refused', checkCard('').ok, false);
test('the Luhn check alone rejects a bad number', luhnValid('4111111111111112'), false);
test('and accepts a good one', luhnValid('4111111111111111'), true);
test('a transposed pair is caught', checkCard('4111111111111121').ok, false);
test('the checksum cannot catch every transposition, though', luhnValid('4111111111111111') === luhnValid('4111111111111111'), true);

The Luhn checksum is the real content here. A regex can confirm a number is sixteen digits starting with a 4; only the checksum notices that one of them is wrong, and the assertions show a single changed digit being caught. Every card network uses it, and it is what stops a mistyped number reaching a payment processor.

Be clear about what it is not. Luhn detects all single-digit errors and most transpositions, but it is a typo check, not authentication — a number that passes may be closed, expired, or invented. The prefix patterns tell you the network, and those ranges change: Mastercard's 2221–2720 block was added in 2017, so any pattern written before then rejects valid cards.

All the numbers above are published test numbers, not real accounts. And in real work, do not build this: use the payment provider's client library, so a card number never touches your server.

Answers JS RegExp 2 in the original set.

3 A pattern for email addresses

hard

Match email addresses. The source is specific: the local part allows letters, digits and ! # $ % & ' * + - / = ? ^ _ ` { | } ~, and a dot that is neither first nor last and never doubled.

Stuck? Three hints, smallest first
  1. 1. Build the local part as: one allowed character, then optionally more groups of "dot then characters".
  2. 2. That structure makes a leading dot, a trailing dot and a double dot impossible without any lookahead.
  3. 3. The only real check that an address works is sending a message to it.

The answer

// The character set the source lists, minus the dot.
const ATOM = "[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]";

/* Local part = atom+ then zero or more of (dot atom+). This shape makes
   ".a", "a." and "a..b" unmatchable, so no lookahead is needed. */
const LOCAL = `${ATOM}+(?:\\.${ATOM}+)*`;
const DOMAIN = '[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)+';

const EMAIL = new RegExp(`^${LOCAL}@${DOMAIN}$`);

const matchesEmail = (value) => typeof value === 'string' && value.length <= 254 && EMAIL.test(value);

const good = [
  'ada@example.com',
  'ada.obi@example.com',
  "o'brien@example.com",
  'a!#$%&*+-/=?^_`{|}~b@example.com',
  'ada+bus@example.co.uk',
  'a@b.co',
];
const bad = [
  '.ada@example.com',        // dot first
  'ada.@example.com',        // dot last
  'ada..obi@example.com',    // doubled dot
  'ada@example',             // no dot in the domain
  'ada@@example.com',
  'ada example@x.com',       // space
  '@example.com',
  'ada@',
  '',
];

for (const e of good) console.log('should match  ', e.padEnd(34), matchesEmail(e));
for (const e of bad) console.log('should not    ', JSON.stringify(e).padEnd(34), matchesEmail(e));

Output


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

18 checks

test('a plain address matches', matchesEmail('ada@example.com'), true);
test('a dot inside the local part is allowed', matchesEmail('ada.obi@example.com'), true);
test('an apostrophe is allowed, as the source lists it', matchesEmail("o'brien@example.com"), true);
test('every listed special character together', matchesEmail('a!#$%&*+-/=?^_`{|}~b@example.com'), true);
test('a plus sign is allowed', matchesEmail('ada+bus@example.co.uk'), true);
test('a leading dot is refused', matchesEmail('.ada@example.com'), false);
test('a trailing dot is refused', matchesEmail('ada.@example.com'), false);
test('a doubled dot is refused', matchesEmail('ada..obi@example.com'), false);
test('a domain with no dot is refused', matchesEmail('ada@example'), false);
test('two at-signs are refused', matchesEmail('ada@@example.com'), false);
test('a space is refused', matchesEmail('ada example@x.com'), false);
test('an empty local part is refused', matchesEmail('@example.com'), false);
test('an empty domain is refused', matchesEmail('ada@'), false);
test('an empty string is refused', matchesEmail(''), false);
test('every good example matches', good.every(matchesEmail), true);
test('and no bad one does', bad.some(matchesEmail), false);
test('but a nonsense address that LOOKS right still passes', matchesEmail('nobody@nowhere.example'), true);
test('and so does a typo', matchesEmail('ada@gmial.com'), true);

The structure atom+ (dot atom+)* is the idea worth keeping. Rather than matching anything and then using lookaheads to forbid a leading dot, a trailing dot and a doubled dot, the shape makes all three impossible. Building the pattern from named pieces in template strings also makes it readable — a single 200-character regex literal is unmaintainable, and this one is long even so.

Now the honest part, which the last two assertions make concrete: nobody@nowhere.example and ada@gmial.com both pass. A pattern checks shape. It cannot know whether an address exists, and the only way to find out is to send a message and see. So use a loose pattern to catch obvious slips, always let the user correct it, and never block a submission because your regex disagrees with an address someone tells you is theirs. The full RFC 5322 grammar permits quoted strings, comments and nested parentheses; matching it exactly is possible and pointless.

Answers JS RegExp 3 in the original set.

4 Find a date inside a string

fair

Pull a date out of surrounding text. Then check it is a date that exists, which a pattern cannot do.

Stuck? Three hints, smallest first
  1. 1. Named groups, (?<year>\d{4}), read far better than match[1].
  2. 2. Use the g flag with matchAll to find every date, not just the first.
  3. 3. new Date(2025, 1, 30) silently becomes 2 March. Check the parts survived.

The answer

// Named groups, so the result reads as words rather than numbers.
const DATE = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/g;

function findDates(text) {
  return [...text.matchAll(DATE)].map((m) => ({
    text: m[0],
    index: m.index,
    ...m.groups,
  }));
}

/* A pattern accepts 2025-02-30. The only reliable existence check is to build
   the date and confirm nothing was rolled over. */
function isRealDate(year, month, day) {
  const y = Number(year);
  const m = Number(month);
  const d = Number(day);
  const date = new Date(Date.UTC(y, m - 1, d));
  return date.getUTCFullYear() === y && date.getUTCMonth() === m - 1 && date.getUTCDate() === d;
}

const text = 'Departs 2025-03-14 from Ojota, returns 2025-03-21. Booked on 2024-12-01.';

console.log('found:', findDates(text).map((d) => d.text));
console.log('first :', findDates(text)[0]);
console.log('--- a pattern cannot tell a real date from an impossible one ---');
for (const candidate of ['2025-02-30', '2025-13-01', '2024-02-29', '2025-02-29', '2025-04-31']) {
  const [y, m, d] = candidate.split('-');
  console.log(candidate, 'matches the pattern:', /^\d{4}-\d{2}-\d{2}$/.test(candidate), '| is a real date:', isRealDate(y, m, d));
}
console.log('what Date does with 2025-02-30:', new Date(Date.UTC(2025, 1, 30)).toISOString().slice(0, 10), '<- rolled over');

Output


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

17 checks

test('every date in the text is found', findDates(text).length, 3);
test('the first one', findDates(text)[0].text, '2025-03-14');
test('its parts are named', findDates(text)[0].year, '2025');
test('and the month', findDates(text)[0].month, '03');
test('and the day', findDates(text)[0].day, '14');
test('the position is reported', findDates(text)[0].index, 8);
test('the last one is found too', findDates(text).at(-1).text, '2024-12-01');
test('text with no date gives nothing', findDates('no dates here'), []);
test('the parts are strings, not numbers', typeof findDates(text)[0].day, 'string');
test('30 February matches the pattern', /^\d{4}-\d{2}-\d{2}$/.test('2025-02-30'), true);
test('but is not a real date', isRealDate('2025', '02', '30'), false);
test('month 13 is not either', isRealDate('2025', '13', '01'), false);
test('31 April is not', isRealDate('2025', '04', '31'), false);
test('29 February exists in a leap year', isRealDate('2024', '02', '29'), true);
test('and does not in a normal one', isRealDate('2025', '02', '29'), false);
test('a real date passes both checks', isRealDate('2025', '03', '14'), true);
test('Date rolls an impossible day into the next month', new Date(Date.UTC(2025, 1, 30)).getUTCMonth(), 2);

Named capture groups turn match[1], match[2], match[3] into groups.year, groups.month, groups.day. It costs nothing and removes a whole class of off-by-one mistake, especially once a pattern gains a group in the middle and every later number shifts.

The substance is the second half. 2025-02-30 matches any date pattern you can write, and new Date(Date.UTC(2025, 1, 30)) does not complain — it silently becomes 2 March, which the last assertion confirms. So the existence check is: build the date, then verify the year, month and day that come back are the ones you put in. If they differ, the input rolled over and was never a real date. This is the only reliable way, and it handles leap years for free — the assertions check 29 February in both 2024 and 2025.

Answers JS RegExp 4 in the original set.

5 A trim function built from a regex

easy

Remove whitespace from both ends of a string with a regular expression, and provide left-only and right-only versions.

Stuck? Three hints, smallest first
  1. 1. /^\s+|\s+$/g replaced with nothing does both ends at once.
  2. 2. \s covers tabs, newlines and a non-breaking space, not only the plain space.
  3. 3. Anchor both ends, or you strip the spaces inside the string too.

The answer

const trim = (text) => text.replace(/^\s+|\s+$/g, '');
const trimStart = (text) => text.replace(/^\s+/, '');
const trimEnd = (text) => text.replace(/\s+$/, '');

// The mistake this exercise exists to show: no anchors.
const broken = (text) => text.replace(/\s+/g, '');

const messy = '   Lagos to Abuja   ';
const mixed = '\t\n  Ojota \u00a0\r\n';

console.log('input      :', JSON.stringify(messy));
console.log('trim       :', JSON.stringify(trim(messy)));
console.log('trimStart  :', JSON.stringify(trimStart(messy)));
console.log('trimEnd    :', JSON.stringify(trimEnd(messy)));
console.log('no anchors :', JSON.stringify(broken(messy)), '<- ate the inner spaces too');
console.log('tabs, newlines and a non-breaking space:', JSON.stringify(trim(mixed)));
console.log('built-in agrees:', trim(messy) === messy.trim());

Output


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

14 checks

test('both ends are trimmed', trim(messy), 'Lagos to Abuja');
test('it agrees with the built-in trim', trim(messy), messy.trim());
test('the inner space survives', trim(messy).includes(' '), true);
test('the unanchored version destroys it', broken(messy), 'LagostoAbuja');
test('trimStart leaves the end alone', JSON.stringify(trimStart(messy)), JSON.stringify('Lagos to Abuja   '));
test('trimEnd leaves the start alone', JSON.stringify(trimEnd(messy)), JSON.stringify('   Lagos to Abuja'));
test('they agree with the built-ins', [trimStart(messy) === messy.trimStart(), trimEnd(messy) === messy.trimEnd()], [true, true]);
test('a string with nothing to trim is unchanged', trim('Lagos'), 'Lagos');
test('a string of only spaces becomes empty', trim('     '), '');
test('an empty string stays empty', trim(''), '');
test('tabs and newlines are whitespace', trim('\t\nLagos\n\t'), 'Lagos');
test('and a non-breaking space is too', trim('\u00a0Lagos\u00a0'), 'Lagos');
test('but a zero-width space is NOT', trim('\u200bLagos'), '\u200bLagos');
test('so it survives the trim', trim('\u200bLagos').length, 6);

/^s+|s+$/g is the whole answer: the alternation matches either end, and the g flag lets it do both in one pass. Without the anchors you get /s+/g, which removes every run of whitespace — the assertion shows ' Lagos to Abuja ' becoming 'LagostoAbuja'. That is a real bug people ship.

s is broader than a space: tabs, newlines, carriage returns, form feeds and the non-breaking space   all count, and the assertions check them. The one that catches people is the zero-width space ​, which is not in s and so survives — it is invisible, it arrives constantly from copy-pasted text, and it makes two apparently identical strings unequal.

In practice use String.prototype.trim, which is faster and clearer. The regex is worth writing once to understand what it is doing.

Answers JS RegExp 5 in the original set.

6 Count the words in a string

fair

Count words. The source is explicit: trim the ends, collapse runs of two or more spaces into one, and do not let a line starting with a space create an extra word.

Stuck? Three hints, smallest first
  1. 1. Trim first, then split on /\s+/, which collapses runs for you.
  2. 2. The empty string is the special case: ‘’.split(/\s+/) gives [‘’], length 1.
  3. 3. match(/\S+/g) is the other route, and it returns null rather than an empty array.

The answer

// Trim, then split on any run of whitespace. The run does the collapsing.
function countWords(text) {
  const trimmed = text.trim();
  if (trimmed === '') return 0;              // without this you get 1
  return trimmed.split(/\s+/).length;
}

// The other way: count the non-whitespace runs. Note match returns null.
const countByMatch = (text) => (text.match(/\S+/g) ?? []).length;

// The bug the source is warning about.
const naive = (text) => text.split(' ').length;

const cases = [
  'Lagos to Abuja',
  '   Lagos to Abuja   ',
  'Lagos    to     Abuja',
  '\n  Lagos\n  to\n  Abuja\n',
  'Lagos',
  '',
  '   ',
  'one-word',
  "it's a bus",
];

for (const t of cases) {
  console.log(JSON.stringify(t).padEnd(30), 'count:', countWords(t), ' naive split(" "):', naive(t));
}
console.log("'' .split(/\\s+/) gives", JSON.stringify(''.split(/\s+/)), 'length', ''.split(/\s+/).length);
console.log("'abc'.match(/xyz/) gives", 'abc'.match(/xyz/), '<- null, not []');

Output


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

16 checks

test('three words', countWords('Lagos to Abuja'), 3);
test('surrounding spaces do not add words', countWords('   Lagos to Abuja   '), 3);
test('runs of spaces collapse', countWords('Lagos    to     Abuja'), 3);
test('newlines and indentation do not add words', countWords('\n  Lagos\n  to\n  Abuja\n'), 3);
test('one word', countWords('Lagos'), 1);
test('the empty string has no words', countWords(''), 0);
test('and neither does a string of spaces', countWords('   '), 0);
test('a hyphenated word counts as one', countWords('one-word'), 1);
test('an apostrophe does not split a word', countWords("it's a bus"), 3);
test('the match version agrees on every case', cases.every((t) => countWords(t) === countByMatch(t)), true);
test('the naive split is wrong on runs of spaces', naive('Lagos    to     Abuja'), 10);
test('because it produces empty strings', 'Lagos    to     Abuja'.split(' ').filter((w) => w === '').length, 7);
test('and wrong on the empty string', naive(''), 1);
test('which is the whole reason for the guard', ''.split(/\s+/).length, 1);
test('match returns null when nothing matches', ''.match(/\S+/g), null);
test('which is why ?? [] is needed', (''.match(/\S+/g) ?? []).length, 0);

Two separate traps, and the source hints at both. Splitting on a literal ' ' turns 'a b' into a list containing empty strings — the assertion shows a three-word string counted as 11. Splitting on /s+/ treats a whole run as one separator, which also handles tabs and newlines.

Then ''.split(/s+/) returns [''], not [], so its length is 1 and an empty string counts as one word. That is why the guard comes first. The match route has the mirror-image problem: it returns null rather than an empty array when nothing matches, so it needs ?? []. Both assertions are there because both are easy to forget and neither fails loudly.

Worth noting "word" is doing a lot of unexamined work here. This counts whitespace-separated runs, so one-word is one and it's is one, which is probably what you want. For languages that do not put spaces between words, none of this applies, and Intl.Segmenter is the tool.

Answers JS RegExp 6 in the original set.

7 Validate an IP address

hard

Check whether a string is an IPv4 address. The obvious pattern accepts 999.999.999.999; make one that does not.

Stuck? Three hints, smallest first
  1. 1. \d{1,3} allows 0 to 999. The valid range is 0 to 255.
  2. 2. Match the range in pieces: 25[0-5], 2[0-4]\d, 1\d\d, [1-9]?\d.
  3. 3. Leading zeros should be refused: 01.1.1.1 is ambiguous, and historically read as octal.

The answer

// The pattern everyone writes first. It accepts 999.999.999.999.
const NAIVE = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/;

/* One octet, 0 to 255, with no leading zeros:
     25[0-5]    250-255
     2[0-4]\d   200-249
     1\d\d      100-199
     [1-9]?\d   0-99, and the optional first digit forbids "01"        */
const OCTET = '(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)';
const IPV4 = new RegExp(`^${OCTET}(\\.${OCTET}){3}$`);

const isIPv4 = (value) => typeof value === 'string' && IPV4.test(value);

// Without a regex at all, which is easier to read and to be sure of.
function isIPv4Plain(value) {
  if (typeof value !== 'string') return false;
  const parts = value.split('.');
  if (parts.length !== 4) return false;
  return parts.every((p) =>
    /^\d+$/.test(p) &&                       // digits only
    String(Number(p)) === p &&                // no leading zeros
    Number(p) <= 255);
}

const cases = ['192.168.1.1', '255.255.255.255', '0.0.0.0', '8.8.8.8',
  '999.999.999.999', '256.1.1.1', '1.2.3', '1.2.3.4.5', '01.1.1.1', '1.1.1.-1', '', 'abc'];

for (const ip of cases) {
  console.log(JSON.stringify(ip).padEnd(20), 'naive:', String(NAIVE.test(ip)).padEnd(6), 'correct:', isIPv4(ip));
}

Output


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

19 checks

test('a normal address passes', isIPv4('192.168.1.1'), true);
test('the maximum passes', isIPv4('255.255.255.255'), true);
test('all zeros passes', isIPv4('0.0.0.0'), true);
test('the naive pattern accepts nonsense', NAIVE.test('999.999.999.999'), true);
test('the correct one does not', isIPv4('999.999.999.999'), false);
test('256 is one too many', isIPv4('256.1.1.1'), false);
test('but 255 is fine', isIPv4('255.1.1.1'), true);
test('three parts is not enough', isIPv4('1.2.3'), false);
test('five is too many', isIPv4('1.2.3.4.5'), false);
test('a leading zero is refused', isIPv4('01.1.1.1'), false);
test('though the naive pattern allows it', NAIVE.test('01.1.1.1'), true);
test('a negative number is refused', isIPv4('1.1.1.-1'), false);
test('an empty string is refused', isIPv4(''), false);
test('letters are refused', isIPv4('abc'), false);
test('a number rather than a string is refused', isIPv4(19216811), false);
test('the plain version agrees on every case', cases.every((ip) => isIPv4(ip) === isIPv4Plain(ip)), true);
test('and the two disagree with the naive one', cases.some((ip) => NAIVE.test(ip) !== isIPv4(ip)), true);
test('249 is in the 2[0-4]d branch', isIPv4('249.0.0.0'), true);
test('and 199 in the 1dd branch', isIPv4('199.0.0.0'), true);

This is the clearest example in the section of a pattern that looks right and is not. d{1,3} means "one to three digits", which is 0 to 999, and the assertion confirms the naive pattern happily accepts 999.999.999.999. Matching a numeric range in a regex means enumerating it by shape: 25[0-5], then 2[0-4]d, then 1dd, then [1-9]?d. The optional first digit in that last branch is what refuses 01.

Leading zeros are worth refusing rather than tolerating. 01.1.1.1 is ambiguous — some networking tools historically read a leading zero as octal, so 010 meant 8 — and accepting it means two different strings for the same address.

The regex-free version is shorter, obviously correct, and gives a better error message. Reach for a pattern when the shape is the rule; when the rule is a numeric range, compare numbers. Note also that none of this covers IPv6, which needs a genuinely different approach.

Answers JS RegExp 7 in the original set.

8 Count vowels, and sort a string’s letters

easy

The source's title asks for a vowel count; its test data shows alphabetize_string('United States') giving "SUadeeinsttt". Both are done here.

Stuck? Three hints, smallest first
  1. 1. For counting: match(/[aeiou]/gi) and remember it returns null, not [].
  2. 2. For sorting: spread into characters, sort(), join back.
  3. 3. Default sort() compares by code unit, so every capital comes before every lower-case letter.

The answer

// What the TITLE asks for.
const countVowels = (text) => (text.match(/[aeiou]/gi) ?? []).length;

// What the TEST DATA asks for. Whitespace removed, then sorted.
const alphabetizeString = (text) => [...text.replace(/\s/g, '')].sort().join('');

// A breakdown, since "count the vowels" usually leads to "which ones".
function vowelBreakdown(text) {
  const found = text.toLowerCase().match(/[aeiou]/g) ?? [];
  const counts = {};
  for (const v of found) counts[v] = (counts[v] ?? 0) + 1;
  return counts;
}

console.log("countVowels('United States')      =", countVowels('United States'));
console.log("alphabetizeString('United States') =", JSON.stringify(alphabetizeString('United States')));
console.log("the source expects                 =", JSON.stringify('SUadeeinsttt'));
console.log('breakdown:', vowelBreakdown('United States'));
console.log('--- why capitals sort first ---');
console.log("'S'.charCodeAt(0) =", 'S'.charCodeAt(0), " 'a'.charCodeAt(0) =", 'a'.charCodeAt(0));
console.log("sorted by code unit:", JSON.stringify([...'bAaB'].sort().join('')));
console.log("sorted by locale   :", JSON.stringify([...'bAaB'].sort((x, y) => x.localeCompare(y, 'en')).join('')));

Output


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

16 checks

test("the source's exact expected output", alphabetizeString('United States'), 'SUadeeinsttt');
test('the space is removed', alphabetizeString('United States').includes(' '), false);
test('the length matches the input minus the space', alphabetizeString('United States').length, 12);
test('vowels in "United States"', countVowels('United States'), 5);
test('counting is case-insensitive', countVowels('AEIOU'), 5);
test('and matches lower case too', countVowels('aeiou'), 5);
test('a string with no vowels counts 0', countVowels('rhythm'), 0);
test('rather than crashing on null', 'rhythm'.match(/[aeiou]/gi), null);
test('an empty string counts 0', countVowels(''), 0);
test('y is not counted as a vowel here', countVowels('y'), 0);
test('the breakdown adds up to the total', Object.values(vowelBreakdown('United States')).reduce((a, b) => a + b, 0), countVowels('United States'));
test('and names each vowel', vowelBreakdown('United States'), { u: 1, i: 1, e: 2, a: 1 });
test('capitals sort before lower case by code unit', [...'bAaB'].sort().join(''), 'ABab');
test('because S is 83 and a is 97', ['S'.charCodeAt(0), 'a'.charCodeAt(0)], [83, 97]);
test('a locale sort groups the cases instead', [...'bAaB'].sort((x, y) => x.localeCompare(y, 'en')).join(''), 'aAbB');
test('sorting is stable for repeated characters', alphabetizeString('aaa'), 'aaa');

The source's title and its test data ask for different things, so both are here: countVowels for the title, alphabetizeString for the data, and the assertion reproduces "SUadeeinsttt" exactly.

That output explains itself once you look at the code units. Default sort() compares strings by UTF-16 code unit, and every ASCII capital (65–90) comes before every lower-case letter (97–122) — so S and U lead, and the assertion checks 83 against 97. If you want aAbB instead, pass localeCompare with an explicit locale.

The ?? [] after match is the recurring detail: match with the g flag returns null when nothing matches, not an empty array, so .length on it throws. The assertion checks the null directly so the reason is visible.

Answers JS RegExp 8 in the original set.

9 Validate a URL

fair

Check whether a string is a valid URL. Compare a pattern against the parser the browser already has.

Stuck? Three hints, smallest first
  1. 1. URL.canParse(value), or new URL() in a try/catch, is the real answer.
  2. 2. The parser accepts things you may not want, like javascript: and data:.
  3. 3. So validating a URL is two questions: is it parseable, and is its protocol one you allow?

The answer

// A typical hand-written pattern. Reasonable, and it disagrees with the parser.
const URL_PATTERN = /^https?:\/\/[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+(:\d+)?(\/[^\s]*)?$/;
const looksLikeUrl = (value) => URL_PATTERN.test(value);

// What the browser actually does.
const parses = (value) => {
  try { new URL(value); return true; } catch { return false; }
};

/* Validating a URL is really two questions. The second one is the security
   question, and it is the one people forget: a parseable URL can be
   javascript:alert(1), which is how a link becomes an attack. */
const ALLOWED = new Set(['http:', 'https:']);
function isSafeWebUrl(value) {
  try {
    const url = new URL(value);
    return ALLOWED.has(url.protocol);
  } catch {
    return false;
  }
}

const cases = [
  'https://example.com',
  'http://example.com/routes?from=Ojota#seats',
  'https://example.com:8080/a/b',
  'https://sub.domain.example.co.uk',
  'example.com',
  'https://localhost:3000',
  'ftp://files.example.com',
  'javascript:alert(1)',
  'data:text/html,<script>alert(1)</script>',
  'mailto:ada@example.com',
  'not a url',
  '',
];

for (const u of cases) {
  console.log(JSON.stringify(u).slice(0, 42).padEnd(44),
    'pattern:', String(looksLikeUrl(u)).padEnd(6),
    'parses:', String(parses(u)).padEnd(6),
    'safe web url:', isSafeWebUrl(u));
}

Output


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

19 checks

test('an https URL is valid', isSafeWebUrl('https://example.com'), true);
test('with a path, query and fragment', isSafeWebUrl('http://example.com/routes?from=Ojota#seats'), true);
test('with a port', isSafeWebUrl('https://example.com:8080/a/b'), true);
test('with subdomains', isSafeWebUrl('https://sub.domain.example.co.uk'), true);
test('a bare domain is not a URL', isSafeWebUrl('example.com'), false);
test('and the parser agrees', parses('example.com'), false);
test('nonsense is refused', isSafeWebUrl('not a url'), false);
test('an empty string is refused', isSafeWebUrl(''), false);
test('localhost parses', parses('https://localhost:3000'), true);
test('but the pattern rejects it, needing a dot', looksLikeUrl('https://localhost:3000'), false);
test('which is the pattern being wrong, not localhost', parses('https://localhost:3000'), true);
test('ftp parses as a URL', parses('ftp://files.example.com'), true);
test('but is not a web URL', isSafeWebUrl('ftp://files.example.com'), false);
test('javascript: PARSES as a URL', parses('javascript:alert(1)'), true);
test('which is exactly why the protocol must be checked', isSafeWebUrl('javascript:alert(1)'), false);
test('a data: URL parses too', parses('data:text/html,<script>alert(1)</script>'), true);
test('and is also refused', isSafeWebUrl('data:text/html,<script>alert(1)</script>'), false);
test('mailto: parses and is not a web URL', [parses('mailto:ada@example.com'), isSafeWebUrl('mailto:ada@example.com')], [true, false]);
test('URL.canParse agrees with the try/catch', URL.canParse('https://example.com'), parses('https://example.com'));

Do not write a pattern for this. new URL() is a complete, standards-tracking parser that already exists in every browser and in Node, and the assertions show the hand-written pattern rejecting https://localhost:3000 — which is a perfectly good URL you will use every day — because it insists on a dot.

The security point is the one to carry away. javascript:alert(1) and data:text/html,... are both valid URLs, and the assertions confirm the parser accepts both. So if you take a URL from a user and put it in an href, "it parsed" is not enough: that is how a link becomes script execution in your page. Check url.protocol against an allow-list of the schemes you actually want, which for a link is almost always just http: and https:. An allow-list, not a block-list — you will not think of every scheme worth blocking.

Answers JS RegExp 9 in the original set.

10 Is the value alphanumeric?

easy

Check that a string contains only letters and digits. Then decide whose letters you mean.

Stuck? Three hints, smallest first
  1. 1. /^[a-z0-9]+$/i is the usual answer, and it is English-only.
  2. 2. /^[\p{L}\p{N}]+$/u means letters and numbers in any script.
  3. 3. Decide what an empty string should be, and make the decision visible.

The answer

const isAlphanumericAscii = (value) => /^[a-z0-9]+$/i.test(value);

// Any script: \p{L} is any letter, \p{N} any numeric character. The u flag is required.
const isAlphanumericUnicode = (value) => /^[\p{L}\p{N}]+$/u.test(value);

const cases = ['abc123', 'ABC', '123', 'Ade\u0301', '\u00c9tienne', '\u0416ukov', '\u4e2d\u6587',
  'abc 123', 'abc-123', 'abc_123', '', '\u20a6500', 'e\u0301'];

for (const v of cases) {
  console.log(JSON.stringify(v).padEnd(14),
    'ascii:', String(isAlphanumericAscii(v)).padEnd(6),
    'unicode:', isAlphanumericUnicode(v));
}
console.log('--- the + means one or more, so an empty string is refused ---');
console.log("with * instead:", /^[a-z0-9]*$/i.test(''), '<- an empty string would pass');

Output


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

18 checks

test('letters and digits pass', isAlphanumericAscii('abc123'), true);
test('letters alone pass', isAlphanumericAscii('ABC'), true);
test('digits alone pass', isAlphanumericAscii('123'), true);
test('a space is refused', isAlphanumericAscii('abc 123'), false);
test('a dash is refused', isAlphanumericAscii('abc-123'), false);
test('an underscore is refused', isAlphanumericAscii('abc_123'), false);
test('an empty string is refused, because of the +', isAlphanumericAscii(''), false);
test('with * it would pass, which is the decision to be explicit about', /^[a-z0-9]*$/i.test(''), true);
test('a currency symbol is refused', isAlphanumericAscii('\u20a6500'), false);
test('the ASCII version refuses an accented letter', isAlphanumericAscii('\u00c9tienne'), false);
test('the Unicode version accepts it', isAlphanumericUnicode('\u00c9tienne'), true);
test('and Cyrillic', isAlphanumericUnicode('\u0416ukov'), true);
test('and Chinese', isAlphanumericUnicode('\u4e2d\u6587'), true);
test('while still refusing a space', isAlphanumericUnicode('abc 123'), false);
test('and a dash', isAlphanumericUnicode('abc-123'), false);
test('a combining accent is a mark, not a letter, so it is refused', isAlphanumericUnicode('e\u0301'), false);
test('until it is normalised into one character', isAlphanumericUnicode('e\u0301'.normalize('NFC')), true);
test('the two versions disagree on non-English text', cases.some((v) => isAlphanumericAscii(v) !== isAlphanumericUnicode(v)), true);

/^[a-z0-9]+$/i is the expected answer. Whether it is the right answer depends entirely on what the field is for: as a check on a generated reference code it is exactly right, and as a check on a person's name it rejects Étienne and Жуков. /^[p{L}p{N}]+$/u is the same idea without that assumption.

Two details the assertions pin down. + versus * decides whether an empty string passes, and it is worth choosing on purpose rather than discovering later. And a combining accent — e followed by U+0301 — is a mark, not a letter, so even the Unicode version refuses it until normalize('NFC') combines the pair into a single character. Any text you compare or validate should be normalised first, because two strings that look identical on screen need not be equal.

Answers JS RegExp 10 in the original set.

11 Is the value a time?

fair

Check whether a string is a valid time. Handle 24-hour and 12-hour forms, and optional seconds.

Stuck? Three hints, smallest first
  1. 1. Hours 0–23 is ([01]\d|2[0-3]). Minutes are [0-5]\d.
  2. 2. Make seconds optional with a group: (:[0-5]\d)?.
  3. 3. 12-hour clocks run 1–12, and 00:30 am is not a time.

The answer

// 24-hour, with optional seconds.
const TIME_24 = /^([01]\d|2[0-3]):[0-5]\d(:[0-5]\d)?$/;

// 12-hour, 1 to 12, with a required am or pm.
const TIME_12 = /^(0?[1-9]|1[0-2]):[0-5]\d(:[0-5]\d)?\s?([ap]m)$/i;

const isTime = (value) => TIME_24.test(value) || TIME_12.test(value);

// Named groups make the parts usable, not just testable.
const PARTS = /^(?<hour>[01]\d|2[0-3]):(?<minute>[0-5]\d)(?::(?<second>[0-5]\d))?$/;
function parseTime(value) {
  const m = PARTS.exec(value);
  if (!m) return null;
  const { hour, minute, second } = m.groups;
  return { hour: Number(hour), minute: Number(minute), second: Number(second ?? 0) };
}

const cases = ['00:00', '09:05', '23:59', '23:59:59', '24:00', '12:60', '9:05',
  '1:05 pm', '12:00 AM', '13:00 pm', '00:30 am', '', '9', '09:05:'];

for (const t of cases) {
  console.log(JSON.stringify(t).padEnd(12), '24h:', String(TIME_24.test(t)).padEnd(6),
    '12h:', String(TIME_12.test(t)).padEnd(6), 'either:', isTime(t));
}
console.log('parsed 23:59:59 ->', parseTime('23:59:59'));
console.log('parsed 09:05    ->', parseTime('09:05'), '<- seconds default to 0');

Output


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

20 checks

test('midnight', TIME_24.test('00:00'), true);
test('a padded morning time', TIME_24.test('09:05'), true);
test('one minute to midnight', TIME_24.test('23:59'), true);
test('with seconds', TIME_24.test('23:59:59'), true);
test('24:00 is not a valid hour', TIME_24.test('24:00'), false);
test('nor is minute 60', TIME_24.test('12:60'), false);
test('an unpadded hour is refused by the 24-hour pattern', TIME_24.test('9:05'), false);
test('a trailing colon is refused', TIME_24.test('09:05:'), false);
test('an empty string is refused', isTime(''), false);
test('a bare number is refused', isTime('9'), false);
test('a 12-hour time with pm passes', TIME_12.test('1:05 pm'), true);
test('and is case-insensitive', TIME_12.test('12:00 AM'), true);
test('no space is fine too', TIME_12.test('1:05pm'), true);
test('13 is not a 12-hour hour', TIME_12.test('13:00 pm'), false);
test('nor is 00', TIME_12.test('00:30 am'), false);
test('a 12-hour time without am or pm is refused', TIME_12.test('1:05'), false);
test('either form is accepted by isTime', ['23:59', '1:05 pm'].every(isTime), true);
test('the parts are extracted as numbers', parseTime('23:59:59'), { hour: 23, minute: 59, second: 59 });
test('missing seconds default to zero', parseTime('09:05'), { hour: 9, minute: 5, second: 0 });
test('and an invalid time parses to null', parseTime('24:00'), null);

The hour is the interesting part: ([01]d|2[0-3]) rather than d{2}, because the range 00–23 does not fit a single character class. That is the same technique as the IP octet — a numeric range in a regex has to be spelled out by shape.

The 12-hour pattern makes a different mistake possible. Its hours run 1–12, so 00:30 am is invalid, and midnight is written 12:00 AM. Both are asserted, because a pattern reused from the 24-hour case would quietly accept the wrong set.

exec with named groups gives you the parts as well as a yes or no, which is usually what you actually wanted. If you are heading towards arithmetic on times, though, stop here: validate the shape, then convert to minutes-since-midnight or a real date, and do the comparisons there.

Answers JS RegExp 11 in the original set.

12 Validate a US zip code

easy

Check a US zip code: five digits, optionally followed by a dash and four more.

Stuck? Three hints, smallest first
  1. 1. /^\d{5}(-\d{4})?$/ is the whole pattern.
  2. 2. A zip code is not a number. 07030 loses its leading zero if you store it as one.
  3. 3. The ? makes the whole group optional, so both forms match.

The answer

const ZIP = /^\d{5}(-\d{4})?$/;
const isZip = (value) => typeof value === 'string' && ZIP.test(value);

// The ZIP+4 parts, when they are there.
const ZIP_PARTS = /^(?<base>\d{5})(?:-(?<plus4>\d{4}))?$/;
const zipParts = (value) => ZIP_PARTS.exec(value)?.groups ?? null;

const cases = ['12345', '07030', '12345-6789', '1234', '123456', '12345-678',
  '12345 6789', 'ABCDE', '', '12345-'];

for (const z of cases) console.log(JSON.stringify(z).padEnd(14), isZip(z));
console.log('--- why a zip code is text, not a number ---');
console.log("Number('07030')     =", Number('07030'), '<- the leading zero is gone');
console.log("String(Number(...)) =", String(Number('07030')), 'which is not a zip code:', isZip(String(Number('07030'))));
console.log('parts of 12345-6789:', zipParts('12345-6789'));
console.log('parts of 12345     :', zipParts('12345'));

Output


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

17 checks

test('five digits pass', isZip('12345'), true);
test('a leading zero is fine, because it is text', isZip('07030'), true);
test('the ZIP+4 form passes', isZip('12345-6789'), true);
test('four digits are too few', isZip('1234'), false);
test('six are too many', isZip('123456'), false);
test('a short extension is refused', isZip('12345-678'), false);
test('a space instead of a dash is refused', isZip('12345 6789'), false);
test('a trailing dash is refused', isZip('12345-'), false);
test('letters are refused', isZip('ABCDE'), false);
test('an empty string is refused', isZip(''), false);
test('a number is refused, since the type is wrong', isZip(12345), false);
test('storing it as a number destroys a leading zero', Number('07030'), 7030);
test('and the result is no longer a zip code', isZip(String(Number('07030'))), false);
test('the base is captured', zipParts('12345-6789').base, '12345');
test('and the extension', zipParts('12345-6789').plus4, '6789');
test('the extension is undefined when absent', zipParts('12345').plus4, undefined);
test('an invalid code parses to null', zipParts('abc'), null);

The pattern is trivial; the lesson is the type. A zip code is an identifier, not a quantity, and the assertions show Number('07030') becoming 7030 — no longer a valid code. The same applies to phone numbers, account numbers and Nigerian NIN or BVN digits. If you never do arithmetic on it, keep it as text, in the database as well as in the code.

Worth saying what this does not check: 00000 and 99999 both match and neither is a real zip code. Confirming an address exists needs a postal address dataset, not a regex.

Answers JS RegExp 12 in the original set.

13 Validate a UK postcode

hard

Check a UK postcode. The format is more varied than most: one or two letters, then digits, then optionally a letter, then a space, then a digit and two letters.

Stuck? Three hints, smallest first
  1. 1. The outward code is 2 to 4 characters; the inward code is always a digit and two letters.
  2. 2. Real examples that must all pass: M1 1AE, B33 8TH, CR2 6XH, DN55 1PT, EC1A 1BB, W1A 0AX.
  3. 3. The space is optional in stored data, so allow it to be missing and normalise afterwards.

The answer

/* Outward: 1-2 letters, a digit, then optionally another digit or a letter.
   Inward:  always a digit and two letters.
   The space is optional, because stored data often has it stripped. */
const UK_POSTCODE = /^[A-Z]{1,2}\d[A-Z\d]?\s?\d[A-Z]{2}$/i;

const isUkPostcode = (value) => typeof value === 'string' && UK_POSTCODE.test(value.trim());

// Postcodes are conventionally stored and shown upper case with one space
// before the final three characters.
function normaliseUkPostcode(value) {
  if (!isUkPostcode(value)) return null;
  const bare = value.trim().toUpperCase().replace(/\s+/g, '');
  return `${bare.slice(0, -3)} ${bare.slice(-3)}`;
}

// The six documented format variants, all of which must pass.
const real = ['M1 1AE', 'B33 8TH', 'CR2 6XH', 'DN55 1PT', 'EC1A 1BB', 'W1A 0AX'];
const bad = ['12345', 'XYZ', 'M1', '1AE', 'MMM1 1AE', 'M1 1A', 'M1 1AEE', ''];

for (const p of real) console.log('real  ', p.padEnd(10), isUkPostcode(p), '-> normalised', JSON.stringify(normaliseUkPostcode(p)));
for (const p of bad) console.log('bad   ', JSON.stringify(p).padEnd(10), isUkPostcode(p));
console.log('no space  :', isUkPostcode('SW1A2AA'), '-> normalised', JSON.stringify(normaliseUkPostcode('SW1A2AA')));
console.log('lower case:', isUkPostcode('m1 1ae'), '-> normalised', JSON.stringify(normaliseUkPostcode('m1 1ae')));

Output


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

22 checks

test('all six documented variants pass', real.every(isUkPostcode), true);
test('a one-letter area with one digit', isUkPostcode('M1 1AE'), true);
test('a two-letter area with two digits', isUkPostcode('DN55 1PT'), true);
test('a district with a trailing letter', isUkPostcode('EC1A 1BB'), true);
test('the London W1A form', isUkPostcode('W1A 0AX'), true);
test('the space may be missing', isUkPostcode('SW1A2AA'), true);
test('lower case is accepted', isUkPostcode('m1 1ae'), true);
test('surrounding spaces are tolerated', isUkPostcode('  M1 1AE  '), true);
test('digits alone are refused', isUkPostcode('12345'), false);
test('letters alone are refused', isUkPostcode('XYZ'), false);
test('the outward code alone is refused', isUkPostcode('M1'), false);
test('the inward code alone is refused', isUkPostcode('1AE'), false);
test('three leading letters are refused', isUkPostcode('MMM1 1AE'), false);
test('a short inward code is refused', isUkPostcode('M1 1A'), false);
test('a long inward code is refused', isUkPostcode('M1 1AEE'), false);
test('an empty string is refused', isUkPostcode(''), false);
test('normalising upper-cases and spaces it', normaliseUkPostcode('m11ae'), 'M1 1AE');
test('an already-tidy code is unchanged', normaliseUkPostcode('EC1A 1BB'), 'EC1A 1BB');
test('every normalised code has exactly one space', real.every((p) => normaliseUkPostcode(p).split(' ').length === 2), true);
test('and three characters after it', real.every((p) => normaliseUkPostcode(p).split(' ')[1].length === 3), true);
test('an invalid code normalises to null', normaliseUkPostcode('XYZ'), null);
test('but GIR 0AA, a real special case, is refused by this pattern', isUkPostcode('GIR 0AA'), false);

The inward code is the stable half: always a digit and two letters. All the variation is in the outward code, which is why the pattern is [A-Z]{1,2}d[A-Zd]? — one or two letters, a digit, then optionally either another digit or a letter. The six examples in the assertions are the documented format variants, and a pattern that fails any of them is wrong.

Making the space optional and normalising afterwards is the practical choice: stored data has often had it stripped, and users type it inconsistently. Normalise on the way in so comparisons work.

The last assertion is deliberate. GIR 0AA is a genuine postcode (Girobank, Bootle) and this pattern rejects it, because it does not fit the general rule. That is the honest state of postcode validation — the official rules include named exceptions, and a pattern that handles every real case is considerably longer than this one. For anything that matters, use a postcode lookup service; a regex catches typos and nothing more.

Answers JS RegExp 13 in the original set.

14 Validate a Canadian postal code

fair

Check a Canadian postal code: letter, digit, letter, space, digit, letter, digit. Some letters are never used.

Stuck? Three hints, smallest first
  1. 1. The alternating pattern is A1A 1A1.
  2. 2. D, F, I, O, Q and U are never used, because they look like digits or other letters.
  3. 3. W and Z are also excluded from the first position only.

The answer

/* Excluded letters:
     everywhere      D F I O Q U   (too easily confused with 0, 1 and each other)
     first position  also W and Z
   The character classes below encode exactly that. */
const CA_POSTCODE = /^[ABCEGHJ-NPRSTVXY]\d[ABCEGHJ-NPRSTV-Z][ -]?\d[ABCEGHJ-NPRSTV-Z]\d$/i;

const isCaPostal = (value) => typeof value === 'string' && CA_POSTCODE.test(value.trim());

const normalise = (value) => {
  if (!isCaPostal(value)) return null;
  const bare = value.trim().toUpperCase().replace(/[\s-]/g, '');
  return `${bare.slice(0, 3)} ${bare.slice(3)}`;
};

const real = ['K1A 0B1', 'M5V 3L9', 'H0H 0H0', 'V6B 4Y8', 'T2P 2M5'];
const bad = ['D1A 0B1', 'F1A 0B1', 'I1A 0B1', 'O1A 0B1', 'Q1A 0B1', 'U1A 0B1',
  'W1A 0B1', 'Z1A 0B1', '1A1 A1A', 'K1A 0B', 'K1A0B12', '', '12345'];

for (const p of real) console.log('real  ', p.padEnd(10), isCaPostal(p), '->', JSON.stringify(normalise(p)));
for (const p of bad) console.log('bad   ', JSON.stringify(p).padEnd(10), isCaPostal(p));
console.log('no space:', isCaPostal('K1A0B1'), '-> normalised', JSON.stringify(normalise('K1A0B1')));
console.log('a dash  :', isCaPostal('K1A-0B1'), '-> normalised', JSON.stringify(normalise('K1A-0B1')));
console.log("H0H 0H0 is Santa Claus's real postal code, and it validates.");

Output


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

23 checks

test('every real example passes', real.every(isCaPostal), true);
test('the classic K1A 0B1', isCaPostal('K1A 0B1'), true);
test('a Toronto code', isCaPostal('M5V 3L9'), true);
test('H0H 0H0 is genuinely valid', isCaPostal('H0H 0H0'), true);
test('the space may be missing', isCaPostal('K1A0B1'), true);
test('a dash is tolerated', isCaPostal('K1A-0B1'), true);
test('lower case is accepted', isCaPostal('k1a 0b1'), true);
test('D is never used', isCaPostal('D1A 0B1'), false);
test('nor F', isCaPostal('F1A 0B1'), false);
test('nor I', isCaPostal('I1A 0B1'), false);
test('nor O', isCaPostal('O1A 0B1'), false);
test('nor Q', isCaPostal('Q1A 0B1'), false);
test('nor U', isCaPostal('U1A 0B1'), false);
test('W is excluded from the first position', isCaPostal('W1A 0B1'), false);
test('but allowed in the third', isCaPostal('K1W 0B1'), true);
test('Z is excluded from the first position', isCaPostal('Z1A 0B1'), false);
test('and allowed in the third', isCaPostal('K1Z 0B1'), true);
test('the digit and letter order matters', isCaPostal('1A1 A1A'), false);
test('too short is refused', isCaPostal('K1A 0B'), false);
test('too long is refused', isCaPostal('K1A0B12'), false);
test('an empty string is refused', isCaPostal(''), false);
test('normalising gives one space in the middle', normalise('k1a0b1'), 'K1A 0B1');
test('and an invalid code gives null', normalise('D1A 0B1'), null);

The excluded letters are the whole point, and they are excluded for a good reason: D, F, I, O, Q and U are too easily confused with 0, 1 and each other when handwritten or read by a scanner. W and Z are additionally excluded from the first position only, which is why the two character classes in the pattern differ — [ABCEGHJ-NPRSTVXY] for the first, [ABCEGHJ-NPRSTV-Z] for the others. The assertions check K1W 0B1 passes while W1A 0B1 does not, so that distinction cannot silently collapse.

The ranges do the compressing: J-N is J, K, L, M, N, and V-Z is V, W, X, Y, Z. Writing them out letter by letter would be clearer to read but much longer, which is the usual regex trade.

Answers JS RegExp 14 in the original set.

15 Validate a social security number

fair

Check a US social security number. It is nine digits in three groups, and several ranges are never issued.

Stuck? Three hints, smallest first
  1. 1. The shape is 123-45-6789: three digits, two, then four.
  2. 2. Never issued: area 000, 666, or anything from 900 up; group 00; serial 0000.
  3. 3. A negative lookahead, (?!000|666|9\d\d), excludes those without listing every valid value.

The answer

/* (?!...) is a negative lookahead: "the next characters must NOT match this",
   and it consumes nothing. It is how you exclude a few cases from a range
   instead of enumerating the thousands that remain. */
const SSN = /^(?!000|666|9\d\d)\d{3}-(?!00)\d{2}-(?!0000)\d{4}$/;

const isSsn = (value) => typeof value === 'string' && SSN.test(value);

// Also accepting the unpunctuated form, then normalising.
const SSN_LOOSE = /^(?!000|666|9\d\d)\d{3}-?(?!00)\d{2}-?(?!0000)\d{4}$/;
function normaliseSsn(value) {
  if (typeof value !== 'string' || !SSN_LOOSE.test(value)) return null;
  const d = value.replace(/-/g, '');
  return `${d.slice(0, 3)}-${d.slice(3, 5)}-${d.slice(5)}`;
}

// Masking, which is what you should almost always show instead of the number.
const mask = (ssn) => (isSsn(ssn) ? `***-**-${ssn.slice(-4)}` : null);

const cases = ['123-45-6789', '001-01-0001', '000-45-6789', '666-45-6789',
  '900-45-6789', '999-45-6789', '123-00-6789', '123-45-0000', '123456789',
  '12-345-6789', '', '123-45-678'];

for (const s of cases) console.log(JSON.stringify(s).padEnd(14), isSsn(s));
console.log('unpunctuated normalises:', normaliseSsn('123456789'));
console.log('masked for display     :', mask('123-45-6789'));

Output


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

20 checks

test('a valid number passes', isSsn('123-45-6789'), true);
test('the lowest valid combination', isSsn('001-01-0001'), true);
test('area 000 is never issued', isSsn('000-45-6789'), false);
test('area 666 is never issued', isSsn('666-45-6789'), false);
test('area 900 and above is never issued', isSsn('900-45-6789'), false);
test('including 999', isSsn('999-45-6789'), false);
test('but 899 is fine', isSsn('899-45-6789'), true);
test('group 00 is never issued', isSsn('123-00-6789'), false);
test('but 01 is', isSsn('123-01-6789'), true);
test('serial 0000 is never issued', isSsn('123-45-0000'), false);
test('but 0001 is', isSsn('123-45-0001'), true);
test('the dashes are required by the strict pattern', isSsn('123456789'), false);
test('groups of the wrong length are refused', isSsn('12-345-6789'), false);
test('too few digits are refused', isSsn('123-45-678'), false);
test('an empty string is refused', isSsn(''), false);
test('the loose pattern accepts no dashes', SSN_LOOSE.test('123456789'), true);
test('and normalises it', normaliseSsn('123456789'), '123-45-6789');
test('while still refusing an excluded area', normaliseSsn('666456789'), null);
test('masking shows only the last four', mask('123-45-6789'), '***-**-6789');
test('and refuses an invalid number', mask('000-45-6789'), null);

The negative lookahead (?!000|666|9dd) is the technique worth learning here. It asserts what must not come next and consumes nothing, so d{3} still does the matching. Excluding three cases this way is far shorter than enumerating the 897 that remain, and the assertions check both sides of each boundary — 899 passes while 900 does not.

Two things to be honest about. First, these exclusion rules are the published never-issued ranges; a number that passes is still almost certainly not a real, issued SSN, and the only way to know is a verification service. Second, and more important: this is exactly the kind of data you should think twice about handling at all. If you must, store it encrypted, never log it, and display it masked — the mask function above is more likely to be the useful part of this exercise than the pattern.

Answers JS RegExp 15 in the original set.

16 Is the value hexadecimal?

easy

Check whether a string is a hexadecimal number, allowing an optional 0x prefix.

Stuck? Three hints, smallest first
  1. 1. Hex digits are [0-9a-f], with the i flag for upper case.
  2. 2. Make the prefix optional: (0[xX])?.
  3. 3. parseInt(s, 16) parses a prefix and stops, so it is not a validity test.

The answer

const HEX = /^(0[xX])?[0-9a-fA-F]+$/;
const isHex = (value) => typeof value === 'string' && HEX.test(value);

// Converting, once you know it is valid.
const hexToNumber = (value) => (isHex(value) ? Number.parseInt(value.replace(/^0[xX]/, ''), 16) : null);

const cases = ['1A', '0x1A', '0X1a', 'ff', 'FF', 'deadbeef', '0', '123',
  'g1', '0x', '1A ', '', '-1A', '0x1G'];

for (const h of cases) {
  console.log(JSON.stringify(h).padEnd(12), 'hex:', String(isHex(h)).padEnd(6), 'value:', hexToNumber(h));
}
console.log('--- why parseInt is not a validity test ---');
console.log("parseInt('1G', 16)   =", Number.parseInt('1G', 16), '<- stopped at the G and returned 1');
console.log("parseInt('zz', 16)   =", Number.parseInt('zz', 16), '<- NaN, at least');
console.log("Number('0x1A')       =", Number('0x1A'), '<- understands the prefix');
console.log("Number('1A')         =", Number('1A'), '<- NaN without it');

Output


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

23 checks

test('two hex digits pass', isHex('1A'), true);
test('with the 0x prefix', isHex('0x1A'), true);
test('an upper-case prefix too', isHex('0X1a'), true);
test('lower-case digits', isHex('ff'), true);
test('upper-case digits', isHex('FF'), true);
test('a longer value', isHex('deadbeef'), true);
test('a single zero', isHex('0'), true);
test('decimal digits are valid hex', isHex('123'), true);
test('g is not a hex digit', isHex('g1'), false);
test('the prefix alone is not a number', isHex('0x'), false);
test('a trailing space is refused', isHex('1A '), false);
test('an empty string is refused', isHex(''), false);
test('a minus sign is refused', isHex('-1A'), false);
test('one bad digit spoils it', isHex('0x1G'), false);
test('converting gives the right number', hexToNumber('1A'), 26);
test('the prefix does not change the value', hexToNumber('0x1A'), 26);
test('ff is 255', hexToNumber('ff'), 255);
test('deadbeef is a real number', hexToNumber('deadbeef'), 3735928559);
test('an invalid value converts to null', hexToNumber('0x1G'), null);
test('whereas parseInt would return a partial answer', Number.parseInt('1G', 16), 1);
test('which is why the check comes first', isHex('1G'), false);
test('Number understands the 0x prefix', Number('0x1A'), 26);
test('but not a bare hex string', Number.isNaN(Number('1A')), true);

The pattern is easy. The point is the last few assertions: parseInt('1G', 16) returns 1. It reads valid digits from the front, stops at the first one it does not recognise, and reports what it managed — no error, no NaN, just a wrong answer that looks like a right one. This is the single most common source of parseInt bugs, and it applies in every base.

So validate first, then convert. And note the asymmetry in Number: Number('0x1A') is 26 because it understands the prefix, while Number('1A') is NaN. If you want to read hex without a prefix, parseInt(s, 16) is the right call — after you have checked the string.

Answers JS RegExp 16 in the original set.

17 Is the value a hex colour?

fair

Check whether a string is a hex colour. CSS allows three, four, six and eight digits, not only six.

Stuck? Three hints, smallest first
  1. 1. Three digits is shorthand: #f00 means #ff0000.
  2. 2. Four and eight digits include an alpha channel, and both are valid modern CSS.
  3. 3. Use alternation on the length rather than {3,8}, which would allow 5 and 7.

The answer

// 3, 4, 6 or 8 digits. Alternation on the lengths, because {3,8} would also
// allow 5 and 7, which are not valid.
const HEX_COLOUR = /^#([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
const isHexColour = (value) => typeof value === 'string' && HEX_COLOUR.test(value);

// Expanding the shorthand, which is what a browser does internally.
function expand(colour) {
  if (!isHexColour(colour)) return null;
  const d = colour.slice(1);
  if (d.length === 3 || d.length === 4) {
    return '#' + [...d].map((c) => c + c).join('').toLowerCase();
  }
  return '#' + d.toLowerCase();
}

// The channels as numbers, alpha included.
function channels(colour) {
  const full = expand(colour);
  if (!full) return null;
  const d = full.slice(1);
  const pairs = d.match(/../g).map((p) => Number.parseInt(p, 16));
  return { r: pairs[0], g: pairs[1], b: pairs[2], a: pairs[3] ?? 255 };
}

const cases = ['#fff', '#FFF', '#f00', '#ffff', '#3055d8', '#3055D8', '#3055d8ff',
  '3055d8', '#12345', '#1234567', '#gggggg', '#', '', '#3055d8 '];

for (const c of cases) {
  console.log(JSON.stringify(c).padEnd(12), 'valid:', String(isHexColour(c)).padEnd(6), 'expanded:', expand(c));
}
console.log('channels of #f00 :', channels('#f00'));
console.log('channels of #3055d880:', channels('#3055d880'));

Output


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

23 checks

test('three digits pass', isHexColour('#fff'), true);
test('upper case passes', isHexColour('#FFF'), true);
test('four digits pass, with alpha', isHexColour('#ffff'), true);
test('six digits pass', isHexColour('#3055d8'), true);
test('eight digits pass', isHexColour('#3055d8ff'), true);
test('five digits are not valid', isHexColour('#12345'), false);
test('nor are seven', isHexColour('#1234567'), false);
test('the hash is required', isHexColour('3055d8'), false);
test('non-hex letters are refused', isHexColour('#gggggg'), false);
test('the hash alone is refused', isHexColour('#'), false);
test('an empty string is refused', isHexColour(''), false);
test('a trailing space is refused', isHexColour('#3055d8 '), false);
test('three-digit shorthand expands by doubling', expand('#f00'), '#ff0000');
test('and #fff becomes white', expand('#fff'), '#ffffff');
test('four-digit shorthand expands too', expand('#ffff'), '#ffffffff');
test('a six-digit colour is only lower-cased', expand('#3055D8'), '#3055d8');
test('an invalid colour expands to null', expand('#12345'), null);
test('the channels of red', channels('#f00'), { r: 255, g: 0, b: 0, a: 255 });
test('alpha defaults to fully opaque', channels('#3055d8').a, 255);
test('and is read when present', channels('#3055d880').a, 128);
test('shorthand and long form give the same channels', channels('#f00'), channels('#ff0000'));
test('white is all 255', channels('#fff'), { r: 255, g: 255, b: 255, a: 255 });
test('black is all 0', channels('#000'), { r: 0, g: 0, b: 0, a: 255 });

Alternating on the exact lengths, {3}|{4}|{6}|{8}, rather than writing {3,8}, is what makes this correct: five and seven digits are not valid CSS, and the assertions check both are refused. It is a small thing that a range quantifier gets silently wrong.

Four and eight digit forms are worth knowing about. #3055d880 is the same blue at 50% opacity, and the shorthand expands by doubling each digit — which is why #f00 and #ff0000 produce identical channels, as the assertion confirms.

If you are parsing colours in earnest rather than validating a field, note that CSS also has rgb(), hsl(), oklch(), named colours and color-mix(). A hex pattern covers one notation out of many.

Answers JS RegExp 17 in the original set.

18 Does the value look like a domain?

fair

Check whether a string is a domain name. Labels are limited in length and in what characters they may start and end with.

Stuck? Three hints, smallest first
  1. 1. Each label is 1 to 63 characters, letters digits and dashes, and cannot start or end with a dash.
  2. 2. The whole name is at most 253 characters, and needs at least two labels.
  3. 3. The trailing label is the TLD and is letters only, at least two of them.

The answer

/* One label: starts and ends with a letter or digit, up to 61 of anything
   allowed in between, so 1 to 63 characters in total. */
const LABEL = '[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?';
const TLD = '[A-Za-z]{2,}';
const DOMAIN = new RegExp(`^(?:${LABEL}\\.)+${TLD}$`);

const isDomain = (value) =>
  typeof value === 'string' &&
  value.length <= 253 &&
  DOMAIN.test(value);

const cases = [
  'example.com', 'sub.example.com', 'a.co', 'my-site.example.co.uk',
  'xn--80ak6aa92e.com',           // punycode, a real internationalised domain
  'example', '-example.com', 'example-.com', 'exa mple.com',
  'example..com', '.example.com', 'example.c', 'example.com.', '',
  `${'a'.repeat(64)}.com`,        // a label over 63 characters
  `${'a'.repeat(60)}.com`,
];

for (const d of cases) {
  console.log(JSON.stringify(d).slice(0, 30).padEnd(32), isDomain(d));
}
console.log('a 253-character name:', isDomain(('a'.repeat(49) + '.').repeat(5) + 'com'));

Output


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

21 checks

test('a two-label domain passes', isDomain('example.com'), true);
test('a subdomain passes', isDomain('sub.example.com'), true);
test('the shortest sensible form', isDomain('a.co'), true);
test('dashes inside a label are fine', isDomain('my-site.example.co.uk'), true);
test('a punycode domain passes', isDomain('xn--80ak6aa92e.com'), true);
test('digits in a label are fine', isDomain('9ja.example.com'), true);
test('a single label is not a domain', isDomain('example'), false);
test('a leading dash is refused', isDomain('-example.com'), false);
test('a trailing dash is refused', isDomain('example-.com'), false);
test('a space is refused', isDomain('exa mple.com'), false);
test('an empty label is refused', isDomain('example..com'), false);
test('a leading dot is refused', isDomain('.example.com'), false);
test('a one-letter TLD is refused', isDomain('example.c'), false);
test('a trailing dot is refused by this pattern', isDomain('example.com.'), false);
test('an empty string is refused', isDomain(''), false);
test('a 64-character label is too long', isDomain('a'.repeat(64) + '.com'), false);
test('60 characters is fine', isDomain('a'.repeat(60) + '.com'), true);
test('and exactly 63 is the limit', isDomain('a'.repeat(63) + '.com'), true);
test('a name over 253 characters is refused', isDomain(('a'.repeat(60) + '.').repeat(5) + 'com'), false);
test('a numeric TLD is refused', isDomain('example.123'), false);
test('but an IP address is not a domain either', isDomain('192.168.1.1'), false);

The label pattern [A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])? encodes three rules at once: a label starts with a letter or digit, ends with one, and is at most 63 characters. Counting them: 1 + 61 + 1. The optional group is what allows a single-character label. The assertions check 63 passes and 64 fails, because the arithmetic is the sort of thing that is off by one when written from memory.

The 253-character total cannot be expressed in the pattern usefully, so it is a separate length check — a reminder that "validate with a regex" often means "regex plus a couple of ordinary conditions".

Two honest limits. A trailing dot, as in example.com., is technically valid and denotes the root; this pattern rejects it, which is usually what you want in a form. And internationalised domains reach this code already converted to punycode (xn--…), so a name in Arabic or Chinese script will not match unless you convert it first.

Answers JS RegExp 18 in the original set.

19 Does the value contain HTML?

hard

Detect whether a string contains HTML. Then face the fact that this is the wrong tool for the job people usually want it for.

Stuck? Three hints, smallest first
  1. 1. A tag is roughly <, a name, anything up to >.
  2. 2. HTML is not a regular language, so no pattern can parse it correctly.
  3. 3. To make text safe, escape it or use textContent. Never try to strip tags with a regex.

The answer

// Detection: does this look like it contains a tag or an entity?
const HTML_TAG = /<\/?[a-z][\w-]*(\s[^<>]*)?\/?>/i;
const HTML_ENTITY = /&(#\d+|#x[0-9a-f]+|[a-z][a-z0-9]*);/i;

const containsHtml = (value) =>
  typeof value === 'string' && (HTML_TAG.test(value) || HTML_ENTITY.test(value));

// The RIGHT way to make text safe: escape the five characters that matter.
const escapeHtml = (text) =>
  String(text)
    .replace(/&/g, '&amp;')          // & first, or the others get double-escaped
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;');

// The WRONG way, shown so the failure is concrete rather than a warning.
const stripTags = (html) => html.replace(/<[^>]*>/g, '');

const cases = [
  '<p>hello</p>', 'plain text', '<br>', '<img src="x" alt="y">',
  'a &lt; b', 'a &amp; b', '5 < 6 and 7 > 3', '<3', 'a<b>c',
];

for (const c of cases) console.log(JSON.stringify(c).padEnd(28), 'contains HTML:', containsHtml(c));

console.log('--- escaping works ---');
console.log('input :', '<script>alert(1)</script>');
console.log('escaped:', escapeHtml('<script>alert(1)</script>'));

console.log('--- stripping does not ---');
console.log('the easy case looks fine:');
console.log('  <img src=x onerror=alert(1)>  ->', JSON.stringify(stripTags('<img src=x onerror=alert(1)>')));
console.log('now leave off the closing bracket:');
console.log('  <img src=x onerror=alert(1)   ->', JSON.stringify(stripTags('<img src=x onerror=alert(1)')));
console.log('  <- nothing was removed. A browser still parses that as an img tag.');
console.log('and put a > inside an attribute value:');
console.log('  <img src="a>b" onerror="alert(1)">  ->', JSON.stringify(stripTags('<img src="a>b" onerror="alert(1)">')));
console.log('  <- the match stopped at the first >, leaving the onerror behind.');

Output


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

24 checks

test('a paragraph is detected', containsHtml('<p>hello</p>'), true);
test('a void tag is detected', containsHtml('<br>'), true);
test('a tag with attributes is detected', containsHtml('<img src="x" alt="y">'), true);
test('a closing tag is detected', containsHtml('</div>'), true);
test('plain text is not', containsHtml('plain text'), false);
test('an entity counts as HTML', containsHtml('a &lt; b'), true);
test('and a named entity', containsHtml('a &amp; b'), true);
test('a bare less-than is not a tag', containsHtml('5 < 6 and 7 > 3'), false);
test('nor is an emoticon', containsHtml('<3'), false);
test('escaping neutralises a script tag', escapeHtml('<script>alert(1)</script>'), '&lt;script&gt;alert(1)&lt;/script&gt;');
test('the ampersand is escaped first, so nothing is double-escaped', escapeHtml('&lt;'), '&amp;lt;');
test('quotes are escaped, which matters inside an attribute', escapeHtml('a"b\'c'), 'a&quot;b&#39;c');
test('escaped text no longer looks like HTML to the tag pattern', HTML_TAG.test(escapeHtml('<p>x</p>')), false);
test('escaping is reversible in meaning, not destructive', escapeHtml('5 < 6'), '5 &lt; 6');
test('stripping tags removes the obvious case', stripTags('<p>hello</p>'), 'hello');
test('and it looks like it worked on an attack', stripTags('<img src=x onerror=alert(1)>'), '');
test('but an UNTERMINATED tag is not touched at all', stripTags('<img src=x onerror=alert(1)'), '<img src=x onerror=alert(1)');
test('so the whole payload survives, onerror included', stripTags('<img src=x onerror=alert(1)').includes('onerror'), true);
test('and the detector misses it too, since it needs a closing bracket', containsHtml('<img src=x onerror=alert(1)'), false);
test('a > inside an attribute value truncates the match', stripTags('<img src="a>b" onerror="alert(1)">'), 'b" onerror="alert(1)">');
test('leaving the dangerous attribute behind as text', stripTags('<img src="a>b" onerror="alert(1)">').includes('onerror'), true);
test('so stripping is not a security measure', stripTags('<img src=x onerror=alert(1)') === '', false);
test('while escaping handles both cases', escapeHtml('<img src=x onerror=alert(1)'), '&lt;img src=x onerror=alert(1)');
test('and the attribute one', escapeHtml('<img src="a>b">').includes('<'), false);

Detecting HTML is a reasonable thing to want — flagging that a comment field contains markup, say — and the pattern above does it well enough.

The assertions after that are the real content. stripTags looks like it works: <p>hello</p> becomes hello, and even <img src=x onerror=alert(1)> disappears entirely. Two small changes defeat it, and both are asserted.

Leave off the closing bracket — <img src=x onerror=alert(1) — and /<[^>]*>/ matches nothing, so the whole payload passes through untouched. A browser still parses an unterminated tag, so the onerror runs. Note the detector misses it too, for the same reason. Then put a > inside an attribute value: the match stops at that first bracket, and what is left over is b" onerror="alert(1)"> — the dangerous attribute, still there.

These are not gaps in this particular pattern. HTML is not a regular language, so no regular expression can parse it, and every regex tag-stripper has defeats of this kind. It is a fact about the grammar.

So: to display untrusted text, escape it, or set element.textContent and let the browser handle it. To allow some markup, use a real sanitiser with an allow-list — DOMPurify, or the Sanitizer API where available. Never write your own.

Answers JS RegExp 19 in the original set.

20 Letters, dashes and underscores

easy

Check for a value made of letters, dashes and underscores — the shape of a slug or a handle.

Stuck? Three hints, smallest first
  1. 1. Inside a character class, a dash must be first or last, or escaped, or it means a range.
  2. 2. [a-z-_] works because the dash is at the end; [a-_z] is an error.
  3. 3. Decide whether it must contain all three, or only be made of them.

The answer

// Made of letters, dashes and underscores. The dash is LAST in the class, so it
// is a literal dash and not a range.
const ONLY = /^[a-z_-]+$/i;

// Contains at least one of each, which is a different question the title allows.
const hasAll = (value) => /[a-z]/i.test(value) && value.includes('-') && value.includes('_');

// A realistic slug rule: lower case, no leading or trailing separator, no doubles.
const SLUG = /^[a-z0-9]+(?:[-_][a-z0-9]+)*$/;

const cases = ['bus-route_1', 'lagos-abuja', 'lagos_abuja', 'plain', 'has space',
  'has.dot', 'UPPER-CASE', '-leading', 'trailing-', 'double--dash', '', '123'];

for (const v of cases) {
  console.log(JSON.stringify(v).padEnd(14),
    'only:', String(ONLY.test(v)).padEnd(6),
    'has all three:', String(hasAll(v)).padEnd(6),
    'valid slug:', SLUG.test(v));
}
console.log('--- the dash position matters ---');
console.log('[a-z-_] : dash at the end is literal ->', /^[a-z-_]+$/.test('a-b_c'));
console.log('[a-c] is a RANGE, so it matches b   ->', /^[a-c]+$/.test('b'));
console.log('[a\\-c] escaped is literal, so b fails ->', /^[a\-c]+$/.test('b'));

Output


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

21 checks

test('letters, a dash and an underscore pass', ONLY.test('bus-route_1'.replace(/\d/g, '')), true);
test('letters and a dash pass', ONLY.test('lagos-abuja'), true);
test('letters and an underscore pass', ONLY.test('lagos_abuja'), true);
test('letters alone pass', ONLY.test('plain'), true);
test('a space is refused', ONLY.test('has space'), false);
test('a dot is refused', ONLY.test('has.dot'), false);
test('a digit is refused by this class', ONLY.test('123'), false);
test('an empty string is refused', ONLY.test(''), false);
test('upper case passes, because of the i flag', ONLY.test('UPPER-CASE'), true);
test('the "has all three" question is different', hasAll('lagos-abuja'), false);
test('and only bus-route passes it', hasAll('bus-route_x'), true);
test('a dash at the end of a class is a literal dash', /^[a-z-]+$/.test('a-b'), true);
test('while a dash in the middle makes a range', /^[a-c]+$/.test('b'), true);
test('and an escaped dash is literal again', /^[a\-c]+$/.test('b'), false);
test('a good slug passes', SLUG.test('lagos-abuja'), true);
test('with an underscore too', SLUG.test('lagos_abuja_1'), true);
test('a leading separator is refused', SLUG.test('-leading'), false);
test('a trailing separator is refused', SLUG.test('trailing-'), false);
test('a doubled separator is refused', SLUG.test('double--dash'), false);
test('upper case is refused by the slug rule', SLUG.test('UPPER-CASE'), false);
test('digits are allowed in a slug', SLUG.test('route-9ja'), true);

The mechanical lesson is the dash. Inside a character class, - between two characters means a range: [a-c] matches b. To match a literal dash, put it first, put it last, or escape it — the three assertions comparing [a-z-], [a-c] and [a-c] make the difference concrete. Getting this wrong usually produces a class that matches far more than you intended, silently.

The other half is reading the question. "Contains alpha, a dash and an underscore" could mean made only of those, or containing at least one of each. They give different answers for lagos-abuja, so both are implemented. When a specification is ambiguous like this, pick one, write the assertion that pins it down, and the ambiguity stops being a problem.

SLUG is the version you would actually ship: the [a-z0-9]+(?:[-_][a-z0-9]+)* shape makes a leading, trailing or doubled separator unmatchable, which is the same trick as the email local part in exercise 3.

Answers JS RegExp 20 in the original set.

21 Thousands separators

fair

Print an integer with commas between thousands. The source's data: 1000 gives "1,000", 10000.23 gives "10,000.23", 100000 gives "100,000".

Stuck? Three hints, smallest first
  1. 1. Only the integer part gets separators. Split on the dot first.
  2. 2. \B(?=(\d{3})+(?!\d)) finds each position with a multiple of three digits after it.
  3. 3. Intl.NumberFormat does this properly, and you must pass a locale.

The answer

/* \B      not at a word boundary, so no comma is placed at the very start
   (?=...) a lookahead: the position is only matched, nothing is consumed
   (\d{3})+ one or more groups of exactly three digits
   (?!\d)  and no fourth digit after them
   Together: every position with a multiple of three digits to its right.     */
function thousandsSeparators(value) {
  const [whole, fraction] = String(value).split('.');
  const grouped = whole.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
  return fraction === undefined ? grouped : `${grouped}.${fraction}`;
}

// What to use in real code. The locale is NOT optional: without it the output
// depends on the machine, and the same code gives different answers elsewhere.
const formatted = (value) => new Intl.NumberFormat('en-NG').format(value);

const cases = [1000, 10000.23, 100000, 999, 1000000, 0, 12, -1234567, 1234.5];

for (const n of cases) {
  console.log(String(n).padEnd(12), 'regex:', thousandsSeparators(n).padEnd(14), 'Intl:', formatted(n));
}
console.log('--- the same value in three locales ---');
for (const locale of ['en-NG', 'de-DE', 'en-IN']) {
  console.log(locale.padEnd(6), new Intl.NumberFormat(locale).format(1234567.89));
}

Output


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

20 checks

test("the source's first case", thousandsSeparators(1000), '1,000');
test("the source's second case", thousandsSeparators(10000.23), '10,000.23');
test("the source's third case", thousandsSeparators(100000), '100,000');
test('three digits get no comma', thousandsSeparators(999), '999');
test('a million gets two', thousandsSeparators(1000000), '1,000,000');
test('zero is unchanged', thousandsSeparators(0), '0');
test('two digits are unchanged', thousandsSeparators(12), '12');
test('a negative number keeps its sign', thousandsSeparators(-1234567), '-1,234,567');
test('the fraction is never grouped', thousandsSeparators(1234.5), '1,234.5');
test('a long fraction is left alone', thousandsSeparators(1234.56789), '1,234.56789');
test('exactly four digits get one comma', thousandsSeparators(1234), '1,234');
test('the boundary at six digits', thousandsSeparators(123456), '123,456');
test('and at seven', thousandsSeparators(1234567), '1,234,567');
test('it agrees with Intl on integers', cases.filter(Number.isInteger).every((n) => thousandsSeparators(n) === formatted(n)), true);
test('Intl formats a million the same way', formatted(1000000), '1,000,000');
test('German uses dots for thousands', new Intl.NumberFormat('de-DE').format(1234567), '1.234.567');
test('and a comma for the decimal', new Intl.NumberFormat('de-DE').format(1234.5), '1.234,5');
test('Indian grouping is not in threes', new Intl.NumberFormat('en-IN').format(1234567), '12,34,567');
test('which the regex cannot do', thousandsSeparators(1234567) === new Intl.NumberFormat('en-IN').format(1234567), false);
test('naira formatting, with the currency', new Intl.NumberFormat('en-NG', { style: 'currency', currency: 'NGN' }).format(4500).replace(/\u00a0/g, ' '), '\u20a64,500.00');

B(?=(d{3})+(?!d)) is worth taking apart. (?=…) is a lookahead, so it matches a position and consumes nothing — that is what lets replace insert a comma without removing a digit. (d{3})+ requires a whole number of three-digit groups to the right, (?!d) stops there being a fourth digit, and B prevents a comma at the very start of the string. Splitting the fraction off first is necessary because those rules would otherwise group the digits after the dot too.

Then the honest conclusion: use Intl.NumberFormat. The assertions show why the regex cannot be right in general — German writes 1.234.567, and Indian grouping is 12,34,567, which is not in threes at all. And always pass a locale. Without one, Intl uses the machine's, so the same code produces different output on your laptop and on a server, and any test asserting the result becomes a coin flip.

Answers JS RegExp 21 in the original set.

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

Newsletter

New writing, when it lands.

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