JavaScript RegEx Basics: Searching and Validating Text with Regular Expressions
We use test, match, matchAll and replace with regular expressions, learn character classes, quantifiers, groups and the g, i and m flags, write email, phone and postal code checks and meet RegExp.escape.
Version note: The code in this post targets ES2026 and current browsers (Chrome, Edge, Firefox, Safari).
Predict First
We try the same pattern on the same text twice:
const pattern = /cat/g;
console.log(pattern.test("cat"));
console.log(pattern.test("cat"));
Both lines ask the same question: "does this text contain cat?" Do they get the same answer? Make a guess before you keep reading. The answer is in the pitfalls section near the end of the post.
What Is a Regular Expression?
In the strings post we searched for text inside other text with includes and indexOf. Those methods work when you know exactly, character by character, what you're looking for. But if you're looking for "something made of five digits," "a word with an @ in it and a dot near the end" or "an eleven-digit number starting with 05," a single piece of text won't do. You need to describe a pattern.
A regular expression (RegEx for short) is exactly that: a pattern for text. Think of it as a sieve. You pour the text through it, and the pieces that fit the shape of the holes are separated out. You can write a pattern in two ways:
const r1 = /\d+/g; // literal: between two slashes
const r2 = new RegExp("\\d+", "g"); // constructor: the pattern as a string
const word = "apple";
const r3 = new RegExp(word, "i"); // the pattern comes from a variable
If you know the pattern in advance, the literal form is both short and readable. If the pattern comes from the user or from a variable, you use the RegExp constructor. The letters after the closing slash are flags; we'll see what g and i do shortly.
First Methods: test, match and replace
Paste these into the console and try them:
const pattern = /cat/i;
console.log(pattern.test("There is a Cat in the garden")); // true
console.log("There is a Cat in the garden".match(pattern)); // ["Cat", index: 11, ...]
console.log("Hello".match(/xyz/)); // null
const text = "cat, dog, cat";
console.log(text.match(/cat/g)); // ["cat", "cat"]
console.log(text.replace(/cat/, "bird")); // "bird, dog, cat"
console.log(text.replace(/cat/g, "bird")); // "bird, dog, bird"
test asks the simplest question: is there a piece that fits the pattern? The answer is true or false. It's what you'll use most for validation.
match is a string method, and its behavior depends on the g flag. Without g, it gives you the first match with its details in an array: the matched text, where it was found (index) and the groups we'll see in a moment. With g, it returns all matches as a plain array of strings. If there's no match at all, you get null in both cases, not an empty array; forgetting that is a very common bug.
replace swaps out the matching part. Without g, only the first one; with g, all of them. You can also pass a regular expression to the replaceAll we saw earlier, but then the g flag is mandatory; without it you get a TypeError.
The Building Blocks: Character Classes and Anchors
The power of a pattern comes from describing kinds of characters instead of individual letters:
"Order no: A-4821, total: 350 TL".match(/\d+/g); // ["4821", "350"]
"Phone: 0532-123-45-67".replace(/\D/g, ""); // "05321234567"
/^\w+$/.test("cafe"); // true
/^\w+$/.test("café"); // false
/^\p{L}+$/u.test("café"); // true
These are the ones you'll use most: \d is a digit, \w is a letter, digit or underscore, \s is a whitespace character (space, tab, line break). Their upper-case versions mean the opposite: \D is anything that isn't a digit, and \W and \S follow the same logic. The second line is a practical use of this: we cleaned up a phone number by deleting everything that isn't a digit. A dot (.) means any character except a line break.
With square brackets you build your own class: [aeiou] is a vowel, [a-z] is a letter from a to z, [0-9] is a digit. Put a ^ at the start and the meaning flips: [^0-9] is a character that isn't a digit.
^ and $ outside a class are anchors. They don't describe a character but a position: ^ is the start of the text, $ is the end. When validating, wrapping the pattern between the two means "not just part of the text, the whole text must fit this pattern."
An important warning if your users write in languages other than English: \w only knows English letters, so characters like é, ñ, ç or ü aren't letters to it. To cover letters in every language, use the \p{L} syntax, which arrived with ES2018, together with the u flag. If you want to search for one of the characters with special meaning in a pattern, . * + ? ( ) [ ] { } | ^ $ \ /, as a plain character, put a backslash in front of it: \. means a real dot.
Quantifiers: How Many?
You say how many times a piece repeats with quantifiers:
/^\d{5}$/.test("34000"); // true: exactly 5 digits
/^\d{2,4}$/.test("123"); // true: between 2 and 4 digits
/^\d{3,}$/.test("12"); // false: at least 3 digits
/https?:\/\//.test("http://a"); // true: with or without the s
const text = '"apple" and "pear"';
text.match(/".+"/)[0]; // '"apple" and "pear"'
text.match(/".+?"/)[0]; // '"apple"'
{5} means exactly five times, {2,4} between two and four, {3,} at least three. There are also three shortcuts: * zero or more, + one or more, ? zero or one time. That's why https? matches both http and https.
The last two lines show a habit of quantifiers: by default they're greedy. .+ grabs as long a piece as it can, which is why it took everything from the first quote to the last. Put a ? after the quantifier and it becomes lazy, stopping at the shortest piece that completes the pattern.
Groups and matchAll
Parentheses group part of a pattern and capture that part separately:
const result = "Delivery date: 2026-03-15".match(/(\d{4})-(\d{2})-(\d{2})/);
console.log(result[0]); // "2026-03-15": the whole match
console.log(result[1]); // "2026": the first group
console.log(result[3]); // "15": the third group
const named = "2026-03-15".match(/(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/);
console.log(named.groups.year, named.groups.month); // "2026" "03"
console.log("2026-03-15".replace(/(\d{4})-(\d{2})-(\d{2})/, "$3.$2.$1")); // "15.03.2026"
Element zero of the match array is the whole matched text, and the following elements are what each group captured, in order. With the (?<name>...) syntax that arrived in ES2018, you can name your groups and reach them readably through groups. Inside replace, expressions like $1 and $2 stand for the groups; on the last line we turned the ISO date from the previous post into day.month.year format in one go. Use | inside a group to say "either this or that": (cat|dog).
If you want every match in a text along with its groups, use matchAll:
const post = "#javascript with #regex, wrapping up the #basics series";
for (const match of post.matchAll(/#(\w+)/g)) {
console.log(match[1], match.index);
}
// "javascript" 0
// "regex" 17
// "basics" 41
When you gave match the g flag, you lost the groups and positions; matchAll gives you all the details of every match. It returns an iterator rather than an array; you loop over it with the for...of we saw earlier, or turn it into an array with [...post.matchAll(pattern)]. There's one condition: the pattern must have the g flag, otherwise it throws a TypeError.
Flags: g, i and m
Flags change how a pattern works and are written after the closing slash:
"Cat cat CAT".match(/cat/gi); // ["Cat", "cat", "CAT"]
"one\ntwo\ncafé".match(/^\w+$/gm); // ["one", "two"]
"one\ntwo\ncafé".match(/^\w+$/g); // null
/istanbul/i.test("İSTANBUL"); // false
g (global) doesn't stop at the first match, it finds them all. i treats upper and lower case as the same. m (multiline) makes ^ and $ mark the start and end of every line; without it they mark the start and end of the whole text. You already know why "café" didn't come back on the second line: é isn't a letter to \w.
The last line holds a surprise for Turkish text: the Turkish alphabet has a dotted capital İ, and it didn't match. The i flag compares letters with general, language-independent Unicode rules, and by those rules the capital of i is I, not İ. Keep that in mind when searching user text in languages with special casing rules. Beyond these there are other flags, such as u (Unicode) and s (let the dot match line breaks too); you'll run into them as you need them.
Practical Examples: Email, Phone, Postal Code
Now let's apply what we've learned to a form. Save it, open it and start typing into the boxes:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Live Check</title>
<style>
.valid { color: green; }
.invalid { color: firebrick; }
</style>
</head>
<body>
<p><input id="email" placeholder="Email"> <span id="email-status"></span></p>
<p><input id="phone" placeholder="Mobile phone"> <span id="phone-status"></span></p>
<p><input id="postalCode" placeholder="Postal code"> <span id="postalCode-status"></span></p>
<script>
const rules = {
email: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
phone: /^(\+90|0)?5\d{2}\s?\d{3}\s?\d{2}\s?\d{2}$/,
postalCode: /^\d{5}$/
};
for (const field of Object.keys(rules)) {
const box = document.getElementById(field);
const status = document.getElementById(field + "-status");
box.addEventListener("input", () => {
const value = box.value.trim();
if (value === "") {
status.textContent = "";
return;
}
const ok = rules[field].test(value);
status.textContent = ok ? "Looks good" : "Wrong format";
status.className = ok ? "valid" : "invalid";
});
}
</script>
</body>
</html>
The email pattern reads like this: at the start, one or more characters that aren't whitespace or @, then an @, then again characters that aren't whitespace or @, a real dot, and finally more characters of the same kind. It's deliberately lenient. The official rules for email addresses are so broad that a regular expression covering them completely becomes unreadable. The only sure way to know an address really works is to send a confirmation email to it.
The phone pattern targets Turkish mobile numbers as an example format: an optional +90 or 0, then a three-digit operator code starting with 5, and groups of digits with optional spaces between them. 0532 123 45 67, +905321234567 and 5321234567 pass, 0212 123 45 67 doesn't. For postal codes, five digits is a sufficient check in Türkiye. If you also want the first two digits to be a valid province code, you can tighten the pattern to /^(0[1-9]|[1-7]\d|8[01])\d{3}$/. Adapting these to your own country's formats is a good exercise. The HTML pattern attribute we saw in the forms post accepts patterns like these too.
One last tool: if you build a pattern from text the user typed, the special characters in it cause trouble. RegExp.escape, which arrived with ES2025, escapes those characters for you. It has worked in all current browsers since May 2025, but older browser versions don't have it:
const text = "Version 3.5 and 345 came out on the same day.";
const searchTerm = "3.5";
console.log(text.match(new RegExp(searchTerm, "g"))); // ["3.5", "345"]
console.log(text.match(new RegExp(RegExp.escape(searchTerm), "g"))); // ["3.5"]
On the first line, 345 matched too, because the dot means "any character." Once RegExp.escape turned the dot into a real dot, only the result we were looking for remained.
Common Pitfalls
Using test with the g flag
The answer to the opening question: first true, then false. A pattern with the g flag remembers where it left off in a property called lastIndex. The first test finds "cat" and sets lastIndex to 3. The second test starts searching at character 3, finds nothing at the end of the text, returns false and resets lastIndex to zero. A third call would say true again. If you're only asking "is it there?", don't use g.
Forgetting the anchors
/\d{5}/.test("123456") returns true, because there's a five-digit piece inside the six digits. If you forget ^ and $ in validation patterns, the whole value counts as valid when only part of it fits.
A single backslash inside a string
If you write new RegExp("\d+"), the string swallows the backslash before it ever reaches the pattern, and the pattern becomes d+. In strings you pass to the constructor, write the backslash twice: "\\d+".
Writing unreadable patterns
Regular expressions can turn into cryptic text very quickly. If even you struggle to understand a pattern a week after writing it, split it into smaller checks or put a comment next to it explaining what it does.
A Small Experiment
★☆☆ Write a pattern that checks a four-digit PIN. "1234" should pass, "12a4" and "12345" shouldn't.
★★☆ Predict the outputs before running, then try them:
console.log("a1b22c333".match(/\d+/g));
console.log("a1b22c333".match(/\d/g).length);
console.log("2026-03-15".replace(/-/g, "/"));
★★★ Put a paragraph and a search box on a page. As the user types, show how many times the search term appears in the paragraph. It should ignore upper and lower case, and work correctly even if the user types a dot or a parenthesis. Hint: RegExp.escape and the gi flags; also remember that match returns null when there's no match.
With this post, we've finished the JavaScript Basics series. From variables to regular expressions, we built the language's building blocks and our first interactive pages in the browser together. In the Intermediate series we'll build on top of all this: modern syntax like destructuring and spread, map, filter and reduce, closures and this, classes and, above all, the asynchronous world. With promises, async/await and the Fetch API, your page will talk to a server for the first time.