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 #

FlagNameFunction
gglobalFind all matches, not just the first
icase-insensitiveIgnore letter case
mmultiline^ and $ match at the start/end of every line
sdotAll. matches newline characters too
uunicodeEnable full Unicode mode (including emoji)
vunicodeSetsMore complete Unicode mode (ES2024)
dindicesInclude index information for every group
ystickyOnly 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; use new RegExp(string) only when the pattern is dynamic (from variables or user input).
  • test() for pattern-existence checks (returns a boolean) — simplest and has no lastIndex problem if used without the g flag.
  • match() without the g flag returns RegExpMatchArray | null with group details; with the g flag it returns string[] | null without 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 lastIndex trap: a regex with the g flag stores its last position in the lastIndex property; calling test() or exec() repeatedly on the same input can produce different results — always create a new regex or reset lastIndex = 0 before reuse.
  • Named capturing groups (?<name>pola) are far easier to read and maintain than numeric-indexed groups; access via hasil.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.

← Previous: Date & Time   Next: Regex Identifier →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact