JavaScript (ES2026) Module 7 – Strings, Unicode and regular expressions
Regular expressions in JavaScript
Write JavaScript regular expressions as literals or with RegExp, choose the right flags, and use test, exec, match, matchAll, replace and split safely.
What you will learn
- Create regexes with literals and the RegExp constructor
- Choose the flags a task needs and predict what each one changes
- Use test, exec, match, matchAll, replace and split for the job each one does best
- Avoid the lastIndex trap of global and sticky regexes
Before you start
What you’ll learn · Before you start
What you will learn
- Create regexes with literals and the RegExp constructor
- Choose the flags a task needs and predict what each one changes
- Use test, exec, match, matchAll, replace and split for the job each one does best
- Avoid the lastIndex trap of global and sticky regexes
Before you start
On this page
A regular expression, or regex, is a short pattern that describes text: /^\d{6}$/ means “six digits and nothing
else”. In JavaScript a regex is a RegExp object. You write it as a literal between two slashes, with optional flags
such as g or i after the closing slash, or you build it while the program runs with new RegExp(pattern, flags).
Two groups of methods use it: the regex’s own test and exec, and the string methods match, matchAll,
replace, replaceAll, split and search. Together they check that input has the right shape, pull fields out of
a log line, rewrite text and cut it into pieces. This lesson shows how to create a regex, what each flag changes,
which method fits which job, and the lastIndex trap: a regex with the g or y flag remembers where its last match
ended, so the same call with the same kind of input can answer differently the second time.
A literal or the RegExp constructor
A literal is the pattern itself between slashes, /\d{6}/. It is read together with the rest of the code, so a broken
pattern, such as a bracket that is never closed, stops the script with a SyntaxError before any line of it runs.
Each time the literal is evaluated, for example on every call of a function that contains it, it creates a new
RegExp object.
new RegExp(pattern, flags) takes the pattern as a string. That is what you need when part of the pattern is known
only at run time: a field name read from a configuration file, a word someone searches for. The cost of the string is
the backslash. In a string literal '\d' is simply the letter d, so the backslash that belongs to the pattern has to
be written twice, '\\d'.
// The same pattern written two ways: six digits in a row.
const literal = /\d{6}/;
const built = new RegExp('\\d{6}'); // inside a string, the pattern's backslash is written twice
console.log(literal.source, '|', built.source);
console.log(literal.test('Pune 411001'), built.test('Pune 411001'));
// A pattern that is only known when the program runs: build it from a string.
const field = 'pin';
const fieldLine = new RegExp(`^${field}=(\\d+)$`, 'm');
console.log(fieldLine.source, '| flags:', fieldLine.flags);
console.log(fieldLine.exec('name=Asha\npin=411001\ncity=Pune')[1]);
// A copy with other flags: pass a regex and the new flags. The original keeps its own.
const everyMatch = new RegExp(literal, 'g');
console.log(everyMatch.source, '| flags:', everyMatch.flags, '| original flags:', JSON.stringify(literal.flags));
// The usual mistake: '\d' in a string is just 'd', so this pattern looks for six letters d.
const wrong = new RegExp('\d{6}');
console.log(wrong.source, wrong.test('411001'), wrong.test('dddddd')); Output
\d{6} | \d{6}
true true
^pin=(\d+)$ | flags: m
411001
\d{6} | flags: g | original flags: ""
d{6} false true
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node literal-and-constructor.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
Both ways produce the same kind of object: source holds the pattern and flags the flags. Passing an existing regex
and new flags to the constructor makes a copy with those flags, and the original keeps its own. The last line is the
mistake almost everyone makes once: new RegExp('\d{6}') received the string d{6}, so it rejects six digits and
accepts six letters d, without any error. A template literal is a handy way to assemble a pattern from pieces, but a
piece that comes from outside the program, such as a search term, must be escaped first, or its ., ( and + turn
into pattern syntax. RegExp.escape, which does that safely, is in the next lesson of this module.
The pattern language, briefly
The building blocks below cover most patterns in everyday code. Characters without a special meaning match themselves, and the rest combine freely.
| Pattern | Matches |
|---|---|
abc |
the characters a, b and c, in that order |
. |
any one character except a line break (any at all with the s flag) |
\d, \w, \s |
a digit 0 to 9; an ASCII letter, digit or underscore; a space, tab, line break or other white space |
\D, \W, \S |
any character that \d, \w or \s does not match |
[aeiou], [A-Z0-9], [^,] |
one character from a set or a range; a ^ at the start of the brackets inverts the set |
^, $ |
the start and the end of the text (of every line with the m flag) |
\b |
a word boundary: a position between a \w character and one that is not |
x*, x+, x? |
zero or more, one or more, zero or one x, as many as possible |
x{6}, x{2,4}, x{2,} |
exactly 6, from 2 to 4, at least 2 |
x+?, x*? |
as few as possible instead of as many as possible (lazy) |
(…), (?:…), (?<name>…) |
a numbered group that captures its text, a group that captures nothing, a named group |
a|b |
a or b |
\., \(, \\ |
the character itself: a backslash removes a special meaning |
Lookahead and lookbehind, Unicode property escapes such as \p{L} and the syntax added in the newest editions are
covered in the next lesson.
Flags change how the whole pattern matches
Flags come after the closing slash, /x/gi, or as the second argument of the constructor. Each one has a read-only
property with a longer name.
d(hasIndices): each result also gives the start and end of every group, inindices.g(global): look for every match;testandexecstart where the last match ended.i(ignoreCase): capital and small letters match each other.m(multiline):^and$match at the start and end of every line, not only of the text.s(dotAll):.matches line breaks as well.u(unicode): the text is read by code point, so an emoji is one character; the syntax is stricter, and\p{…}escapes become available.v(unicodeSets): everythingudoes, plus set operations inside character classes.y(sticky): a match must start exactly atlastIndex.
const notes = 'Line one\nline two\nLINE THREE';
// i ignores case; m makes ^ and $ match at every line; g finds every match.
console.log(JSON.stringify(notes.match(/^line \w+/gim)));
console.log(JSON.stringify(notes.match(/^line \w+/gi)));
// s lets . match a line break too.
console.log(/one.line/.test(notes), /one.line/s.test(notes));
// u reads the pattern and the text by code point, so one emoji is one character.
console.log('🚆'.length, /^.$/.test('🚆'), /^.$/u.test('🚆'));
// The flags property lists the flags in a fixed order, whatever order you wrote them in.
const re = new RegExp('x', 'yigm');
console.log(re.flags, re.global, re.ignoreCase, re.multiline, re.sticky);
// Unknown, repeated or clashing flags are refused when the regex is made.
for (const flags of ['gg', 'q', 'uv']) {
try {
new RegExp('x', flags);
} catch (e) {
console.log(JSON.stringify(flags), e.name);
}
} Output
["Line one","line two","LINE THREE"] ["Line one"] false true 2 false true gimy true true true true "gg" SyntaxError "q" SyntaxError "uv" SyntaxError
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node flags.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
With i and m together, ^line \w+ finds a match at the start of each of the three lines whatever their case;
without m, ^ means only the start of the whole text, so only the first line matches. A dot refuses the line break
between “one” and “line” until the s flag allows it. The train emoji is two UTF-16 code units, so /^.$/ sees two
characters, while the u flag reads it as the one code point it is. Whatever order you type the flags in, flags
lists them in one fixed order, dgimsuvy. A repeated flag, an unknown one, or u together with v is refused with a
SyntaxError when the regex is created.
Which method for which job
The regex methods and the string methods overlap. Pick by what you need back:
| You need | The call, and what it returns |
|---|---|
| Whether the text matches | re.test(text): true or false |
| The first match, with its groups and position | re.exec(text), or text.match(re) without g: a match array, or null |
| The text of every match | text.match(re) with g: an array of strings, or null |
| Every match, with its groups and position | text.matchAll(re), which requires g: an iterator of match arrays |
| Where the first match starts | text.search(re): an index, or -1 |
| A changed copy of the text | text.replace(re, …) or text.replaceAll(re, …): a new string |
| The text cut into pieces | text.split(re): an array of strings |
search and split ignore the g flag and lastIndex, and leave them as they found them; the others are covered
one by one below.
Checking that a whole string has a shape
test answers yes or no. To check a value rather than search inside it, the pattern must be anchored: ^ at the
start and $ at the end, so that nothing may come before or after the shape.
The shape of an IFSC
The Reserve Bank of India describes the IFSC (Indian Financial System Code), which identifies a bank branch for
NEFT transfers, as 11 characters: the first four are letters for the bank, the fifth is always the digit 0, and the
last six identify the branch. The regex /^[A-Z]{4}0[A-Z0-9]{6}$/ says exactly that.
// The shape of an IFSC: 4 letters for the bank, the digit 0, then 6 letters or digits for the branch.
const IFSC = /^[A-Z]{4}0[A-Z0-9]{6}$/;
function ifscShape(input) {
const code = input.trim().toUpperCase();
return IFSC.test(code) ? `${code} has the shape of an IFSC` : `${JSON.stringify(input)} is not an IFSC`;
}
for (const input of ['ABCD0123456', ' wxyz0a1b2c3 ', 'ABCD0I23456', 'ABCDO123456', 'ABCD012345', 'ABCD01234567', 'AB1D0123456']) {
console.log(ifscShape(input));
}
// Without ^ and $, test() is true when the shape appears anywhere in the text.
const unanchored = /[A-Z]{4}0[A-Z0-9]{6}/;
console.log(unanchored.test('XABCD0123456789'), IFSC.test('XABCD0123456789')); Output
ABCD0123456 has the shape of an IFSC WXYZ0A1B2C3 has the shape of an IFSC ABCD0I23456 has the shape of an IFSC "ABCDO123456" is not an IFSC "ABCD012345" is not an IFSC "ABCD01234567" is not an IFSC "AB1D0123456" is not an IFSC true false
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node ifsc-check.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
The function cleans the input before testing it: trim() removes the spaces a form often adds, and toUpperCase()
lets someone type the code in small letters while the pattern stays strict about the stored value. Two lines of the
output deserve a second look. ABCD0I23456 passes, because a capital I is a valid branch character: a regex checks the
shape of a value, never whether a branch with that code exists. ABCDO123456, with the letter O in place of the zero,
is rejected, which is exactly what the fifth position is for. The last line shows why the anchors matter: without them,
test is happy to find a valid-looking code inside a longer, invalid string.
Getting the parts: exec and match
exec returns the first match as an array: the whole matched text at index 0, then one entry per numbered group,
plus a few properties. index is where the match starts, input is the text that was searched, and groups is an
object with one property per named group (it is undefined when the pattern has no named groups). When nothing
matches, the result is null.
const sms = 'Order 4521: 2 x masala chai, ready. Order 4522: 1 x samosa, on its way.';
const ORDER = /Order (?<id>\d+): (?<qty>\d+) x (?<item>[a-z ]+)/;
// exec returns the first match with its groups and position, or null.
const m = ORDER.exec(sms);
console.log(m[0]);
console.log(m[1], m.groups.item, 'at index', m.index);
// match without the g flag returns the same kind of result as exec.
const { id, qty } = sms.match(ORDER).groups;
console.log(id, Number(qty) * 20);
// With the g flag, match returns only the matched texts: no groups, no positions.
console.log(JSON.stringify(sms.match(/Order \d+/g)));
// No match gives null, so read .length or a group only after checking.
console.log(sms.match(/refund/));
console.log((sms.match(/refund/g) ?? []).length); Output
Order 4521: 2 x masala chai 4521 masala chai at index 0 4521 40 ["Order 4521","Order 4522"] null 0
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node exec-and-match.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
Without the g flag, text.match(re) gives back the same kind of result as re.exec(text), which makes
destructuring the groups convenient. With g, match changes its answer: an array of the matched texts only, with no
groups and no positions. In both forms a text with no match gives null, not an empty array, so code such as
text.match(re).length throws a TypeError on the first message that does not match; ?? [] turns the null into
an empty array.
Every match with its groups: matchAll
matchAll returns an iterator that produces one full match array per match, each with its groups and index. It is
the method for reading many records out of one text, such as the lines of a log.
const log = `GET /api/trains/12951 200 38ms
POST /api/bookings 201 120ms
GET /api/pnr/4521 404 12ms
GET /api/trains/12002 200 41ms
POST /api/bookings 500 1503ms
GET /health 200 2ms`;
// One named group per field; m makes ^ and $ work per line, g lets matchAll find every line.
const LINE = /^(?<method>GET|POST) (?<path>\S+) (?<status>\d{3}) (?<ms>\d+)ms$/gm;
const perStatus = new Map();
const slow = [];
for (const { groups, index } of log.matchAll(LINE)) {
perStatus.set(groups.status, (perStatus.get(groups.status) ?? 0) + 1);
if (Number(groups.ms) >= 100) slow.push(`${groups.method} ${groups.path}, ${groups.ms} ms, at character ${index}`);
}
console.log([...perStatus].map(([status, n]) => `${status}: ${n}`).join(', '));
console.log(slow.join('\n'));
// matchAll works on a copy of the regex, so LINE.lastIndex is still 0 afterwards.
console.log('lastIndex', LINE.lastIndex);
// Without the g flag, matchAll refuses to start.
try {
log.matchAll(/GET/);
} catch (e) {
console.log(e.name);
} Output
200: 3, 201: 1, 404: 1, 500: 1 POST /api/bookings, 120 ms, at character 31 POST /api/bookings, 1503 ms, at character 118 lastIndex 0 TypeError
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node log-parser.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
The pattern has the m flag so that ^ and $ hold at each line, and the g flag because matchAll refuses to
start without it: it throws a TypeError rather than quietly return a single match. The loop destructures groups and index straight from each match. matchAll copies the regex
before it starts, so the lastIndex of LINE is still 0 afterwards and the same regex can be used again safely. The
iterator is lazy and finds the next match only when the loop asks for it; [...text.matchAll(re)] turns it into an
array when you need all of them at once.
Replacing text
replace returns a new string and leaves the original as it was. What it replaces depends on the pattern: a string
pattern replaces its first occurrence only, a regex without g its first match, and a regex with g every match.
replaceAll replaces every occurrence of a string, and with a regex it insists on the g flag.
const note = 'Platform 4 for train 12951, platform 4 again for train 12953';
// A string pattern replaces the first occurrence only; replaceAll replaces every one.
console.log(note.replace('4', 'four'));
console.log(note.replaceAll('4', 'four'));
// A regex without g replaces the first match; with g, every match.
console.log(note.replace(/platform/i, 'Track'));
console.log(note.replace(/platform/gi, 'Track'));
// In the replacement text, $& is the whole match and $<name> a named group.
console.log(note.replace(/\b\d{5}\b/g, '[$&]'));
console.log('Rao, Asha'.replace(/^(?<last>\w+), (?<first>\w+)$/, '$<first> $<last>'));
// A function receives the match and its groups and returns the replacement.
const camel = 'seat_number_and_coach'.replace(/_([a-z])/g, (match, letter) => letter.toUpperCase());
console.log(camel);
const masked = 'Account 123456789012, IFSC ABCD0123456'.replace(/\b\d{8,}\b/g, (digits) => '•'.repeat(digits.length - 4) + digits.slice(-4));
console.log(masked);
// replaceAll with a regex demands the g flag.
try {
note.replaceAll(/platform/i, 'Track');
} catch (e) {
console.log(e.name);
} Output
Platform four for train 12951, platform 4 again for train 12953 Platform four for train 12951, platform four again for train 12953 Track 4 for train 12951, platform 4 again for train 12953 Track 4 for train 12951, Track 4 again for train 12953 Platform 4 for train [12951], platform 4 again for train [12953] Asha Rao seatNumberAndCoach Account ••••••••9012, IFSC ABCD0123456 TypeError
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node replace.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
The replacement text may refer to the match:
| In the replacement | Inserts |
|---|---|
$& |
the whole match |
$1, $2 … |
the text of a numbered group |
$<name> |
the text of a named group |
$` and $' |
the text before and after the match |
$$ |
one dollar sign |
When the new text needs logic, pass a function instead of a string. It is called once per match with the matched
text, then the text of each numbered group, the position and the whole string (and an object of named groups, when
the pattern has any); whatever it returns is inserted. The example converts snake_case to camelCase that way and
masks an account number so that only its last four digits remain readable.
Splitting with a regex
split accepts a regex as the separator, which handles separators that vary, such as a comma or a semicolon with any
number of spaces around it.
// Split on a run of separators with optional spaces around them.
console.log(JSON.stringify('chai, samosa;vada pav , idli'.split(/\s*[,;]\s*/)));
// A capture group in the separator puts the separators into the result too.
console.log(JSON.stringify('12+30-5'.split(/([+-])/)));
// A separator at the start gives an empty first piece.
console.log(JSON.stringify(',a,,b'.split(/,/)));
// The second argument limits how many pieces come back.
console.log(JSON.stringify('a1b2c3d'.split(/\d/, 2))); Output
["chai","samosa","vada pav","idli"] ["12","+","30","-","5"] ["","a","","b"] ["a","b"]
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node split.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
A capture group in the separator puts the separators themselves into the result, between the pieces, which is a quick way to keep the operators of a sum. A separator at the very start produces an empty first piece, and two separators in a row produce an empty piece between them. The second argument caps the number of pieces returned.
The lastIndex trap
A regex with the g flag or the y flag is stateful. test and exec start reading at the regex’s lastIndex
property, set it to the end of the match when they find one, and put it back to 0 when they find none. That is what
lets a loop of exec calls walk through a text match by match. It is also a classic bug when one global regex is
shared and used with test on different strings.
Why a regex with the g flag answers differently on the next call
Text description of the diagram
The regex is const DIGITS = /^\d+$/g, and it is called four times with four six-digit codes. Each call is drawn as a row of six character boxes with an arrow under the position where the search starts.
- Call 1, DIGITS.test('411001'): the search starts at index 0, the whole code matches, the result is true, and lastIndex goes from 0 to 6.
- Call 2, DIGITS.test('560001'): the search starts at index 6, which is already the end of the new code, so the boxes are greyed out. Nothing matches, the result is false, and lastIndex goes back from 6 to 0.
- Call 3, DIGITS.test('110001'): the same as call 1: start at 0, true, lastIndex 0 to 6.
- Call 4, DIGITS.test('600001'): the same as call 2: start at 6, false, lastIndex 6 to 0.
The note at the bottom says that each call starts where the last match ended, and that a failed call puts lastIndex back to 0. Every code is valid, yet only every other call says so.
// The g flag makes a regex remember where its last match ended (lastIndex).
const DIGITS = /^\d+$/g;
for (const code of ['411001', '560001', '110001', '600001']) {
const result = DIGITS.test(code);
console.log(code, result, 'lastIndex is now', DIGITS.lastIndex);
}
// The same bug inside filter: half of the valid codes are dropped.
const codes = ['411001', '560001', '110001', '600001'];
console.log(codes.filter((c) => DIGITS.test(c)).length, 'of', codes.length, 'kept');
// Fix 1: a regex that is only used with test() needs no g flag.
const ALL_DIGITS = /^\d+$/;
console.log(codes.filter((c) => ALL_DIGITS.test(c)).length, 'of', codes.length, 'kept');
// Fix 2: when a g regex has to be reused, set lastIndex back to 0 before every call.
const again = codes.map((c) => {
DIGITS.lastIndex = 0;
return DIGITS.test(c);
});
console.log(again.join(' ')); Output
411001 true lastIndex is now 6 560001 false lastIndex is now 0 110001 true lastIndex is now 6 600001 false lastIndex is now 0 2 of 4 kept 4 of 4 kept true true true true
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node lastindex-trap.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
The second call starts at index 6, which is already the end of the next six-digit code, so it fails and resets
lastIndex to 0; the third call succeeds again. Inside filter, that drops half of the valid codes, and the bug
depends on what was checked before, which makes it hard to reproduce. The fixes, in order of preference:
- Leave out
gwhen a regex is only used withtestor for a singleexec. It brings nothing there. - Create the regex inside the function that uses it: a literal makes a new object, with
lastIndex0, every time it is evaluated. - When one global regex has to be reused, set
lastIndex = 0before each call. - Prefer
match,matchAll,replaceandsplitfor global work.matchandreplacestart from 0 and leavelastIndexat 0,matchAllworks on a copy, andsplitandsearchignore it.
The y flag uses the same property on purpose. A sticky regex matches only if the match starts exactly at
lastIndex, never further along, which is what a tokenizer needs: every character must belong to a token, and an
unknown one must stop it rather than be skipped.
// y (sticky): every match must start exactly at lastIndex, so nothing can be skipped.
const TOKEN = /\s*(?:(?<number>\d+(?:\.\d+)?)|(?<op>[-+*/()]))\s*/y;
function tokenize(source) {
const tokens = [];
TOKEN.lastIndex = 0;
while (TOKEN.lastIndex < source.length) {
const at = TOKEN.lastIndex;
const m = TOKEN.exec(source);
if (m === null) throw new SyntaxError(`unexpected ${JSON.stringify(source[at])} at position ${at}`);
tokens.push(m.groups.number ?? m.groups.op);
}
return tokens;
}
console.log(tokenize('12 + 3.5*(4 - 1)').join(' | '));
console.log(tokenize(' 7/2 ').join(' | '));
try {
tokenize('2 + x * 3');
} catch (e) {
console.log(`${e.name}: ${e.message}`);
}
// With g instead of y, exec searches on from lastIndex: it jumps over the x and finds the 3.
const searching = /\d+/g;
searching.lastIndex = 4;
console.log(searching.exec('2 + x * 3')[0], 'found at', searching.lastIndex - 1); Output
12 | + | 3.5 | * | ( | 4 | - | 1 | ) 7 | / | 2 SyntaxError: unexpected "x" at position 4 3 found at 8
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node tokenizer.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
Each exec call reads one token where the previous one ended, so the loop stops with a precise error at the x.
The last two lines show the difference the flag makes: with g, exec searches onward from lastIndex and silently
passes over the x to find the 3.
Exercise
Rupee amounts
The exercise reads amounts in Indian rupees: the sign ₹ or the abbreviation Rs, digits grouped in the Indian style (12,34,567) or the international one (1,234,567), and paise, the hundredth part of a rupee, after a full stop. It returns whole numbers of paise, so that no fraction is ever added up.
Exercise · Medium · JavaScript
Pull every rupee amount out of a message
Write extractAmounts(text) in amounts.mjs. It returns every rupee amount written in text, in the order they appear, as whole numbers of paise (100 paise make one rupee), so that no fractions are ever added up.
An amount is:
- a currency mark: the sign
₹, orRswith or without a full stop after it, in any case (Rs.,rs,RS); - optionally one space;
- the rupees: digits, which may be grouped with commas in the Indian style (
12,34,567) or the international style (1,234,567); - optionally a full stop and one or two digits of paise (
.5is 50 paise).
A number without a currency mark is not an amount, and neither is a currency mark without digits after it.
extractAmounts('Paid ₹1,234.50 to Asha and ₹99 to Ravi') → [123450, 9900]
extractAmounts('Refund of Rs. 12,34,567.5 approved') → [123456750]
extractAmounts('No amounts here, only 42 and ₹ alone') → []
Use one regex with the g flag and matchAll, with a named group for the rupees and one for the paise.
Starter code · amounts.mjs
/** Every rupee amount in the text, in paise (100 paise = 1 rupee), in the order they appear. */
export function extractAmounts(text) {
// Replace these lines with your code. This version stops reading each amount at its first comma.
const found = text.match(/₹\s?[\d,.]+/g) ?? [];
return found.map((amount) => Math.round(parseFloat(amount.slice(1)) * 100));
} The sample tests · amounts.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { extractAmounts } from './amounts.mjs';
test('the first example of the prompt', () => {
assert.deepEqual(extractAmounts('Paid ₹1,234.50 to Asha and ₹99 to Ravi'), [123450, 9900]);
});
test('Indian digit grouping and one digit of paise', () => {
assert.deepEqual(extractAmounts('Refund of Rs. 12,34,567.5 approved'), [123456750]);
});
test('Rs in any case, with or without a full stop and a space', () => {
assert.deepEqual(extractAmounts('rs 40 for tea, RS.15 for biscuits, Rs.7.25 for the bus'), [4000, 1500, 725]);
});
test('numbers without a currency mark, and a mark without digits, are not amounts', () => {
assert.deepEqual(extractAmounts('No amounts here, only 42 and ₹ alone'), []);
assert.deepEqual(extractAmounts(''), []);
});
test('the same result every time the function is called', () => {
const text = '₹10 now and ₹20 later';
assert.deepEqual(extractAmounts(text), [1000, 2000]);
assert.deepEqual(extractAmounts(text), [1000, 2000]);
}); A hint
Start the pattern with (?:₹|\bRs\.?)\s?: the sign, or Rs with an optional full stop, then an optional space; the i flag lets Rs match in any case. After it, put the rupees in a named group that allows a comma between runs of digits, and make the paise optional with (?:…)?. Take the commas out with replaceAll(',', '') before converting with Number; a group that did not match is undefined.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Check yourself
5 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
Interview questions
Why does calling test() twice on a regex with the g flag return true and then false? With the g or y flag a
regex keeps state in its lastIndex property. test and exec start reading at lastIndex, and after a match they
set it to the position where the match ended. The second call therefore starts part of the way into the next string,
or past its end, finds nothing, returns false and sets lastIndex back to 0, so a third call succeeds again. A
regex that is only used with test should not have the g flag. If a global regex must be shared, reset
lastIndex to 0 before each call, or create the regex inside the function that uses it. match, matchAll,
replace and split do not have this problem.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
A Node.js script reads a 700 MB access log with readFileSync(file, 'utf8') and collects every URL with text.match(/https?:\/\/\S+/g). It crashes before matching anything, and on a 300 MB log it uses gigabytes of memory. Explain both failures and rewrite the approach so that memory stays flat.
-
Fresher to mid
An import feature splits each line of a CSV file with line.split(','), and a row such as Asha,"Pune, MH",42 comes out with four fields instead of three. A teammate proposes a cleverer regex for split. Explain the bug, whether a regex can fix it, and what you would ship.
Key takeaways
- Write a regex as a literal when the pattern is fixed, and with
new RegExp(string, flags)when it is built at run time; in a string, the pattern’s backslashes are written twice. - Flags change the whole match:
ifor case,mfor line anchors,sfor dots across lines,uorvfor Unicode,dfor group positions, andgoryfor searching on fromlastIndex. - Anchor a validation pattern with
^and$, and remember that a regex checks a shape, not that the value exists. execandmatchwithoutggive the first match with its groups;matchwithggives only texts;matchAllgives every match with its groups.replacewithg, orreplaceAll, changes every match;$&,$1and$<name>reuse the match, and a function can compute each replacement.- A shared regex with
gorycarrieslastIndexfrom call to call: leave the flag out fortest, create the regex where it is used, or resetlastIndex.
References
- ECMAScript 2026 Language Specification: RegExp (Regular Expression) Objects (Ecma International)
- ECMAScript 2026 Language Specification: Regular Expression Literals, Evaluation (Ecma International)
- ECMAScript 2026 Language Specification: RegExp.prototype.exec (Ecma International)
- ECMAScript 2026 Language Specification: RegExpBuiltinExec (lastIndex) (Ecma International)
- ECMAScript 2026 Language Specification: get RegExp.prototype.flags (Ecma International)
- ECMAScript 2026 Language Specification: String.prototype.matchAll (Ecma International)
- ECMAScript 2026 Language Specification: GetSubstitution (replacement patterns) (Ecma International)
- ECMAScript 2026 Language Specification: RegExp.prototype [ %Symbol.split% ] (Ecma International)
- Regular expressions (JavaScript Guide) (MDN Web Docs)
- RegExp.prototype.lastIndex (MDN Web Docs)
- Frequently Asked Questions on NEFT (What is Indian Financial System Code?) (Reserve Bank of India)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress