Regex #
Regular expressions (regex) are a mini-language for describing patterns in text — one of the most powerful and most easily misused tools in programming. TypeScript inherits regex from JavaScript in full, but adds type safety to all regex-related methods: the return types of match(), exec(), replace(), and the like are all precisely defined, so the compiler can catch mistakes like accessing a capture group that doesn’t exist. Understanding regex in TypeScript means understanding not just pattern syntax, but also how each method interacts with the g flag, the difference between RegExpMatchArray and RegExpExecArray, and the lastIndex trap that often causes subtle bugs.
Creating Regex — Literal vs Constructor #
There are two ways to create a regex in TypeScript:
// 1. Regex literal — the pattern is compiled at parse time
const polaLiteral = /\d+/;
const polaLiteralFlag = /hello/gi;
// 2. RegExp constructor — the pattern is compiled at runtime
// Useful when the pattern comes from dynamic input (variables, database, config)
const polaDinamis = new RegExp("\\d+"); // \\ because it's inside a string
const polaFlag = new RegExp("hello", "gi"); // Flags as the second argument
// When to use the constructor: patterns from variables
function buatValidatorPola(pola: string, flag = ""): RegExp {
return new RegExp(pola, flag);
}
const validatorKustom = buatValidatorPola("^\\+62\\d{9,12}$");
// NOTE: In RegExp strings, backslashes must be double-escaped
// /\d/ in a literal = new RegExp("\\d") in the constructor
// This is often a source of bugs when converting between the two
Regex Methods — Complete with Return Types #
TypeScript defines precise return types for all regex methods. Understanding these return types is important for avoiding runtime errors:
RegExp.prototype.test() — Boolean Checking
#
const regexAngka = /^\d+$/;
// test() always returns a boolean — simplest and safest
console.log(regexAngka.test("12345")); // true
console.log(regexAngka.test("abc")); // false
console.log(regexAngka.test("12a34")); // false
// Use test() for validation — no match result needed
function isAngkaValid(input: string): boolean {
return /^\d{1,10}$/.test(input);
}
RegExp.prototype.exec() — Matching with Details
#
const regexTanggal = /(\d{4})-(\d{2})-(\d{2})/;
const teks = "Tanggal lahir: 1995-08-17";
const hasil = regexTanggal.exec(teks);
// Type: RegExpExecArray | null
if (hasil !== null) {
console.log(hasil[0]); // "1995-08-17" — full match
console.log(hasil[1]); // "1995" — group 1
console.log(hasil[2]); // "08" — group 2
console.log(hasil[3]); // "17" — group 3
console.log(hasil.index); // 15 — match position in the string
console.log(hasil.input); // The original searched text
}
String.prototype.match() — Depends on the g Flag
#
The return type of match() differs depending on whether the regex uses the g flag or not — this is one of the most surprising behaviors:
const teks = "Harga: 1000 dan 2500 dan 500";
// WITHOUT the g flag — returns RegExpMatchArray | null (same as exec)
const tanpaG = teks.match(/\d+/);
// Type: RegExpMatchArray | null
if (tanpaG) {
console.log(tanpaG[0]); // "1000" — only the first match
console.log(tanpaG.index); // 7 — position available
}
// WITH the g flag — returns string[] | null (all matches, no details)
const denganG = teks.match(/\d+/g);
// Type: string[] | null — index and groups are NOT available!
if (denganG) {
console.log(denganG); // ["1000", "2500", "500"] — all matches
// denganG.index; // ✗ Property 'index' doesn't exist on string[]
}
String.prototype.matchAll() — All Matches with Details (ES2020)
#
matchAll() combines the advantages of both — all matches with group details:
const logEntri = `
[2025-05-07] ERROR: Koneksi gagal
[2025-05-08] INFO: Server dimulai
[2025-05-09] WARN: Memori rendah
`;
// The regex MUST use the g flag for matchAll
const regexLog = /\[(\d{4}-\d{2}-\d{2})\] (\w+): (.+)/g;
// matchAll() returns an IterableIterator<RegExpMatchArray>
for (const cocok of logEntri.matchAll(regexLog)) {
const [, tanggal, level, pesan] = cocok;
// All groups available, plus cocok.index
console.log(`[${tanggal}] ${level}: ${pesan.trim()}`);
}
// Or convert to an array
const semuaCocok = [...logEntri.matchAll(regexLog)];
const entri = semuaCocok.map(([, tanggal, level, pesan]) => ({
tanggal,
level,
pesan: pesan.trim(),
}));
// Type: Array<{ tanggal: string; level: string; pesan: string }>
String.prototype.replace() and replaceAll()
#
const teks = "harga: 1000, diskon: 200, total: 800";
// replace() with a string — only replaces the first match
const ganti1 = teks.replace(/\d+/, "XXX");
// "harga: XXX, diskon: 200, total: 800"
// replace() with the g flag — replaces all matches
const gantiSemua = teks.replace(/\d+/g, "XXX");
// "harga: XXX, diskon: XXX, total: XXX"
// replace() with a function — dynamic transformation
const formatRupiah = teks.replace(/\d+/g, (angka) => {
return `Rp ${parseInt(angka).toLocaleString("id-ID")}`;
});
// "harga: Rp 1.000, diskon: Rp 200, total: Rp 800"
// replace() with group references — $1, $2, etc.
const formatTanggal = "2025-05-07".replace(/(\d{4})-(\d{2})-(\d{2})/, "$3/$2/$1");
// "07/05/2025"
// replaceAll() (ES2021) — without needing the g flag
const gantiSemua2 = teks.replaceAll("000", "K");
// "harga: 1K, diskon: 200, total: 800"
String.prototype.split() with Regex
#
// split() can use a regex as the delimiter
const kalimat = "Halo dunia, TypeScript itu keren";
// Split by one or more spaces/commas/dots
const kata = kalimat.split(/[\s,]+/);
// ["Halo", "dunia", "TypeScript", "itu", "keren"]
// Split while keeping the delimiter (use a capturing group)
const bagian = "satu1dua2tiga".split(/(\d)/);
// ["satu", "1", "dua", "2", "tiga"] — numbers remain as elements
All Regex Flags #
| Flag | Name | Function |
|---|---|---|
g | global | Find all matches, not just the first |
i | case-insensitive | Ignore letter case |
m | multiline | ^ and $ match at the start/end of every line |
s | dotAll | . matches newline characters too |
u | unicode | Enable full Unicode mode (including emoji) |
v | unicodeSets | More complete Unicode mode (ES2024) |
d | indices | Include index information for every group |
y | sticky | Only match from the lastIndex position |
// Flag g — global: find all matches
const semuaAngka = "abc123def456".match(/\d+/g);
// ["123", "456"]
// Flag i — case-insensitive
/hello/i.test("HELLO WORLD"); // true
// Flag m — multiline: ^ and $ match per line
const teksMultibaris = "baris pertama\nbaris kedua";
const cocokPerBaris = teksMultibaris.match(/^baris/gm);
// ["baris", "baris"] — both match
// Flag s — dotAll: the dot matches newlines
const teksNewline = "awal\nakhir";
/awal.akhir/.test(teksNewline); // false — the dot doesn't match newlines by default
/awal.akhir/s.test(teksNewline); // true — with the s flag
// Flag u — unicode: needed for emoji and non-BMP characters
/^\p{Emoji}$/u.test("😊"); // true — checks if it's an emoji (needs the u flag and \p{})
// Flag d — indices: include start-end positions of each group (ES2022)
const regexD = /(\d+)/d;
const hasilD = regexD.exec("abc123");
if (hasilD?.indices) {
console.log(hasilD.indices[0]); // [3, 6] — full match position
console.log(hasilD.indices[1]); // [3, 6] — group 1 position
}
Special Characters and Quantifiers #
Special Characters (Metacharacters) #
// Metacharacters — characters with special meaning in regex
// . \d \w \s \b \D \W \S \B ^ $ [] () {} | ? * +
// \d = digit (0-9)
/\d/.test("5"); // true
/\d/.test("a"); // false
// \D = not a digit
/\D/.test("a"); // true
// \w = word character (a-z, A-Z, 0-9, _)
/\w/.test("a"); // true
/\w/.test("!"); // false
// \s = whitespace (space, tab, newline)
/\s/.test(" "); // true
/\s/.test("\t"); // true
// \b = word boundary — the position between \w and \W
/\bkata\b/.test("ini kata bukan"); // true — "kata" stands alone
/\bkata\b/.test("katakata"); // false — no word boundary
// ^ = start of string (or start of line with the m flag)
/^halo/.test("halo dunia"); // true
/^halo/.test("hai halo"); // false
// $ = end of string (or end of line with the m flag)
/dunia$/.test("halo dunia"); // true
/dunia$/.test("dunia baru"); // false
Quantifiers #
// Quantifiers control how many times the previous element may appear
// * = 0 or more
// + = 1 or more
// ? = 0 or 1 (optional)
// {n} = exactly n times
// {n,} = at least n times
// {n,m} = between n and m times
/\d*/.test(""); // true — 0 or more digits
/\d+/.test(""); // false — at least 1 digit
/\d?/.test(""); // true — 0 or 1 digit
/\d{3}/.test("123"); // true — exactly 3 digits
/\d{2,4}/.test("12"); // true — between 2-4 digits
/\d{2,4}/.test("12345"); // true — matches 4 digits at the start
// Greedy vs Lazy — greedy takes as much as possible, lazy as little as possible
const html = "<b>tebal</b> dan <i>miring</i>";
const greedy = html.match(/<.+>/); // Greedy — matches "<b>tebal</b> dan <i>miring</i>"
const lazy = html.match(/<.+?>/); // Lazy (?) — matches "<b>" only
console.log(greedy?.[0]); // "<b>tebal</b> dan <i>miring</i>"
console.log(lazy?.[0]); // "<b>"
Capturing Groups and Named Groups #
Capturing Groups ()
#
// Capturing group — capture part of a match
const regexJam = /(\d{2}):(\d{2})(?::(\d{2}))?/;
const hasilJam = regexJam.exec("waktu: 14:30:45");
if (hasilJam) {
const [keseluruhan, jam, menit, detik] = hasilJam;
console.log(`Jam: ${jam}, Menit: ${menit}, Detik: ${detik ?? "00"}`);
// "Jam: 14, Menit: 30, Detik: 45"
}
// Non-capturing group (?:) — grouping without capturing
// Useful for grouping without needing the result
const regexNonCapture = /(?:https?|ftp):\/\/(\w+\.\w+)/;
const hasilURL = regexNonCapture.exec("kunjungi https://contoh.com");
if (hasilURL) {
console.log(hasilURL[0]); // "https://contoh.com" — full match
console.log(hasilURL[1]); // "contoh.com" — only the domain (group 1)
// hasilURL[2] doesn't exist because (?:) doesn't capture
}
Named Capturing Groups (?<name>)
#
Named groups give descriptive names to capture groups — easier to read and no need to count indices:
// Named groups — access via groups.name, not numeric indices
const regexNIK = /^(?<provinsi>\d{2})(?<kotaKab>\d{2})(?<kecamatan>\d{2})(?<tglLahir>\d{6})(?<urutan>\d{4})$/;
const nik = "3171011708950001";
const hasilNIK = regexNIK.exec(nik);
if (hasilNIK?.groups) {
const { provinsi, kotaKab, kecamatan, tglLahir, urutan } = hasilNIK.groups;
console.log(`Provinsi: ${provinsi}`); // "31" (Jakarta)
console.log(`Kota/Kab: ${kotaKab}`); // "71" (South Jakarta)
console.log(`Kecamatan: ${kecamatan}`); // "01"
console.log(`Tgl Lahir: ${tglLahir}`); // "170895" (August 17, 1995)
}
// Named groups in replace — use $<name>
const tgl = "2025-05-07";
const formatBaru = tgl.replace(
/(?<tahun>\d{4})-(?<bulan>\d{2})-(?<hari>\d{2})/,
"$<hari>/$<bulan>/$<tahun>"
);
console.log(formatBaru); // "07/05/2025"
Lookahead and Lookbehind #
Lookahead and lookbehind are zero-width assertions — they check context without consuming characters:
// Positive lookahead (?=...) — matches if followed by a pattern
const hargaDenganRupiah = "Rp 1000 dan 2000 IDR dan 3000".match(/\d+(?= IDR)/g);
// ["2000"] — only numbers followed by " IDR"
// Negative lookahead (?!...) — matches if NOT followed by a pattern
const angkaTanpaIDR = "Rp 1000 dan 2000 IDR dan 3000".match(/\d+(?! IDR)/g);
// ["1000", "200", "3000"] — numbers not followed by " IDR"
// (note "200" because "2000" contains "200" that isn't followed by " IDR")
// Positive lookbehind (?<=...) — matches if preceded by a pattern
const hargaSetelahRp = "Rp 1000 dan 2000 IDR".match(/(?<=Rp )\d+/g);
// ["1000"] — only numbers preceded by "Rp "
// Negative lookbehind (?<!...) — matches if NOT preceded by a pattern
const angkaTanpaRp = "Rp 1000 dan 2000 IDR".match(/(?<!Rp )\d+/g);
// ["2000"] — numbers not preceded by "Rp "
The g Flag and lastIndex Trap
#
This is one of the most dangerous bug sources with regex in TypeScript/JavaScript:
// ANTI-PATTERN: A regex with the g flag stored in a reused variable
const regexGlobal = /\d+/g;
// First time works
console.log(regexGlobal.test("abc123")); // true
console.log(regexGlobal.lastIndex); // 6 — the regex "remembers" its position!
// Second time with the SAME input — fails!
console.log(regexGlobal.test("abc123")); // false — because lastIndex=6, searching from position 6
console.log(regexGlobal.lastIndex); // 0 — resets after failure
// Third time — works again
console.log(regexGlobal.test("abc123")); // true — starts from 0 again
// SOLUTION 1: Create a new regex every time it's used
function validasiAngka(input: string): boolean {
return /\d+/g.test(input); // A new literal on every call
}
// SOLUTION 2: Reset lastIndex manually
function validasiDenganReset(regex: RegExp, input: string): boolean {
regex.lastIndex = 0;
return regex.test(input);
}
// SOLUTION 3: Use a regex WITHOUT the g flag for test()
// test() doesn't need the g flag — the g flag is only needed to find all matches
const regexTanpaG = /\d+/; // No lastIndex problem
console.log(regexTanpaG.test("abc123")); // true — always
console.log(regexTanpaG.test("abc123")); // true — no state
Common Validation Patterns for Indonesia #
// Email validation (simple but useful for most cases)
const REGEX_EMAIL = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
console.log(REGEX_EMAIL.test("[email protected]")); // true
console.log(REGEX_EMAIL.test("[email protected]")); // false
// Indonesian phone numbers
const REGEX_TELEPON_ID = /^(\+62|62|0)[0-9]{8,12}$/;
console.log(REGEX_TELEPON_ID.test("08123456789")); // true
console.log(REGEX_TELEPON_ID.test("+62812345678")); // true
console.log(REGEX_TELEPON_ID.test("628123456789")); // true
console.log(REGEX_TELEPON_ID.test("1234567")); // false
// NIK (Indonesian national ID number) — 16 digits
const REGEX_NIK = /^\d{16}$/;
console.log(REGEX_NIK.test("3171011708950001")); // true
// NPWP — format xx.xxx.xxx.x-xxx.xxx
const REGEX_NPWP = /^\d{2}\.\d{3}\.\d{3}\.\d-\d{3}\.\d{3}$/;
console.log(REGEX_NPWP.test("12.345.678.9-012.345")); // true
// Indonesian postal codes — 5 digits
const REGEX_KODE_POS = /^\d{5}$/;
console.log(REGEX_KODE_POS.test("12345")); // true
// Simple URL
const REGEX_URL = /^https?:\/\/([\w-]+\.)+[\w-]+(\/[\w-./?%&=]*)?$/;
console.log(REGEX_URL.test("https://typescript.unisbadri.com/basic/regex/")); // true
// Strong password (min 8 chars, has uppercase, lowercase, number, symbol)
const REGEX_PASSWORD_KUAT = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/;
console.log(REGEX_PASSWORD_KUAT.test("P@ssword1")); // true
console.log(REGEX_PASSWORD_KUAT.test("password")); // false
// URL slug
const REGEX_SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
console.log(REGEX_SLUG.test("belajar-typescript-dasar")); // true
console.log(REGEX_SLUG.test("Belajar TypeScript")); // false
Regex Pattern Anatomy #
flowchart TD
A["Regex Pattern"] --> B["Characters"]
A --> C["Quantifiers"]
A --> D["Anchors"]
A --> E["Groups"]
A --> F["Lookaround"]
B --> B1["Literal: abc or 123"]
B --> B2["Metacharacters"]
B --> B3["Character classes"]
B --> B4["Negated character classes"]
C --> C1["Star = 0 or more"]
C --> C2["Plus = 1 or more"]
C --> C3["Question = 0 or 1"]
C --> C4["Character count ranges"]
C --> C5["Lazy quantifiers"]
D --> D1["Start of string or line"]
D --> D2["End of string or line"]
D --> D3["Word boundaries"]
E --> E1["Capturing groups"]
E --> E2["Named groups"]
E --> E3["Non-capturing groups"]
E --> E4["Alternation"]
F --> F1["Lookahead"]
F --> F2["Negative lookahead"]
F --> F3["Lookbehind"]
F --> F4["Negative lookbehind"]Summary #
- Regex literals
/pola/are compiled at parse time and more efficient; usenew RegExp(string)only when the pattern is dynamic (from variables or user input).test()for pattern-existence checks (returns a boolean) — simplest and has nolastIndexproblem if used without thegflag.match()without thegflag returnsRegExpMatchArray | nullwith group details; with thegflag it returnsstring[] | nullwithout group details.matchAll()(ES2020) gives all matches with group details at once — the best choice for finding all matches with access to captured groups.- The
lastIndextrap: a regex with thegflag stores its last position in thelastIndexproperty; callingtest()orexec()repeatedly on the same input can produce different results — always create a new regex or resetlastIndex = 0before reuse.- Named capturing groups
(?<name>pola)are far easier to read and maintain than numeric-indexed groups; access viahasil.groups?.name.- Greedy vs lazy: by default quantifiers are greedy (
+,*) and take as much as possible — add?after a quantifier (+?,*?) for lazy matching that takes as little as possible.- Lookahead and lookbehind are zero-width assertions that check context without consuming characters — very useful for conditional patterns like “numbers preceded by Rp” or “words not followed by a period”.
- Always test regex with valid, invalid, and edge cases before using in production — tools like regex101.com are very helpful for debugging complex patterns.