I6 · Part 2 · Intermediate
Regex & dates
Two toolboxes every real app ends up reaching for, and the traps that come with each.
04On this page
Regex — the essentials
/abc/ // literal — matches "abc" exactly
/abc/i // flag: i = case-insensitive
/abc/g // flag: g = find ALL matches, not just the first
/(\w+)@(\w+)/ // ( ) = a capturing group — grabbed separately from the full match
const text = "contact: ana@example.com today";
const match = text.match(/(\w+)@(\w+)\.com/);
console.log(match[0]); // the whole match — what happens?
console.log(match[1]); // group 1 — what happens?
console.log(match[2]); // group 2 — what happens?
"ana@example.com", then "ana", then
"example" — the full match is always index 0, and every
parenthesized group after it fills in one more slot, in order.
// Named groups — same idea, readable by name instead of position
const parsed = "2024-01".match(/(?<year>\d{4})-(?<month>\d{2})/);
parsed.groups.year; // "2024"
parsed.groups.month; // "01"
"2024-01-15".replace(/(\d+)-(\d+)-(\d+)/, "$3/$2/$1"); // "15/01/2024" — $1/$2/$3 refer back to the groups
[..."a1 b22 c333".matchAll(/[a-z](\d+)/g)].map((m) => m[1]); // ["1", "22", "333"] — every match, not just the first
⚠ A /g regex remembers where it left off
.test() and .exec() on a regex literal
with the g flag mutate the regex object's own
lastIndex — the next call resumes searching from there,
not from the start of the string.
const stateful = /\d/g;
console.log(stateful.test("a1")); // what happens?
console.log(stateful.test("a1")); // SAME regex, same string — what happens?
console.log(stateful.test("a1")); // what happens?
Dry run: three .test() calls on the same /g regex object
| Call | lastIndex before | Search starts at | Result | lastIndex after |
|---|---|---|---|---|
1st stateful.test("a1") | 0 | index 0 | true — "1" found at index 1 | 2 (end of match) |
2nd stateful.test("a1") | 2 | index 2 — past the end of a 2-character string | false — nothing left to search | reset to 0 |
3rd stateful.test("a1") | 0 | index 0 again | true — same match as the 1st call | 2 |
true, false, true — alternating,
on the exact same input. First call finds the digit and leaves
lastIndex at 2; second call starts
searching from index 2 in a 2-character string, finds
nothing, and resets lastIndex back to 0;
third call starts over and finds it again. Reusing one global-flagged
regex object across unrelated calls is exactly how this bites — a
fresh /\d/g literal each time, or dropping the
g flag for a one-shot .test(), avoids it.
Lookahead, lookbehind, and the ReDoS trap
/\d+(?=px)/.exec("width: 240px")[0]; // "240" — lookahead: match digits, but only if "px" follows (not consumed)
/(?<=\$)\d+/.exec("Price: $99")[0]; // "99" — lookbehind: only if "$" precedes, also not consumed
Neither assertion becomes part of the match itself — that's the whole point of calling them assertions rather than groups. They let a pattern check context ("only if followed/preceded by X") without including that context in what gets extracted or replaced.
⚠ Some patterns can take exponential time — ReDoS
A pattern with nested, overlapping repetition —
/(a+)+b/ against a long string of just as
with no trailing b — forces the engine to try an
exponential number of ways to split the string among the repeated
groups before giving up. Run against user-supplied input (a "validate
this email" regex is the classic real-world case), this is a genuine
denial-of-service vector — Regular expression Denial of Service,
ReDoS — that has taken down real production servers with a single
crafted string. The fix is rewriting the pattern to remove the nested
ambiguity, or running untrusted-input matching behind a timeout.
The sticky flag — y vs g
const g = /\d+/g;
const y = /\d+/y;
const str = "12 34";
g.exec(str)[0]; // "12" — g can skip ahead to find the next match anywhere
y.lastIndex = 0;
y.exec(str)[0]; // "12" — same result here...
y.lastIndex = 1;
y.exec(str); // what happens?
null — y demands a match starting
exactly at lastIndex, nowhere later in the string;
g is happy to scan forward and find the next match
wherever it is. This matters for a hand-written tokenizer or parser,
where "match right here or fail" is the entire point — scanning ahead
silently would mean accepting a token that isn't actually adjacent to
where the parser currently sits.
Dates
const d = new Date(2024, 0, 15); // year, MONTH (0-indexed!), day
console.log(d.getMonth()); // what happens?
console.log(d.getDate()); // what happens?
0, then 15 — getMonth() is
January-is-0, a decision baked into
Date since the original Java date API it was modeled on
in 1995, and never fixed since without breaking every existing
script.
const start = new Date("2024-01-15");
const end = new Date("2024-02-15");
(end - start) / 86_400_000; // 31 — subtracting Dates gives milliseconds; divide to get days
⚠ The two constructors don't parse in the same time zone
new Date(2024, 0, 15) (year, month, day as separate
numbers) builds midnight in the local time zone.
new Date("2024-01-15") (a plain ISO date string, no time)
is parsed as midnight UTC. In UTC+5:30, that ISO string's
local clock time is 05:30, not midnight — a different
instant from the numeric-args version, even though both print "15
January" if you only look at the date part. The subtraction above
only comes out to a clean 31 because both operands went
through the same UTC-parsing string form — mix a numeric-args
Date with a string-parsed one and the day count can be
off by one, depending on which side of midnight UTC the reader's
time zone happens to sit.
⚠ Why almost nobody hand-rolls date math
Time zones, daylight saving transitions, leap years, and leap
seconds all make "add one day" genuinely harder than
+ 86400000 — a DST boundary can make that arithmetic
land on the wrong calendar day entirely. This is the real reason
libraries like date-fns exist, and why the language
now has Temporal: not laziness, a correctness problem
that's easy to get subtly wrong by hand.
Temporal.PlainDate.from("2024-01-31").add({ months: 1 }).toString();
// "2024-02-29" — calendar-aware: no milliseconds, no time zone, no DST surprise
Temporal is the built-in successor to Date. It
ships in Firefox (139+) and Chrome (144+); check support for the rest
of your audience, or load a polyfill, before relying on it.
new Intl.DateTimeFormat("en-IN", { dateStyle: "long" }).format(d);
// "15 January 2024" — locale-correct formatting, no manual string building
new Intl.NumberFormat("en-IN", { style: "currency", currency: "INR" }).format(150000);
// "₹1,50,000.00" — Indian digit grouping, handled for you
The next chapter picks up where this one leaves off — storage, the URL bar, and the event system in depth.
Interview answer, one sentence
"Regex is for patterns, not for parsing nested structure, and nested quantifiers can backtrack catastrophically (ReDoS); store dates as UTC timestamps and format them for display, because arithmetic on local time breaks across daylight-saving changes."
Before you move on
- Write a regex with a named capture group and pull a value out by that name, not by index.
- Explain what makes a regex vulnerable to catastrophic backtracking, and rewrite one to avoid it.
- Explain the difference between the sticky (y) and global (g) flags, and when a sticky match fails where a global one would keep going.
- Store and do arithmetic on a date as a UTC timestamp, and explain why local-time arithmetic breaks across a DST change.
- Format a date and a number for a specific locale using Intl, without hand-building the string.
Opens in the editor: write it, run it, and check it against real tests.
All 33 for this level →Finished reading?
0 of 41 chapters read in this series