The KYC Form Problem
Every bank, mobile wallet and broker app in India asks you to complete KYC (Know Your Customer). You type your PIN code, mobile number, PAN and sometimes your vehicle number. Each one has a strict format. A PIN code has 6 digits and never starts with 0. A mobile number has 10 digits and starts with 6, 7, 8 or 9. A PAN looks like ABCDE1234F: five letters, four digits, one letter. A vehicle number looks like MH12AB1234.
You could check these with loops and many if statements, character by character. It would take dozens of lines for each field. A regular expression (regex for short) describes the whole format in one short pattern, and JavaScript checks it for you.
Regex looks scary at first, because it is dense. The secret is to build each pattern step by step, testing as you go. By the end of this lesson you will read and write patterns for all four KYC fields, and use them in a live form. This lesson builds on the JavaScript Strings lesson and pairs well with the JavaScript Form Validation lesson.
Creating a Regular Expression
The usual way to write a regex is a literal: the pattern between two forward slashes, like /chai/. Letters and digits in a pattern match themselves. The simplest method, pattern.test(text), returns true if the pattern is found anywhere in the text.
const pattern = /chai/;
console.log(pattern.test("One masala chai, please")); // true
console.log(pattern.test("One cold coffee, please")); // false
console.log(pattern.test("CHAI for table 4")); // false (case matters)
// The i flag ignores upper and lower case
console.log(/chai/i.test("CHAI for table 4")); // true
// The same pattern built from a string, useful when the text comes from a variable
const word = "dosa";
const fromString = new RegExp(word, "i");
console.log(fromString.test("Two Masala Dosa")); // true
The letter after the closing slash is a flag. Flags change how the whole pattern behaves:
| Flag | Meaning |
|---|---|
i | Ignore case: A and a are treated the same |
g | Global: find all matches, not just the first (used with match, matchAll and replace) |
Use new RegExp("...") only when the pattern comes from a variable. Literals are easier to read.
Character Classes and Quantifiers
A character class matches one character from a group. A quantifier says how many times the thing before it may repeat. These two ideas do most of the work in any pattern.
| Pattern | Matches |
|---|---|
\d | One digit, 0 to 9 |
\w | One letter, digit or underscore |
\s | One space, tab or new line |
[A-Z] | One capital letter |
[6-9] | One digit from 6 to 9 |
[^0-9] | One character that is not a digit (^ inside brackets means “not”) |
. | Any one character except a new line (write \. for a real dot) |
| Quantifier | Meaning |
|---|---|
{n} | Exactly n times, so \d{6} is six digits |
{n,m} | Between n and m times |
+ | One or more times |
* | Zero or more times |
? | Zero or one time (optional) |
// \d means "any digit 0 to 9"
console.log(/\d/.test("Flat 4B")); // true
console.log(/\d/.test("Flat B")); // false
// [A-Z] means "any capital letter", [6-9] means "a digit from 6 to 9"
console.log(/[A-Z]/.test("pune")); // false
console.log(/[6-9]/.test("5 and 7")); // true
// \w is a letter, digit or underscore; \s is a space, tab or new line
console.log(/\w\s\w/.test("MG Road")); // true (G, space, R)
// Quantifiers say how many times
console.log(/\d{3}/.test("Room 12")); // false (needs 3 digits in a row)
console.log(/\d{3}/.test("Room 120")); // true
console.log(/^\d+$/.test("400001")); // true (+ means one or more)
console.log(/^\d*$/.test("")); // true (* means zero or more)
console.log(/^colou?r$/.test("color")); // true (? means optional)
Anchors and Building a Pattern Step by Step
By default, test looks for the pattern anywhere in the text. For validation that is wrong: “PIN 560001” contains six digits, but it is not a PIN code. Anchors fix this. ^ means “the start of the text” and $ means “the end”. With both, the whole text must match the pattern, and nothing else is allowed.
Watch the PIN code pattern grow in three steps. Each line of output shows a sample, then the result of step 1, step 2 and step 3:
// Goal: a 6-digit PIN code that does not start with 0
const step1 = /\d{6}/; // six digits somewhere
const step2 = /[1-9]\d{5}/; // first digit 1 to 9, then five more digits
const pinCode = /^[1-9]\d{5}$/; // ^ start and $ end: nothing else allowed
const samples = ["110001", "012345", "56001", "4000012", "PIN 560001"];
samples.forEach((s) => {
console.log(s, step1.test(s), step2.test(s), pinCode.test(s));
});
// 110001 true true true
// 012345 true false false
// 56001 false false false
// 4000012 true true false
// PIN 560001 true true false
Step 1 accepts “012345”, which is wrong. Step 2 rejects it by asking for the first digit in [1-9], then five more, written \d{5}. But step 2 still accepts seven digits and extra text. Step 3 adds the anchors and gets every case right. The same method gives the other KYC patterns:
| Field | Pattern | Read it as |
|---|---|---|
| PIN code | /^[1-9]\d{5}$/ | A digit 1 to 9, then 5 digits |
| Mobile | /^[6-9]\d{9}$/ | A digit 6 to 9, then 9 digits |
| PAN | /^[A-Z]{5}\d{4}[A-Z]$/ | 5 capitals, 4 digits, 1 capital |
| Vehicle | /^[A-Z]{2}\d{2}[A-Z]{1,2}\d{4}$/ | State, district, 1 or 2 series letters, 4 digits |
A regex checks the format, not the truth. ABCDE1234F has a valid PAN format, but only the tax department can say whether it is a real PAN. Always check real data on the server too.
Finding Matches: match and matchAll
To extract text instead of just checking it, use string methods:
text.match(pattern)withoutgreturns the first match with details such as its position, ornull.text.match(pattern)withgreturns an array of every matching string, ornull.text.matchAll(pattern)needsgand gives every match with its groups, ready for afor...ofloop.
Round brackets in a pattern create a group, which captures that part of the match separately as m[1], m[2] and so on.
const address = "Ship to 411014, Pune. Old address: 560034, Bengaluru. Office: 600028.";
// match without g: the first match, with details
const first = address.match(/[1-9]\d{5}/);
console.log(first[0], "found at position", first.index); // 411014 found at position 8
// match with g: an array of every match
console.log(address.match(/[1-9]\d{5}/g)); // ["411014", "560034", "600028"]
// No match gives null, not an empty array
console.log("No PIN here".match(/[1-9]\d{5}/g)); // null
// matchAll with g: every match with its groups (here, the city after the PIN)
const withCity = /([1-9]\d{5}), (\w+)/g;
for (const m of address.matchAll(withCity)) {
console.log(m[2] + ": " + m[1]);
}
// Pune: 411014
// Bengaluru: 560034
The office PIN 600028 is in the g match list but not in the matchAll loop, because that pattern also needs a comma and a city name after the PIN.
Replacing Text with replace
text.replace(pattern, replacement) returns a new string. Without g, only the first match is replaced. Inside the replacement, $1 and $2 insert the groups. You can also pass a function that receives each match and returns its replacement. This is very useful for cleaning what people type before you validate it:
// Clean what the user typed: remove all spaces, make it upper case
const typedPan = " abcde 1234 f ";
const pan = typedPan.replace(/\s+/g, "").toUpperCase();
console.log(pan); // ABCDE1234F
// Without g, only the first match is replaced
console.log("1-2-3".replace(/-/, "+")); // 1+2-3
console.log("1-2-3".replace(/-/g, "+")); // 1+2+3
// Use groups in the replacement with $1, $2 ...: hide the middle of a mobile number
const mobile = "9000012345"; // a made-up number
console.log(mobile.replace(/^(\d{2})\d{6}(\d{2})$/, "$1******$2")); // 90******45
// Or pass a function to build the replacement
const price = "Total: 125000 rupees";
console.log(price.replace(/\d+/, (n) => Number(n).toLocaleString("en-IN")));
// Total: 1,25,000 rupees
Named Groups
Numbered groups like m[3] are hard to read. A named group, written (?<name>...), lets you read the part as match.groups.name. Here the vehicle number is split into its state code, district number, series and number:
const vehicle = /^(?<state>[A-Z]{2})(?<district>\d{2})(?<series>[A-Z]{1,2})(?<number>\d{4})$/;
const result = "MH12AB1234".match(vehicle);
console.log(result.groups.state); // MH
console.log(result.groups.district); // 12
console.log(result.groups.series); // AB
console.log(result.groups.number); // 1234
// Destructuring makes it neat
const { state, number } = "KA05M9876".match(vehicle).groups;
console.log(state, number); // KA 9876
console.log(vehicle.test("MH12ABC1234")); // false (3 series letters)
console.log("mh12ab1234".match(vehicle)); // null (lower case)
Real-Life Example: A Live KYC Form
Now let us use all four patterns in a form. Each field is checked as you type. The clean function first removes spaces and hyphens and makes letters upper case, so “mh 12 ab 1234” is accepted. The vehicle field uses named groups to show the state and district.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>KYC Form</title>
<style>
body { font-family: sans-serif; padding: 16px; max-width: 420px; }
label { display: block; margin-top: 12px; font-weight: bold; }
input { width: 100%; padding: 8px; font-size: 16px; box-sizing: border-box; }
.hint { font-size: 14px; margin: 4px 0 0; }
.ok { color: green; }
.bad { color: crimson; }
</style>
</head>
<body>
<h2>Complete Your KYC</h2>
<form id="kyc" novalidate>
<label for="pin">PIN code</label>
<input id="pin" inputmode="numeric" placeholder="560001">
<p class="hint" id="pin-hint"></p>
<label for="mobile">Mobile number</label>
<input id="mobile" inputmode="numeric" placeholder="10 digits">
<p class="hint" id="mobile-hint"></p>
<label for="pan">PAN</label>
<input id="pan" placeholder="ABCDE1234F">
<p class="hint" id="pan-hint"></p>
<label for="vehicle">Vehicle number (optional)</label>
<input id="vehicle" placeholder="MH12AB1234">
<p class="hint" id="vehicle-hint"></p>
<p><button type="submit">Submit</button></p>
<p id="result"></p>
</form>
<script>
const rules = {
pin: { pattern: /^[1-9]\d{5}$/, message: "6 digits, not starting with 0" },
mobile: { pattern: /^[6-9]\d{9}$/, message: "10 digits, starting with 6, 7, 8 or 9" },
pan: { pattern: /^[A-Z]{5}\d{4}[A-Z]$/, message: "5 letters, 4 digits, 1 letter" },
vehicle: {
pattern: /^(?<state>[A-Z]{2})(?<district>\d{2})(?<series>[A-Z]{1,2})(?<number>\d{4})$/,
message: "Like MH12AB1234",
optional: true,
},
};
function clean(value) {
return value.replace(/[\s-]+/g, "").toUpperCase(); // allow spaces and hyphens
}
function check(id) {
const input = document.getElementById(id);
const hint = document.getElementById(id + "-hint");
const rule = rules[id];
const value = clean(input.value);
if (value === "" && rule.optional) {
hint.textContent = "";
return true;
}
const match = value.match(rule.pattern);
if (match) {
hint.className = "hint ok";
hint.textContent = match.groups
? "Valid: registered in state " + match.groups.state + ", district " + match.groups.district
: "Looks good";
return true;
}
hint.className = "hint bad";
hint.textContent = rule.message;
return false;
}
Object.keys(rules).forEach((id) => {
document.getElementById(id).addEventListener("input", () => check(id));
});
document.querySelector("#kyc").addEventListener("submit", (event) => {
event.preventDefault();
const results = Object.keys(rules).map(check);
const allOk = results.every(Boolean);
document.querySelector("#result").textContent = allOk
? "All details are valid. KYC submitted."
: "Please fix the fields marked in red.";
});
</script>
</body>
</html>
Try these changes:
- Type a PIN code starting with 0 and watch the message. Then type a correct one.
- Change the mobile rule so that an optional
+91at the start is allowed. Hint:(\+91)?, where\+means a real plus sign. - Add an email field with a simple pattern such as
/^[^\s@]+@[^\s@]+\.[^\s@]+$/and test it withstudent@example.com.
Common Mistakes
- Forgetting the anchors. Without
^and$,/[6-9]\d{9}/also accepts a 15-digit number, because it finds 10 good digits inside it. - Using the
gflag withtest. A regex withgremembers where its last match ended (inlastIndex), so callingtestagain on the same text can give a different answer:
const pinWithG = /^[1-9]\d{5}$/g; // g is not needed here
console.log(pinWithG.test("560001")); // true
console.log(pinWithG.test("560001")); // false (!)
console.log(pinWithG.test("560001")); // true
// Fix: no g flag when you only want test()
const pin = /^[1-9]\d{5}$/;
console.log(pin.test("560001"), pin.test("560001")); // true true
- Forgetting to escape special characters.
.,+,?,*,(and$have special meanings. Put a backslash before them to match the real character, as in\.for a dot. - Being stricter than people type. Users add spaces, hyphens and small letters. Clean the input with
replaceandtoUpperCasefirst, then validate. - Writing one giant pattern at once. Build it in small steps and test each step with good and bad samples.
Practice Task: IFSC Code and Date of Birth
Two more fields appear in banking forms. Write a pattern for each one:
- An IFSC code (a bank branch code): 4 capital letters, then the digit 0, then 6 capital letters or digits. Example:
ABCD0123456. - A date of birth in the form DD-MM-YYYY, with named groups
day,monthandyear. The day must be 01 to 31, the month 01 to 12, and the year must start with 19 or 20. Hint: use|, which means “or”, inside a group. - Run the samples and make sure every expected result appears.
// TODO 1: an IFSC code pattern.
// 4 capital letters, then the digit 0, then 6 capital letters or digits.
const ifsc = /TODO/;
// TODO 2: a date of birth pattern DD-MM-YYYY with named groups day, month, year.
// Day 01 to 31, month 01 to 12, year 19xx or 20xx.
const dob = /TODO/;
const ifscSamples = ["ABCD0123456", "abcd0123456", "ABCD1123456", "ABCD012345", "XYZW0A1B2C3"];
ifscSamples.forEach((s) => console.log(s, ifsc.test(s)));
// Expected: true, false, false, false, true
const dobSamples = ["15-08-2004", "31-12-1999", "32-01-2000", "10-13-2001", "5-6-2002"];
dobSamples.forEach((s) => {
const m = s.match(dob);
console.log(s, m ? "year " + m.groups.year : "invalid");
});
// Expected: year 2004, year 1999, invalid, invalid, invalid
Show one possible answer (try it yourself first!)
const ifsc = /^[A-Z]{4}0[A-Z0-9]{6}$/;
const dob = /^(?<day>0[1-9]|[12]\d|3[01])-(?<month>0[1-9]|1[0-2])-(?<year>(19|20)\d{2})$/;
const ifscSamples = ["ABCD0123456", "abcd0123456", "ABCD1123456", "ABCD012345", "XYZW0A1B2C3"];
ifscSamples.forEach((s) => console.log(s, ifsc.test(s)));
// ABCD0123456 true
// abcd0123456 false
// ABCD1123456 false
// ABCD012345 false
// XYZW0A1B2C3 true
const dobSamples = ["15-08-2004", "31-12-1999", "32-01-2000", "10-13-2001", "5-6-2002"];
dobSamples.forEach((s) => {
const m = s.match(dob);
console.log(s, m ? "year " + m.groups.year : "invalid");
});
// 15-08-2004 year 2004
// 31-12-1999 year 1999
// 32-01-2000 invalid
// 10-13-2001 invalid
// 5-6-2002 invalid
Key Takeaways
- A regular expression is a pattern for text, written between slashes, like
/^[1-9]\d{5}$/. - Character classes (
\d,\w,\s,[A-Z]) match one character; quantifiers ({n},+,*,?) say how many. - Use
^and$to make the whole text match, which is what validation needs. testchecks,matchandmatchAllextract, andreplacechanges text. Thegflag means all matches;iignores case.- Groups capture parts of a match; named groups make them readable as
match.groups.name. - Build patterns step by step, clean the input first, and remember that a regex checks only the format.