Regular expressions, built up from a pincode
Regular expressions look like somebody fell on the keyboard. They are also the shortest way to answer "is this a valid pincode", and you will meet them in every codebase. This lesson builds one from nothing rather than presenting a table of symbols.
The problem
An Indian pincode is six digits and never starts with zero. Written by hand:
function isPincode(value) {
if (value.length !== 6) return false;
if (value[0] === '0') return false;
for (const character of value) {
if (character < '0' || character > '9') return false;
}
return true;
}
Correct, and nine lines. The same thing as a pattern:
const pincode = /^[1-9][0-9]{5}$/;
console.log(pincode.test('411014'));
console.log(pincode.test('041101'));
true
false
Building that pattern up
The slashes are the literal syntax, like quotes for a string. Then, left to right:
| Piece | Means |
|---|---|
^ |
Start of the string |
[1-9] |
One character, from 1 to 9 |
[0-9] |
One character, from 0 to 9 |
{5} |
Exactly five of the previous thing |
$ |
End of the string |
So: start, a digit 1–9, then exactly five digits 0–9, then end.
^ and $ are what make it a validator. Without them the pattern says
"contains", not "is":
console.log(/[1-9][0-9]{5}/.test('my pin is 411014 ok'));
console.log(/^[1-9][0-9]{5}$/.test('my pin is 411014 ok'));
true
false
Leaving off the anchors is the commonest validation bug, and it accepts far too much.
The pieces worth memorising
Enough to read and write most patterns you will meet.
| Pattern | Matches |
|---|---|
. |
Any character except a newline |
\d |
A digit. \D is the opposite |
\w |
Letter, digit or underscore. \W opposite |
\s |
Whitespace. \S opposite |
[abc] |
One of a, b, c |
[^abc] |
Any one character that is not a, b or c |
[a-z] |
A range |
a|b |
a or b |
(...) |
A group, captured |
(?:...) |
A group, not captured |
Quantifiers, which apply to the thing before them:
| Pattern | Means |
|---|---|
* |
Zero or more |
+ |
One or more |
? |
Zero or one — optional |
{3} |
Exactly three |
{2,4} |
Two to four |
{2,} |
Two or more |
Flags, after the closing slash:
| Flag | Effect |
|---|---|
g |
Find all matches, not just the first |
i |
Ignore case |
m |
^ and $ match at each line |
s |
. also matches newlines |
So \d is a shorter [0-9], and the pincode could be /^[1-9]\d{5}$/.
An Indian mobile number — ten digits starting 6 to 9:
const mobile = /^[6-9]\d{9}$/;
console.log(mobile.test('9876543210'));
console.log(mobile.test('5876543210'));
true
false
Escaping
A dot means "any character", so to match a literal dot you escape it:
console.log(/^\d+\.\d+$/.test('12.5'));
console.log(/^\d+.\d+$/.test('12x5'));
true
true
The second accepted 12x5, because the unescaped dot matched the x. Escape
. * + ? ( ) [ ] { } ^ $ | \ / when you mean them literally. Inside [...]
most of them lose their special meaning, so [.] is also a literal dot.
Getting the matched text out
.test() gives a boolean. .match() gives the details:
const result = '411014'.match(/^(\d{3})(\d{3})$/);
console.log(result[0]);
console.log(result[1]);
console.log(result[2]);
411014
411
014
Index 0 is the whole match; the groups follow in order. No match at all
returns null, not an empty array:
console.log('abc'.match(/\d+/));
null
So 'abc'.match(/\d+/)[0] throws Cannot read properties of null. Check first.
Named groups are far more readable than counting brackets:
const date = '2026-09-27'.match(
/^(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})$/,
);
console.log(date.groups.day);
27
For every match, use matchAll with the g flag:
const numbers = [...'a1b22c333'.matchAll(/\d+/g)];
console.log(numbers.map((m) => m[0]));
[ '1', '22', '333' ]
Replacing
replace takes a pattern, and $1 refers to a group:
console.log('9876543210'.replace(/^(\d{5})(\d{5})$/, '$1 $2'));
console.log('2026-09-27'.replace(/(\d{4})-(\d{2})-(\d{2})/, '$3/$2/$1'));
98765 43210
27/09/2026
Module 2 said .replace() only replaces the first match. With a regex you have a
choice — add g:
console.log('a-b-c'.replace(/-/g, '/'));
a/b/c
The trap: a /g regex remembers where it was
This is the bug that will cost you an afternoon.
const digits = /\d+/g;
console.log(digits.test('a1'));
console.log(digits.test('a1'));
console.log(digits.test('a1'));
true
false
true
The same test on the same string, alternating. Nothing is random and nothing is broken.
A regex with g carries a lastIndex property — where to resume searching.
.test() advances it past the match. The second call starts after the digit,
finds nothing, returns false, and resets lastIndex to 0. So the third call
works again.
It bites when you store a regex in a const at the top of a file and use it in a
loop: every other item fails validation, and the pattern is obviously correct.
The fix: do not put g on a regex you use with .test(). You are asking "is
there one", not "find them all".
const digits = /\d+/;
console.log(digits.test('a1'));
console.log(digits.test('a1'));
true
true
If you genuinely need g, create the regex where you use it, or reset
digits.lastIndex = 0 before each test.
Greedy by default
console.log('<a><b>'.match(/<.+>/)[0]);
console.log('<a><b>'.match(/<.+?>/)[0]);
<a><b>
<a>
+ takes as much as it can and then backs off only far enough to match. Adding
? makes it lazy — take as little as possible. When a pattern grabs far more
than you expected, this is why.
When not to use one
Genuinely important.
Do not validate email addresses with a regex. The correct pattern is
thousands of characters long, and every short one you find online rejects
addresses that are real. Check there is an @ with something either side, and
then send a confirmation email — which is what actually proves an address works.
Do not parse HTML or JSON with a regex. Use a parser; JSON.parse exists.
Do not use one where includes or startsWith would do.
text.includes('dal') is clearer than /dal/.test(text).
Comment any pattern longer than about fifteen characters. You will not be able to read it next month, and neither will anybody else.
Check your work
/^[1-9][0-9]{5}$/ accepts 411014 and rejects 041101 (leading zero),
41101 (five digits), 4110145 (seven) and 411 014 (a space).
Without ^ and $, the pattern means "contains" and accepts
'my pin is 411014 ok'.
\d is [0-9], so /^[1-9]\d{5}$/ is the same pincode pattern.
An unescaped . matches any character, so /^\d+.\d+$/ accepts 12x5.
Escape it as \..
.match() gives the whole match at index 0 and groups after, so
'411014'.match(/^(\d{3})(\d{3})$/) gives 411014, 411, 014. A failed
match returns null, so indexing it throws.
Named groups read result.groups.day.
$1 in a replacement refers to the first group, so '9876543210' becomes
98765 43210.
A /g regex used with .test() alternates true, false, true because
lastIndex advances past the match and then resets on failure. Do not put g
on a regex you use with .test().
/<.+>/ is greedy and matches <a><b>; /<.+?>/ is lazy and matches
<a>.
Do not validate email with a regex. Check for an @ with something either
side and send a confirmation.
The validate exercise keeps one named, commented pattern per field, none
of them carrying g:
const PINCODE = /^[1-9]\d{5}$/; // six digits, no leading zero
const MOBILE = /^[6-9]\d{9}$/; // ten digits, starting 6 to 9
const NAME = /^[A-Za-z ]{2,}$/; // letters and spaces, at least two
function validate(form) {
const errors = {};
if (!PINCODE.test(form.pincode)) errors.pincode = 'Six digits, not starting with zero.';
if (!MOBILE.test(form.mobile)) errors.mobile = 'Ten digits starting 6 to 9.';
if (!NAME.test(form.name)) errors.name = 'Letters and spaces only.';
return { ok: Object.keys(errors).length === 0, errors };
}
Returning { ok, errors } rather than throwing is module 5's rule: a user
mistyping a pincode is an expected outcome, not an exceptional one.
The NAME pattern is deliberately crude and would reject real names —
anything with a hyphen, an apostrophe or a Devanagari character. That is the
email lesson in miniature: a pattern that is easy to write is not the same as a
pattern that is right, and for names the honest answer is to check only that
something was entered.
Practice
- Build the pincode pattern piece by piece. Start with
/\d{6}/, test it on'411014'and on'my pin is 411014 ok', then add the anchors and test again. - Extend it to reject a leading zero. Test all five values from the answer above.
- Write the mobile pattern for ten digits starting 6–9. Test a valid number, one starting with 5, and one with eleven digits.
- Escape a dot. Write a pattern for a decimal number, first without
escaping and then with. Prove the unescaped one accepts
12x5. - Use
.match()with two groups to split a pincode into halves. Then run it against text with no match and read the error you get from indexingnull. - Rewrite that with named groups.
- Use
matchAllto pull every number out of'3 dal, 22 rice, 333 atta'. - Format
'9876543210'as98765 43210withreplaceand$1 $2. - Turn
2026-09-27into27/09/2026with three groups. - Reproduce the
/gbug. Make a/\d+/gand call.test()on the same string three times. Then fix it two ways — removingg, and resettinglastIndex. This is the one to spend time on. - Show greedy against lazy on
'<a><b>'. - Harder. Write
validate(form)checking a pincode, a mobile number and a name of at least two characters containing only letters and spaces, and returning{ ok, errors }whereerrorsnames each failing field. Use a separate named pattern per field, with a comment on each, and make sure none of them carries agflag.
Next: numbers and money — why 80.10 * 3 is not ₹240.30, and how to format
rupees the way an Indian reader expects.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship