RizTech Academy logo
RizTech Academy
Text, Numbers and DatesLesson 1 of 330 min

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

  1. 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.
  2. Extend it to reject a leading zero. Test all five values from the answer above.
  3. Write the mobile pattern for ten digits starting 6–9. Test a valid number, one starting with 5, and one with eleven digits.
  4. Escape a dot. Write a pattern for a decimal number, first without escaping and then with. Prove the unescaped one accepts 12x5.
  5. 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 indexing null.
  6. Rewrite that with named groups.
  7. Use matchAll to pull every number out of '3 dal, 22 rice, 333 atta'.
  8. Format '9876543210' as 98765 43210 with replace and $1 $2.
  9. Turn 2026-09-27 into 27/09/2026 with three groups.
  10. Reproduce the /g bug. Make a /\d+/g and call .test() on the same string three times. Then fix it two ways — removing g, and resetting lastIndex. This is the one to spend time on.
  11. Show greedy against lazy on '<a><b>'.
  12. 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 } where errors names each failing field. Use a separate named pattern per field, with a comment on each, and make sure none of them carries a g flag.

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