Strings and text processing · Lesson 41 of 77 · practice

Regular expressions: a first pass

About 50 minutes.

By the end of this lesson you can

  • Read a simple regular expression out loud: which characters it matches and how many of them.
  • Write a pattern using character classes, quantifiers and anchors.
  • Choose between test, match, matchAll, replace and replaceAll for the job in front of you.
  • Say what the g and i flags change about a pattern's behaviour.
  • Avoid the two classic traps: greedy quantifiers, and a reused /g pattern with .test().
  • Recognise the jobs where a regular expression is the wrong tool.

A pattern is a description of text

Explanation

Everything so far has searched for text you could type out in full: a comma, an @, the word 2026. A regular expression searches for text you can only DESCRIBE — four digits in a row, a word starting with a capital letter, anything between two angle brackets.

You write one between slashes rather than quotes: `/\d+/` is a pattern, not a string. Letters and digits in a pattern match themselves, so `/cat/` finds the letters c, a, t. The power comes from the handful of symbols that mean something else.

Reading them aloud is the skill. `/\d{4}/` is 'four digits'. `/^cat/` is 'cat, at the very start'. `/[chm]at/` is 'c or h or m, then a t'. If you can say a pattern in English you can usually fix it; if you cannot, you are guessing, and guessing at regular expressions is how they earn their reputation.

The eight pieces that cover most jobs

Explanation

Character classes name a KIND of character. `\d` is any digit, `\w` is a letter, digit or underscore, `\s` is any whitespace. A dot means any character at all. Their capitals are the opposites: `\D` is any non-digit.

Square brackets make your own class: `[chm]` is any one of those three letters, `[a-z]` is any lower-case letter, and `[^0-9]` — with the caret INSIDE the brackets — is anything that is not a digit.

Quantifiers say how many. `+` is one or more, `*` is zero or more, `?` is zero or one (optional), and braces are exact: `{4}` is exactly four, `{2,4}` is between two and four, `{2,}` is two or more. A quantifier applies to the thing immediately before it, so `\d{4}` is four digits but `abc{2}` is ab followed by two c's.

Anchors pin the pattern to a place: `^` is the start of the text and `$` is the end. `/^\d+$/` means 'digits and nothing else', which is a completely different question from `/\d+/`, and confusing the two is a common source of sloppy validation.

Round brackets group — both for applying a quantifier to several characters at once and for capturing the part you want to keep. A vertical bar means or: `/(cat|dog)/`.

Finally, a backslash escapes a symbol that would otherwise mean something: `/\./` is a literal dot, not 'any character'.

Two flags go after the closing slash. `g` means find every match rather than stopping at the first; `i` means ignore capitalisation. `/cat/gi` is 'every cat, in any case'.

Five methods, five different kinds of answer

Explanation

`pattern.test(text)` answers true or false. Reach for it when that is genuinely all you need.

`text.match(pattern)` behaves in two quite different ways depending on the flag. WITHOUT `g` it returns an array-like result describing the first match: position 0 is the whole match, positions 1 and up are the round-bracket groups, and `.index` is where it was found. WITH `g` it returns a plain array of every matching string and no group information at all.

Either way, `match` returns `null` when nothing matched — not an empty array. `text.match(/x/g)[0]` on text without an x throws a TypeError, and that is one of the most common runtime errors in text-handling code. Check for null, or use `matchAll`.

`text.matchAll(pattern)` needs the `g` flag and gives you every match WITH its groups and positions, as something you spread into an array: `[...text.matchAll(/(\d+)/g)]`. When you want every match and its parts, this is the modern answer.

`text.replace(pattern, replacement)` returns a new string — strings are still immutable — replacing the first match, or every match with the `g` flag. `replaceAll` does every match and, unlike replace, also accepts a plain string as the thing to find. If the replacement is a function, it is called with each match and its return value is used, which is how you transform rather than merely substitute.

Quantifiers are greedy

A common mistake

`/<.+>/` looks like 'an angle bracket, some stuff, an angle bracket'. Run it on `<b>bold</b>` and it matches the WHOLE thing, not just `<b>`.

That is because `+` and `*` are greedy: they take as much as they possibly can and only give characters back if the rest of the pattern cannot otherwise match. The final `>` is happy to match the very last angle bracket in the text, so it does.

Adding a question mark makes a quantifier lazy — `+?` takes as little as it can. `/<.+?>/` matches just `<b>`, which is what you meant. Whenever a pattern is matching far more than you expected, greed is the first thing to check.

A /g pattern remembers where it stopped

A common mistake

This one is genuinely surprising. A regular expression with the `g` flag carries a `lastIndex` property, and `test` both reads it and updates it — so calling `test` repeatedly on the SAME regular expression object walks through the string, returning true, true, true, then false, then true again.

It happens whenever you store a /g pattern in a variable or at the top of a file and call `.test()` on it in a loop. The results depend on how many times you have called it before, which is exactly the kind of bug that cannot be reproduced by staring at the failing line.

The fix is simple: do not put the `g` flag on a pattern you are using with `test`, since test only ever wants a yes or no. If you need the same pattern for both jobs, create it fresh where you use it, or reset `lastIndex` to 0 yourself.

When a regular expression is the wrong tool

Why it matters

If `split`, `indexOf` or `includes` already answers the question, use them. `text.includes("@")` is clearer than a pattern and impossible to get subtly wrong.

Do not use a regular expression to parse HTML, JSON or CSV. Those formats nest and quote and escape, and a pattern cannot count — every 'nearly working' regular expression for them fails on the day a value contains the character you assumed it never would. Use a parser.

Do not try to validate an email address with a pattern. The real rules are far stranger than any pattern you would write, and the practical test of an address is whether mail sent to it arrives. A rough check for one @ with something either side, then send a confirmation, is the honest approach.

Where regular expressions genuinely shine: finding and replacing structured fragments in prose, splitting on a variable separator such as `/\s+/`, tidying whitespace, and pulling known-shaped values — dates, reference codes, numbers — out of otherwise unstructured text.

What to take away

Recap

Patterns go between slashes. Character classes say what kind, quantifiers say how many, anchors say where, brackets group and capture, and the g and i flags say every-match and ignore-case.

test gives a boolean, match gives the first match with its groups (or every matching string with /g, or null), matchAll gives everything with groups, replace and replaceAll give you a new string. Watch for greedy quantifiers, for null from match, and for a reused /g pattern in test.

Worked examples

Every output below was produced by actually running the code in the same sandbox your exercises run in — it is not a prediction.

Pulling values out of a log line

One realistic line of text and five questions asked of it with the same basic tools. Notice how the SAME pattern gives a different kind of answer depending on the method and the flag.

1const line = "Order 1042 shipped on 2026-07-31 for 3 items";2console.log(/\d+/.test(line));3console.log(line.match(/\d+/)[0]);4console.log(JSON.stringify(line.match(/\d+/g)));5const date = line.match(/(\d{4})-(\d{2})-(\d{2})/);6console.log(date[1], date[2], date[3]);7console.log(date.index);8console.log(line.match(/z+/g));
  • line 2

    'one or more digits' — is there any run of digits at all? Yes.

  • line 3

    Without the g flag, match describes the FIRST match; position 0 is the matched text itself.

  • line 4

    With the g flag, match returns every matching string as a plain array. Note that it found five runs, including the parts of the date.

  • line 5

    Three round-bracket groups. Positions 1, 2 and 3 of the result are the captured pieces, in the order the brackets open.

  • line 7

    .index is where in the string the match began.

  • line 8

    null, not an empty array. This is the value that causes TypeError when you index into it without checking.

What it prints

true1042["1042","2026","07","31","3"]2026 07 3122null

Replacing, with and without the g flag

Four replacements on the same short string. The last one is the interesting one: a function as the replacement, which lets you compute each replacement from what was matched rather than substituting a fixed string.

1console.log("hello world".replace(/o/, "0"));2console.log("hello world".replace(/o/g, "0"));3console.log("hello world".replaceAll("o", "0"));4console.log("a1b22c333".replace(/\d+/g, (found) => "[" + found.length + "]"));
  • line 1

    No g flag: only the FIRST o is replaced. This surprises people constantly.

  • line 2

    With g, every o goes.

  • line 3

    replaceAll accepts a plain string, so no pattern is needed at all for a simple every-occurrence swap.

  • line 4

    The function is called once per match with the matched text, and what it returns is used as the replacement — three runs of digits become their lengths.

What it prints

hell0 worldhell0 w0rldhell0 w0rlda[1]b[2]c[3]

Greedy and lazy, side by side

The same pattern with and without a question mark after the quantifier, on the same text. This one example explains most 'why did my regular expression match the whole line' questions.

1const markup = "<b>bold</b> and <i>it</i>";2console.log(markup.match(/<.+>/)[0]);3console.log(markup.match(/<.+?>/)[0]);4console.log(JSON.stringify(markup.match(/<.+?>/g)));
  • line 2

    Greedy: .+ takes everything it can, so the closing > matches the LAST angle bracket in the whole string.

  • line 3

    Lazy: .+? takes as little as possible, so the match stops at the first >.

  • line 4

    The lazy pattern with the g flag finds all four tags — which is what you wanted in the first place.

What it prints

<b>bold</b> and <i>it</i><b>["<b>","</b>","<i>","</i>"]

The same test, four times, three different answers

A /g pattern stored in a variable and tested four times against the same string. Nothing about the string changes. If this output does not look wrong to you, read the lastIndex section again before the exercise.

1const hasA = /a/g;2console.log(hasA.test("banana"), hasA.lastIndex);3console.log(hasA.test("banana"), hasA.lastIndex);4console.log(hasA.test("banana"), hasA.lastIndex);5console.log(hasA.test("banana"), hasA.lastIndex);6console.log(/a/.test("banana"), /a/.test("banana"));
  • line 2

    true, and lastIndex moves to 2 — the pattern has remembered where it stopped.

  • line 5

    false, on the same string as before: there is no fourth a after position 6. lastIndex then resets to 0, so the next call would return true again.

  • line 6

    Without the g flag there is no memory, so the answer is stable no matter how often you ask.

What it prints

true 2true 4true 6false 0true true

Check yourself

Not scored, not stored. Getting one wrong is the useful part.

Three patterns against the same sentence. Say each one aloud in English first — that is the habit this lesson is trying to build.

const text = "cat, hat, mat";console.log(JSON.stringify(text.match(/[chm]at/g)));console.log(text.replace(/at/g, "AT"));console.log(/^cat/.test(text), /^hat/.test(text));

What does the pattern /\d{2,4}/ match?

You want an array of every four-digit number in a piece of text, as plain strings. Which expression gives you that?

Write it yourself

Exercise · core · 7 tests

Redact long numbers from a message

Write `redactNumbers(text)` that returns a new string with every run of FOUR OR MORE digits replaced by the text `[redacted]`. Shorter runs are left exactly as they are. `redactNumbers("call 5551234 or 12")` gives `"call [redacted] or 12"` — the seven-digit number goes, the two-digit one stays. Every long run must be replaced, not just the first, and everything else in the text must survive untouched. This is a two-part decision: a pattern that describes four or more digits in a row, and a method that replaces every occurrence rather than the first.

What your code must do

Returns a new string in which every maximal run of four or more consecutive digits has been replaced by the literal text "[redacted]". Runs of one to three digits, and all non-digit text, are unchanged. Text with no digits comes back identical, and an empty string returns an empty string.

Define redactNumbers at the top level of your code — the tests call it by name.

Runs on your device in a sandbox with no network and no access to this page.

Exercise · stretch · 7 tests

Count how often each word appears

Write `wordFrequency(text)` that returns an object mapping each word to the number of times it appears. A word here means a run of letters and apostrophes: `a` to `z` and `'`. Everything else — spaces, full stops, commas, digits — separates words and is not part of them. Words are counted without regard to capitalisation, and the keys of the object are lower-case. `wordFrequency("The cat. The CAT sat!")` gives an object where `the` is 2, `cat` is 2 and `sat` is 1. Text with no words at all — including an empty string — gives an empty object, not null. Remember what `match` returns when it finds nothing.

What your code must do

Returns a plain object whose keys are the lower-cased words (runs of a-z and apostrophes) found in the text and whose values are the number of times each appeared. Text containing no words returns an empty object. Key insertion order is not part of the contract; the tests sort entries before comparing where order could vary.

Define wordFrequency at the top level of your code — the tests call it by name.

Runs on your device in a sandbox with no network and no access to this page.

Worth remembering

  • What does text.match(pattern) return when nothing matches?

    null, not an empty array. So text.match(/x/g)[0] throws a TypeError on text with no x. Check for null first, or use [...text.matchAll(pattern)], which gives an empty array.

  • Why does /<.+>/ match all of <b>bold</b> instead of just <b>?

    Quantifiers are greedy: .+ takes as much as it can, so the closing > matches the last angle bracket available. Add a question mark — /<.+?>/ — to make it lazy and stop at the first >.

  • Why does calling .test() repeatedly on the same /g pattern give different answers?

    A /g pattern keeps a lastIndex, and test both reads and advances it, so repeated calls walk through the string and eventually return false. Do not put the g flag on a pattern you use with test.

  • What do the g and i flags do?

    g means every match rather than only the first (needed by matchAll, and by replace when you want all occurrences). i ignores capitalisation. They go after the closing slash: /cat/gi.

Check this lesson against the source

We wrote the explanation; we did not invent the facts. This is the page that backs them.

MDN — Regular expressions (JavaScript Guide)

Check there: The guide documents literal /pattern/flags syntax, character classes, quantifiers (including the lazy ? form), anchors, groups, the g and i flags, and the behaviour of test, match, matchAll, replace and replaceAll.

MDN — Regular expression syntax cheatsheet

Check there: A one-page table of every class, quantifier, anchor and group form used in this lesson.

MDN — RegExp.prototype.test()

Check there: With the g flag, test advances lastIndex on each call and returns false once past the final match, then starts again.

MDN — String.prototype.match()

Check there: match returns null when there is no match, an array of all matches with the g flag, and the first match with its capture groups without it.

MDN — String.prototype.replace()

Check there: replace returns a new string, replaces only the first match unless the g flag is set, and accepts a function as the replacement.

Prefer to read the source in a structured breakdown? Turn this doc into a breakdown

0 of 2 exercises in this lesson solved on this device

Consent version 2026-07-31.1

Cookie preferences