../

Strings

Text in TypeScript, ordered for DS&A work first: UTF-16 code units and code points, indexing and slicing, the String methods, searching and comparing, building strings and what each operation costs, and the idioms string problems are built from; then regular expressions, Unicode, Intl formatting and the string types TypeScript adds on top. Examples are checked with TypeScript 7.0 and 6.0 under strict and run on Node 24 LTS, Node 26 and Bun 1.4 (ES2025 built-ins). Numbers parsed out of text are in Numbers, arrays of characters in Arrays, and the Python side in Strings.

Literals & basics

SyntaxNotes
'single', "double"identical; pick one and let the formatter enforce it
`a ${expr} b`template literal: interpolation and real newlines
tag`a ${x}` tagged template: calls tag(strings, ...values)
String.raw`C:\new` backslashes stay literal
"\n", "\t", "\\"newline, tab, backslash
"\x41", "\u00e9", "\u{1F600}"A by hex, é as one code unit, any code point (braces form)
String(x), `${x}`convert; String(null) is "null"; only String accepts a symbol
x.toString()throws on null and undefined

string is an immutable sequence of UTF-16 code units. Every method returns a new string, and s[0] = "j" is a type error (TS2542) that also throws a TypeError at runtime in strict code, which includes every module and class body. There is no char type: s[0] is a string of length 1. To edit characters, spread the string into an array, change the array, and join("") it back.

const s = "hello";
s.length; // 5: UTF-16 code units
s.includes("ell"); // true
s.repeat(2); // "hellohello"
s < "help"; // true: compares code units
String(42); // "42"
["a", "b", "c"].join(", "); // "a, b, c"
[1, 2].join(", "); // "1, 2": join converts items
 
const chars = [...s]; // ["h", "e", "l", "l", "o"]
chars[0] = "j";
chars.join(""); // "jello"
 
// @ts-expect-error: TS2542, strings are read-only
s[0] = "j"; // TypeError at runtime in a module
const esc = (s: string) =>
  s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
 
function html(
  strings: TemplateStringsArray,
  ...values: unknown[]
): string {
  return strings.reduce((out, s, i) => {
    const v =
      i < values.length ? esc(String(values[i])) : "";
    return out + s + v;
  }, "");
}
 
html`<p>${"<b>"}</p>`; // "<p>&#60;b&#62;</p>"
String.raw`C:\new\t${1 + 1}`; // "C:\\new\\t2"

A tag receives one more string than values, and strings.raw holds the text before escapes are processed.

Characters & code points

ExpressionResultNotes
s.charCodeAt(i)0 to 65535one UTF-16 code unit; NaN out of range, never undefined
s.codePointAt(i)number | undefinedthe whole code point when i starts a surrogate pair
s.charCodeAt(i) - 970 to 25 for a to zthe slot in a 26-entry count array ("a" is 97)
String.fromCharCode(97 + k)the k-th lowercase letterfrom code units
String.fromCodePoint(0x1f600)"😀"from code points; RangeError above 0x10ffff
s.charCodeAt(i) - 480 to 9 for "0" to "9"ASCII digits only ("0" is 48)
ch >= "a" && ch <= "z"ASCII lowercase test/\p{Ll}/u.test(ch) also accepts "é" and "ß"
/^[a-z0-9]$/i.test(ch)ASCII letter or digitwhat "alphanumeric" means in most problem statements
/\s/.test(ch)whitespacealso matches "\u00a0" (no-break space) and other Unicode spaces
ch.toUpperCase()case mappingcan change the length: "ß".toUpperCase() is "SS"
[...s], for (const ch of s)code pointss.split("") splits code units and breaks emoji

There are no isDigit or isAlpha methods: compare code units, or test a one-character regex. The u (or v) flag unlocks Unicode property escapes such as \p{L} (any letter) and \p{Nd} (any decimal digit).

const s = "leetcode";
const counts = new Int32Array(26);
for (let i = 0; i < s.length; i++) {
  counts[s.charCodeAt(i) - 97]++;
}
 
let firstUnique = "";
for (const ch of s) {
  if (counts[ch.charCodeAt(0) - 97] === 1) {
    firstUnique = ch;
    break;
  }
}
firstUnique; // "l"
 
const present = [...counts.keys()]
  .filter((k) => counts[k] > 0)
  .map((k) => String.fromCharCode(97 + k));
// ["c", "d", "e", "l", "o", "t"]
 
const caesar = (s: string, k: number) =>
  [...s]
    .map((c) => ((c.charCodeAt(0) - 97 + k) % 26) + 97)
    .map((code) => String.fromCharCode(code))
    .join("");
caesar("xyz", 3); // "abc"

length, indexing and charCodeAt count UTF-16 code units. A character outside the Basic Multilingual Plane (most emoji) takes two, a surrogate pair, and every index-based method can split it.

const s = "a😀b";
s.length; // 4: the emoji is two code units
s[1]; // "\ud83d": half of a surrogate pair
s.charCodeAt(1); // 55357
s.codePointAt(1); // 128512
s.codePointAt(2); // 56832: the low half alone
[...s]; // ["a", "😀", "b"]
s.slice(0, 2).isWellFormed(); // false
code points code units index a U+0061 😀 U+1F600 b U+0062 0x0061 0 0xD83D 1 0xDE00 2 0x0062 3 s.length === 4: four UTF-16 code units [...s].length === 3: three code points U+1F600 is above U+FFFF, so it takes a surrogate pair s[1] === "\ud83d": half of the pair s.charCodeAt(1) === 55357 (0xD83D) indexing, charAt and charCodeAt read one code unit s.codePointAt(1) === 128512 (0x1F600): reads both s.codePointAt(2) === 56832 (0xDE00): the low half only an index that starts a pair gives the code point [...s] is ["a", "😀", "b"] for...of and Array.from(s) also step by code point s.split("") splits code units: 4 items cut s.slice(0, 2) keeps a lone high surrogate s.slice(0, 2).isWellFormed() === false encodeURIComponent would throw a URIError

1/5

s = "a😀b" holds 3 code points but 4 UTF-16 code units: U+1F600 is stored as the surrogate pair 0xD83D, 0xDE00. s.length counts code units, so it is 4.

Indexing & slicing

Indexes name the positions between code units: slice(start, end) takes the units from cut start up to cut end, which is excluded. There are no negative indexes with brackets: s[-1] is the property named "-1", which is undefined.

Expression"python" givesNotes
s[0], s.at(-1)"p", "n"at counts negatives from the end
s[10], s.at(10), s.charAt(10)undefined, undefined, ""out of range never throws
s.slice(1, 4)"yth"end excluded
s.slice(-3)"hon"negatives count from the end
s.slice(4, 1)""start after end: empty
s.substring(4, 1)"yth"swaps the arguments
s.substring(-3)"python"negatives become 0
s.substr(1, 3)"yth"(start, length); legacy Annex B, use slice
s.slice(10)""out-of-range bounds clamp
[...s].reverse().join("")"nohtyp"by code point, so emoji survive
const s = "python";
s.slice(1, 4); // "yth"
s.slice(-3); // "hon"
s.slice(4, 1); // ""
s.substring(4, 1); // "yth"
s.substring(-3); // "python"
s.slice(10); // ""
s[10]; // undefined
[...s].filter((_, i) => i % 2 === 0).join(""); // "pto"
0 -6 1 -5 2 -4 3 -3 4 -2 5 -1 6 cut from end s = start end s.slice(1, 4) === "yth" end is excluded start -3 = 3 end s.slice(-3) === "hon" negatives count from the end start end s.slice(4, 1) === "" start after end: nothing is selected start (was end) end (was start) s.substring(4, 1) === "yth" substring swaps its arguments -3 becomes 0 end s.substring(-3) === "python" substring treats negatives as 0 start 10 clamps to 6 s.slice(10) === "" s[10] is undefined; nothing throws p y t h o n

1/6

Indexes name the cuts between code units. s.slice(1, 4) starts at cut 1 and stops at cut 4, so it is "yth".

Each slice is a new string value. V8 makes a slice of 13 or more code units in O(1) by pointing into the original (a sliced string) and copies shorter ones; either way, comparing or hashing a slice costs O(length), and a short slice can keep a huge parent string alive. In loops, compare in place:

const s = "hello world";
s.startsWith("world", 6); // true: no slice built
s.endsWith("lo", 5); // true: looks at s.slice(0, 5)
s.slice(6, 11) === "world"; // true, after building a slice
 
function isPal(s: string, i: number, j: number): boolean {
  while (i < j) {
    if (s.charCodeAt(i) !== s.charCodeAt(j)) return false;
    i++;
    j--;
  }
  return true;
}
isPal("xracecarx", 1, 7); // true

Methods

MethodDoesExample → result
split(sep, limit?)split on a string or regex"a,b,,c".split(",") → ["a", "b", "", "c"]
split(/\s+/)split on runs of whitespace" a b ".trim().split(/\s+/) → ["a", "b"]
split(/(\d)/)capture groups are kept"a1b".split(/(\d)/) → ["a", "1", "b"]
arr.join(sep)inverse of split["a", "b"].join("-") → "a-b"
trim(), trimStart(), trimEnd()whitespace and line terminatorsno "strip these characters" argument: s.replace(/^x+|x+$/g, "")
padStart(n, fill), padEnd(n, fill)pad to a total length n"7".padStart(3, "0") → "007"
repeat(n)n copies"ab".repeat(3) → "ababab"; RangeError if negative
indexOf(t, from?), lastIndexOf(t, from?)index or -1"banana".indexOf("an", 2) → 3
includes(t), startsWith(t, i), endsWith(t, end)booleanincludes(/re/) throws a TypeError
search(re)index of the first regex match or -1"ab12".search(/\d/) → 2
match(re), matchAll(re)regex matchessee Regular expressions
replace(p, r)first match for a string pattern, all with a g regex"a-b-c".replace("-", "+") → "a+b-c"
replaceAll(p, r)every matcha regex without g throws a TypeError
toUpperCase(), toLowerCase()case mapping"Straße".toUpperCase() → "STRASSE"
toLocaleLowerCase(loc)locale rules"I".toLocaleLowerCase("tr") → "ı"
normalize(form)Unicode normalization"é".normalize("NFD").length → 2
localeCompare(t, loc?, opts?)negative, 0 or positivesee Searching & comparing
at, charAt, charCodeAt, codePointAtread one positionsee Characters & code points
isWellFormed(), toWellFormed()lone surrogates"a\ud83d".toWellFormed() → "a\ufffd"
concat(...strs)join strings+ and templates are the usual way

There is no count: s.split(t).length - 1 counts non-overlapping occurrences, and a lookahead regex counts overlapping ones. "".split(",") is [""], not [].

const line = "id, name ,  email";
line.split(",").map((c) => c.trim());
// ["id", "name", "email"]
" a  b ".trim().split(/\s+/); // ["a", "b"]
"a,b,,c".split(","); // ["a", "b", "", "c"]
"".split(","); // [""]
"a😀".split("").length; // 3: code units
"7".padStart(3, "0"); // "007"
"a-b-c".replace("-", "+"); // "a+b-c": first only
"a-b-c".replaceAll("-", "+"); // "a+b+c"
"Straße".toUpperCase(); // "STRASSE": longer
 
const [user, domain = ""] = "ada@example.com".split("@");
domain; // "example.com"
"photo.final.jpg".split(".").at(-1); // "jpg"

Searching & comparing

ExpressionReturnsNotes
s.includes(t)booleansubstring test
s.indexOf(t, from)index or -1from searches from an offset without slicing
s.lastIndexOf(t, from)index or -1searches backward, starting at from
s.startsWith(t, i)booleanis t at offset i
s.split(t).length - 1countnon-overlapping
a === bbooleanby value, one code unit at a time
a < bbooleanlexicographic by UTF-16 code unit: "Z" < "a", "apple" < "apples", "10" < "9"
[...s].sort().join("")sorted charactersthe default sort compares code units as well
a.localeCompare(b)numberlocale order, for text shown to people

includes, indexOf and split ran in linear time on Node 24 and Bun even for the adversarial search of "aa…ab" in a million "a"s. V8 picks a linear scan for short patterns and switches to Boyer–Moore–Horspool, then Boyer–Moore, for longer ones (src/strings/string-search.h). Write KMP or a rolling hash (see Recipes) when you need every overlapping match, or compare many substrings of one length.

"banana".indexOf("an"); // 1
"banana".lastIndexOf("an"); // 3
"banana".indexOf("an", 2); // 3: searching from index 2
"banana".split("ana").length - 1; // 1: non-overlapping
[..."banana".matchAll(/(?=ana)/g)].length; // 2: overlapping
 
["b", "a", "B"].sort(); // ["B", "a", "b"]: code units
["b", "a", "B"].sort((x, y) => x.localeCompare(y));
// ["a", "b", "B"]
 
// < compares code units, not code points: the emoji's
// high surrogate 0xd83d sorts before U+FF5E
"😀" < "\uff5e"; // true

Python compares strings by code point, so "😀" < "\uff5e" is False there: sorts of text with emoji or other astral characters can differ between the two languages.

const collator = new Intl.Collator("en", {
  numeric: true, // "file2" before "file10"
  sensitivity: "base", // ignore case and accents
});
["file10", "File2", "file1"].sort(collator.compare);
// ["file1", "File2", "file10"]
collator.compare("Résumé", "resume"); // 0
 
// Alien dictionary: is words sorted in this alphabet?
const order = "hlabcdefgijkmnopqrstuvwxyz";
const rank = new Int32Array(26);
for (let i = 0; i < 26; i++) {
  rank[order.charCodeAt(i) - 97] = i;
}
const alien = (a: string, b: string): number => {
  const n = Math.min(a.length, b.length);
  for (let i = 0; i < n; i++) {
    const d =
      rank[a.charCodeAt(i) - 97] -
      rank[b.charCodeAt(i) - 97];
    if (d !== 0) return d;
  }
  return a.length - b.length;
};
const words = ["hello", "leetcode"];
words.every(
  (w, i) => i === 0 || alien(words[i - 1], w) <= 0,
); // true
sensitivityTreats as different
"base"only different letters (a vs b)
"accent"letters and accents
"case"letters and case
"variant"everything (the default for sorting)

localeCompare with options builds a collator on each call; create one Intl.Collator for a large sort and pass its compare.

Building strings & costs

Strings are immutable, so s += piece produces a new string each time. In V8 that is cheap: a result of 13 or more code units is a rope (a cons string) whose two halves point at the old string and the piece, so a loop of += runs in O(n) total. JavaScriptCore, under Bun and Safari, also builds ropes. The bill arrives when you read: the first index, comparison or regex on a rope flattens it into one buffer, O(n).

let s = "";
for (let i = 0; i < 3; i++) s += `<${i}>`;
s; // "<0><1><2>"
 
const parts: string[] = [];
for (const w of ["a", "b"]) parts.push(w.toUpperCase());
parts.join(""); // "AB"
 
// Remove adjacent duplicates with a stack of characters:
// reading a string after each += flattens it every time
function dedupe(s: string): string {
  const st: string[] = [];
  for (const ch of s) {
    if (st.at(-1) === ch) st.pop();
    else st.push(ch);
  }
  return st.join("");
}
dedupe("abbaca"); // "ca"
"abcdefg" (7) "hijklm" (6) "abcdefg" (7) "hijklm" (6) cons 13 cons 13 cons 13 "nop" (3) "nop" (3) cons 16 cons 16 s = a + b s += "nop" 13+ code units: a node, not a copy (O(1)) each += adds a node: n appends cost O(n) a b c d e f g h i j k l m n o p flat flatten: O(n), once s.charCodeAt(5) reads "f" s.slice(2, 15) === "cdefghijklmno": a sliced string, offset 2 and length 13 into the flat buffer, no copy append, read, append, read: every read flattens again Node 24, 80,000 rounds: 211 ms, vs 1.6 ms with a char array

1/5

s = a + b, with a = "abcdefg" and b = "hijklm", has 13 code units, so V8 doesn't copy: it makes a cons string, a node pointing at both halves. (Two literals joined directly are folded into one flat string at compile time.)

Measured on Node 24: building a million-character string with += and with push plus join took about the same time (15 to 80 ms); the first charCodeAt on the += result then took about 6 ms for the flattening, and the next one microseconds. Bun showed the same pattern.

OperationCostNotes
s.length, s[i], s.charCodeAt(i)O(1)on a flat string; the first read of a rope flattens it, O(n) once
s + t, s += tO(1) for 13+ code units in V8 (a rope node)shorter results are copied; engine detail
s.slice(i, j)O(1) for 13+ code units in V8, else O(j − i)comparing or hashing it is O(j − i)
parts.join("")O(total length)one flat result
s === tO(n)O(1) when the lengths differ
includes, indexOf, split, replaceO(n) in practicesee Searching & comparing
[...s], Array.from(s)O(n)one string per code point
[...s].sort().join("")O(n log n)a 26-slot count is O(n)
a string as a Map or Set keyO(n) to hash the first timeV8 caches the hash on the string
String.fromCharCode(...codes)O(n)spreading more than about 125,000 arguments throws a RangeError on Node; map then join

V8 stores a string at one byte per code unit when every unit is at most U+00FF (Latin-1, so "é" fits) and two bytes otherwise. Its longest string is 2²⁹ − 24 = 536,870,888 code units; one more throws RangeError: Invalid string length. Bun accepted "x".repeat(2 ** 31 - 1).

DS&A idioms

The string moves that problem solutions are built from, and the trap in each. The patterns, with TypeScript, JavaScript and Python side by side, are in the DS&A sheets (linked in the last column).

JobTypeScriptWatch outMore
Char ↔ slots.charCodeAt(i) - 97, String.fromCharCode(97 + k)assumes lowercase ASCII: read the constraintsStrings
Count charactersconst cnt = new Int32Array(26); cnt[s.charCodeAt(i) - 97]++other alphabets: a Map<string, number>Counters
Anagram key[...w].sort().join("") (O(k log k)) or cnt.join(",") (O(k))an array is a bad Map key: equal arrays are different keysGroup anagrams
Edit charactersconst cs = [...s]; cs[i] = c; cs.join("")s[i] = c is a type error and throws in strict code
Build output+=, or parts.push(p) then parts.join("")reading the string between appends re-flattens it: O(n²)Costs
Reverse[...s].reverse().join(""); words: s.trim().split(/\s+/).reverse().join(" ")s.split(" ") keeps empty strings between repeated spaces
Palindrometwo pointers comparing s.charCodeAt(i) and s.charCodeAt(j)a reversed copy costs O(n) memoryTwo pointers
Match at an offsets.startsWith(w, i)s.slice(i, i + w.length) === w builds a slice firstWord Break
Longest windowlast.set(ch, i); on a repeat, lo = prev + 1 when prev >= lostore indexes, not substringsSliding window
Window with countsneed counts plus a missing totalupdate the total as characters enter and leave instead of comparing count arraysMinimum window
Custom alphabeta rank table, then a comparatorsort() with no comparator compares code unitsCustom order
Digits and binaryNumber(s), parseInt(s, 2), n.toString(2).padStart(k, "0")parseInt("12px") is 12; Number("") is 0Numbers
Tokenizeexpr.match(/\d+|[-+*/()]/g) ?? []match returns null, not [], when nothing matchesStacks
Bracketsa string[] stack and a Record<string, string> of pairscheck the stack is empty at the endBalanced brackets
Memo over a stringkey the memo on index i (or i * (n + 1) + j)a key like s.slice(i) hashes O(n) code units per lookupTop-down DP
Many same-length substringsa Set of slices, or a rolling hashkeep every product below 2⁵³Rolling hash
Prefix lookupsa trie of nested MapsstartsWith against every word is O(words × k)Tries
Counts and sets of charactersMap, Set, a last-index MapMaps & sets
function longestUnique(s: string): number {
  const last = new Map<string, number>();
  let lo = 0;
  let best = 0;
  for (let i = 0; i < s.length; i++) {
    const ch = s.charAt(i);
    const prev = last.get(ch) ?? -1;
    if (prev >= lo) lo = prev + 1; // jump past the repeat
    last.set(ch, i);
    best = Math.max(best, i - lo + 1);
  }
  return best;
}
longestUnique("abcabcbb"); // 3
 
function groupAnagrams(words: string[]): string[][] {
  const groups = new Map<string, string[]>();
  for (const w of words) {
    const cnt = new Int32Array(26);
    for (let i = 0; i < w.length; i++) {
      cnt[w.charCodeAt(i) - 97]++;
    }
    const key = cnt.join(","); // a string compares by value
    const group = groups.get(key);
    if (group) group.push(w);
    else groups.set(key, [w]);
  }
  return [...groups.values()];
}
groupAnagrams(["eat", "tea", "tan", "ate", "nat"]);
// [["eat", "tea", "ate"], ["tan", "nat"]]
 
function wordBreak(s: string, words: string[]): boolean {
  // ok[i]: s.slice(0, i) splits into words
  const ok = new Uint8Array(s.length + 1);
  ok[0] = 1;
  for (let i = 0; i < s.length; i++) {
    if (!ok[i]) continue;
    for (const w of words) {
      // startsWith at an offset: no slice is built
      if (s.startsWith(w, i)) ok[i + w.length] = 1;
    }
  }
  return ok[s.length] === 1;
}
wordBreak("leetcode", ["leet", "code"]); // true
0 1 2 3 4 5 6 7 s = lo i last = Map(1) {"a" => 0} window = s.slice(0, 1) = "a" best = 1 lo i last = Map(2) {"a" => 0, "b" => 1} window = s.slice(0, 2) = "ab" best = 2 lo i last = Map(3) {"a" => 0, "b" => 1, "c" => 2} window = s.slice(0, 3) = "abc" best = 3 lo i lo jumps last = Map(3) {"a" => 3, "b" => 1, "c" => 2} window = s.slice(1, 4) = "bca" best = 3 lo i lo jumps last = Map(3) {"a" => 3, "b" => 4, "c" => 2} window = s.slice(2, 5) = "cab" best = 3 lo i lo jumps last = Map(3) {"a" => 3, "b" => 4, "c" => 5} window = s.slice(3, 6) = "abc" best = 3 lo i lo jumps last = Map(3) {"a" => 3, "b" => 6, "c" => 5} window = s.slice(5, 7) = "cb" best = 3 lo i lo jumps last = Map(3) {"a" => 3, "b" => 7, "c" => 5} window = s.slice(7, 8) = "b" best = 3 a b c a b c b b

1/8

i = 0: "a" is new, so the window is s.slice(0, 1) = "a" and best is 1.

a b c d e f g h i j k l m n o p q r s t u v w x y z slot anagram 3 0 0 0 0 0 1 0 0 0 0 0 1 1 0 0 0 1 0 0 0 0 0 0 0 0 nagaram 3 0 0 0 0 0 1 0 0 0 0 0 1 1 0 0 0 1 0 0 0 0 0 0 0 0 cnt[w.charCodeAt(i) - 97]++ ("a" is 97, so "a" is slot 0) key: "3,0,0,0,0,0,1,0,0,0,0,0,1,1,0,0,0,1,0,0,0,0,0,0,0,0" key: "3,0,0,0,0,0,1,0,0,0,0,0,1,1,0,0,0,1,0,0,0,0,0,0,0,0" equal keys: anagrams (O(k) per word, no sort)
Slot charCodeAt(i) - 97 counts each letter: the arrays are equal, so their join keys are equal and the words are anagrams

Regular expressions

FlagNameEffect
gglobalfind all matches; enables lastIndex
iignoreCasecase-insensitive
mmultiline^ and $ match at every line break
sdotAll. also matches newlines
uunicodecode-point matching, \u{…}, \p{…} property escapes
vunicodeSetsu plus set operations --, && and \q{…} (ES2024)
ystickymatch only at lastIndex (tokenizers)
dhasIndicesadds indices (start and end) for each group
SyntaxMeaning
(?<year>\d{4})named group, read as m.groups.year
\k<year>, \1backreference inside the pattern
(?:…)non-capturing group
x(?=y), x(?!y)lookahead, negative lookahead
(?<=y)x, (?<!y)xlookbehind, negative lookbehind (any width)
\p{L}, \p{Lu}, \p{Nd}any letter, uppercase letter, decimal digit (u or v)
a*?, a+?lazy quantifiers
CallReturns
re.test(s)boolean; with g or y it moves lastIndex
re.exec(s)RegExpExecArray | null: the next match with index and groups
s.match(re)without g: the first match and its groups; with g: every matched string, or null
s.matchAll(re)iterator of matches with index and groups; the regex needs g
s.replace(re, r), s.replaceAll(re, r)new string; r may be a function
s.split(re)pieces; capture groups are kept
RegExp.escape(s)s with every regex syntax character escaped (ES2025)
const date = /(?<y>\d{4})-(?<m>\d{2})-(?<d>\d{2})/d;
const r = date.exec("on 2026-09-25");
r?.groups?.y; // "2026"
r?.indices?.groups?.m; // [8, 10]
 
const log = "GET /a 200, POST /b 404";
log.match(/\d{3}/g); // ["200", "404"]
const hits = [...log.matchAll(/([A-Z]+) (\S+) (\d+)/g)];
hits.map((m) => `${m[1]}:${m.index}`);
// ["GET:0", "POST:12"]
 
/\d+(?=px)/.exec("12px 3em")?.[0]; // "12"
/(?<=\$)\d+/.exec("cost $40")?.[0]; // "40"
/[\p{L}--[a-z]]/v.test("B"); // true: a letter, not a-z
 
const re = /a/g;
re.test("a"); // true
re.test("a"); // false: lastIndex was 1
 
const safe = RegExp.escape("1+1=2?"); // ES2025
new RegExp(`^${safe}$`).test("1+1=2?"); // true

These patterns work in the replacement string of replace and replaceAll:

PatternInserts
$$a literal dollar sign
$&the whole match
$`, $'the text before, after the match
$1 … $99a numbered capture group
$<name>a named capture group
"2026-09-25".replace(/(\d+)-(\d+)-(\d+)/, "$3/$2/$1");
// "25/09/2026"
"price: $5".replace("$5", "$$10"); // "price: $10"
 
// A function replacer gets (match, ...groups, offset, input)
"a-b_c d".replace(/[-_ ](\w)/g, (_, c: string) =>
  c.toUpperCase(),
); // "aBCD"

A function replacer never interprets $ patterns, so it is the safe choice when the replacement comes from user input.

GoalPattern
digits only/^\d+$/
integer with a sign/^[+-]?\d+$/
collapse whitespaces.replace(/\s+/g, " ").trim()
hex color/^#(?:[0-9a-f]{3}){1,2}$/i
ISO date YYYY-MM-DD/^\d{4}-\d{2}-\d{2}$/
slug/^[a-z0-9]+(?:-[a-z0-9]+)*$/
cat or dog as a word/\b(?:cat|dog)\b/
tokens of an expression/\d+|[-+*/()]/g
all Unicode letters/^\p{L}+$/u

For real validation of emails, URLs and dates prefer URL.canParse, Intl or a schema library such as Zod over regexes.

Unicode

length, indexing and slice count UTF-16 code units; [...s] and for...of count code points; people see graphemes, which Intl.Segmenter finds.

String.length[...s].length (code points)Graphemes
"a"111
"é" (NFC, "\u00e9")111
"é" (NFD, "e\u0301")221
"😀"211
"👍🏽" (thumbs up, skin tone)421
"👨‍👩‍👧" (family)85 (3 people, 2 joiners)1
const seg = new Intl.Segmenter("en", {
  granularity: "grapheme", // also "word", "sentence"
});
const graphemes = (s: string) =>
  Array.from(seg.segment(s), (x) => x.segment);
 
graphemes("👨‍👩‍👧!").length; // 2
const reverse = (s: string) =>
  graphemes(s).toReversed().join(""); // emoji-safe
reverse("a👍🏽b") === "b👍🏽a"; // true
 
const words = Array.from(
  new Intl.Segmenter("en", { granularity: "word" })
    .segment("Hi, you!"),
)
  .filter((x) => x.isWordLike)
  .map((x) => x.segment);
// ["Hi", "you"]
 
const nfc: string = "\u00e9";
const nfd: string = "e\u0301";
nfc === nfd; // false: same look, different code units
nfc === nfd.normalize("NFC"); // true
"a\ud83d".isWellFormed(); // false: a lone surrogate
TaskTool
Same text, different code units ("\u00e9" vs "e\u0301")s.normalize("NFC") before comparing, hashing or storing
Compatibility folding ("fi" → "fi")s.normalize("NFKC") (lossy)
Strip accentss.normalize("NFD").replace(/\p{M}/gu, "")
Case-insensitive compareIntl.Collator with sensitivity: "base", or toLowerCase() on both for ASCII
Check or repair lone surrogatess.isWellFormed(), s.toWellFormed() (ES2024)
User-perceived charactersIntl.Segmenter with granularity: "grapheme"

s.slice(0, n) can cut a surrogate pair and leave a lone surrogate, which makes encodeURIComponent throw a URIError. Truncate by graphemes, or call toWellFormed() first.

Formatting with Intl

ConstructorFormatsExample output
Intl.NumberFormatnumbers, currency, units, percent$1,234.50, 1.2M, 50 km/h
Intl.DateTimeFormatdates and times in a time zoneSep 25, 2026, 10:30 AM
Intl.RelativeTimeFormatrelative timeyesterday, in 3 weeks
Intl.ListFormatlistsa, b, and c, tea or coffee
Intl.PluralRulespicks a plural category"one", "few", "other"
Intl.Collatorcomparison for sortingsee Searching & comparing
Intl.Segmentergraphemes, words, sentencessee Unicode
const usd = new Intl.NumberFormat("en-US", {
  style: "currency",
  currency: "USD",
});
usd.format(1234.5); // "$1,234.50"
 
const compact = new Intl.NumberFormat("en", {
  notation: "compact",
});
compact.format(1_234_567); // "1.2M"
 
const d = new Date(Date.UTC(2026, 8, 25, 14, 30));
new Intl.DateTimeFormat("en-US", {
  dateStyle: "medium",
  timeStyle: "short",
  timeZone: "America/New_York",
}).format(d); // "Sep 25, 2026, 10:30 AM"
 
const rtf = new Intl.RelativeTimeFormat("en", {
  numeric: "auto",
});
rtf.format(-1, "day"); // "yesterday"
 
new Intl.ListFormat("en", { type: "disjunction" })
  .format(["tea", "coffee"]); // "tea or coffee"
 
const ordinal = new Intl.PluralRules("en", {
  type: "ordinal",
});
const suffix: Record<Intl.LDMLPluralRule, string> = {
  zero: "th", one: "st", two: "nd",
  few: "rd", many: "th", other: "th",
};
const nth = (n: number) =>
  `${n}${suffix[ordinal.select(n)]}`;
[1, 22, 11].map(nth); // ["1st", "22nd", "11th"]

String types in TS

TypeAccepts
stringany string
"GET"exactly "GET" (a literal type)
"GET" | "POST"a union of literals
`${number}px`"12px", "1.5px", not "px"
`on${Capitalize<E>}`derived names such as "onClick"
Uppercase<S>, Lowercase<S>, Capitalize<S>, Uncapitalize<S>intrinsic case changes on literal types
"sm" | "lg" | (string & {})any string, but editors still suggest the literals
string & { readonly __brand: "Email" }branded: a string that must pass a check first
Stringthe wrapper object type; annotate with string instead
type Method = "GET" | "POST";
let m: Method = "GET";
 
// @ts-expect-error: "PUT" is not a Method
m = "PUT";
 
const verb = "GET"; // type "GET": const infers the literal
let v2 = "GET"; // type string: let widens
 
type Px = `${number}px`;
const w: Px = "12px";
 
// @ts-expect-error: "12em" does not match `${number}px`
const bad: Px = "12em";
 
type UiEvent = "click" | "focus";
type Handler = `on${Capitalize<UiEvent>}`;
// "onClick" | "onFocus"
 
type Axis = "x" | "y";
type Prop = `${Axis}${"Min" | "Max"}`;
// "xMin" | "xMax" | "yMin" | "yMax"

Template literal types can also parse strings with infer, and remap object keys:

type Param<S extends string> =
  S extends `${string}:${infer P}/${infer Rest}`
    ? P | Param<`/${Rest}`>
    : S extends `${string}:${infer P}`
      ? P
      : never;
 
type P = Param<"/users/:id/posts/:postId">;
// "id" | "postId"
 
type Getters<T> = {
  [K in keyof T & string as `get${Capitalize<K>}`]:
    () => T[K];
};
type G = Getters<{ name: string; age: number }>;
// { getName: () => string; getAge: () => number }

Indexing under noUncheckedIndexedAccess

With the noUncheckedIndexedAccess compiler option, s[i] has type string | undefined, and so do reads from string[] and typed arrays such as Int32Array. charAt(i) ("" out of range) and charCodeAt(i) (NaN) always return their plain types, which is why the loops on this sheet use them. Arithmetic on a typed array slot such as cnt[k]++ needs cnt[k]! + 1 or an explicit check under the option.

const s = "abc";
const a = s[5]; // string | undefined with the flag
const b = s.charAt(5); // always string: ""
const c = s.charCodeAt(5); // always number: NaN
[a, b, c]; // [undefined, "", NaN]

Branded strings

A brand makes a validated string incompatible with a plain string at compile time; at runtime it is still a string.

type Email = string & { readonly __brand: "Email" };
 
function toEmail(s: string): Email {
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s)) {
    throw new Error(`Invalid email: ${s}`);
  }
  return s as Email;
}
 
function send(to: Email): string {
  return to.toLowerCase(); // string methods still work
}
 
send(toEmail("Ada@example.com")); // "ada@example.com"
// @ts-expect-error: a plain string is not an Email
send("ada@example.com");

Zod produces the same shape with z.email().brand<"Email">(). More type tools are in Fundamentals and Objects.

Coming from Python

Python strTypeScript string
len(s) counts code points: len("😀") is 1s.length counts UTF-16 code units: "😀".length is 2; [...s].length counts code points
s[i], s[-1]; IndexError out of ranges[i], s.at(-1); undefined out of range
ord(ch), chr(n)ch.codePointAt(0), String.fromCodePoint(n); charCodeAt for code units
s[a:b], s[-3:]s.slice(a, b), s.slice(-3)
s[::-1][...s].reverse().join("")
s[::2][...s].filter((_, i) => i % 2 === 0).join("")
t in ss.includes(t)
s.find(t), s.rfind(t)s.indexOf(t), s.lastIndexOf(t)
s.index(t) raises ValueErrorno equivalent: check indexOf for -1
s.count(t)s.split(t).length - 1
s.split() (runs of whitespace)s.trim().split(/\s+/), but "".trim().split(/\s+/) is [""]
list(s)[...s] (code points) or s.split("") (code units)
"".join(xs)xs.join("")
s.strip(), s.lstrip(), s.rstrip()s.trim(), s.trimStart(), s.trimEnd()
s.strip("x")s.replace(/^x+|x+$/g, "")
s.replace(a, b) replaces every matchs.replaceAll(a, b); s.replace(a, b) replaces the first
s.upper(), s.lower(), s.casefold()toUpperCase(), toLowerCase(); Intl.Collator for comparisons
s.rjust(5, "0"), s.ljust(5), s.zfill(5)s.padStart(5, "0"), s.padEnd(5)
s * 3s.repeat(3)
s.isdigit(), s.isalpha()/^\d+$/.test(s), /^\p{L}+$/u.test(s)
int(s) raises on "12px"Number("12px") is NaN; parseInt("12px", 10) is 12
f"{a} and {b}"`${a} and ${b}`
sorted(s)[...s].sort(); then join("") for a string
Counter(s)Int32Array(26) for a to z, or a Map<string, number>
re.findall(p, s)s.match(/p/g) ?? []; matchAll for groups
unicodedata.normalize("NFC", s)s.normalize("NFC")
strings compare by code pointstrings compare by code unit: astral characters sort before U+E000 to U+FFFF

Recipes

Palindromes

When checking a phrase while ignoring case and punctuation, or finding the longest palindromic substring.

const isAlnum = (c: number) =>
  (c >= 48 && c <= 57) || // 0-9
  (c >= 65 && c <= 90) || // A-Z
  (c >= 97 && c <= 122); // a-z
const lower = (c: number) =>
  c >= 65 && c <= 90 ? c + 32 : c; // A-Z to a-z
 
function isPalindrome(s: string): boolean {
  let i = 0;
  let j = s.length - 1;
  while (i < j) {
    const a = s.charCodeAt(i);
    const b = s.charCodeAt(j);
    if (!isAlnum(a)) i++;
    else if (!isAlnum(b)) j--;
    else if (lower(a) !== lower(b)) return false;
    else {
      i++;
      j--;
    }
  }
  return true;
}
 
function longestPalindrome(s: string): string {
  let bestLo = 0;
  let bestHi = 0; // [bestLo, bestHi) is the best so far
  for (let center = 0; center < 2 * s.length - 1; center++) {
    let lo = center >> 1;
    let hi = lo + (center & 1); // odd length: hi === lo
    while (lo >= 0 && hi < s.length && s[lo] === s[hi]) {
      lo--;
      hi++;
    }
    if (hi - lo - 1 > bestHi - bestLo) {
      bestLo = lo + 1;
      bestHi = hi;
    }
  }
  return s.slice(bestLo, bestHi);
}
 
isPalindrome("A man, a plan, a canal: Panama"); // true
longestPalindrome("babad"); // "bab"
longestPalindrome("cbbd"); // "bb"

Expanding around all 2n − 1 centers is O(n²) time and O(1) space; Manacher's algorithm does it in O(n).

Anagram checks and windows

When checking two words, or finding every anagram of p inside s with a fixed-size window.

function isAnagram(a: string, b: string): boolean {
  if (a.length !== b.length) return false;
  const cnt = new Int32Array(26);
  for (let i = 0; i < a.length; i++) {
    cnt[a.charCodeAt(i) - 97]++;
    cnt[b.charCodeAt(i) - 97]--;
  }
  return cnt.every((x) => x === 0);
}
 
function anagramStarts(s: string, p: string): number[] {
  const k = p.length;
  const diff = new Int32Array(26); // window count - p count
  let off = 0; // slots where diff is not 0
  const bump = (code: number, by: number) => {
    const slot = code - 97;
    if (diff[slot] === 0) off++;
    diff[slot] += by;
    if (diff[slot] === 0) off--;
  };
  for (let i = 0; i < k; i++) bump(p.charCodeAt(i), -1);
  const out: number[] = [];
  for (let i = 0; i < s.length; i++) {
    bump(s.charCodeAt(i), 1); // entering the window
    if (i >= k) bump(s.charCodeAt(i - k), -1); // leaving
    if (i >= k - 1 && off === 0) out.push(i - k + 1);
  }
  return out;
}
 
isAnagram("listen", "silent"); // true
anagramStarts("cbaebabacd", "abc"); // [0, 6]

Counting slots that differ makes each step O(1); comparing two 26-slot arrays each step would be O(26) instead.

Minimum window substring

When finding the shortest part of s that contains every character of t, duplicates included.

function minWindow(s: string, t: string): string {
  const need = new Map<string, number>();
  for (const ch of t) need.set(ch, (need.get(ch) ?? 0) + 1);
  let missing = t.length; // characters still to cover
  let bestLo = 0;
  let bestLen = Infinity;
  let lo = 0;
  for (let hi = 0; hi < s.length; hi++) {
    const ch = s.charAt(hi);
    const n = need.get(ch);
    if (n !== undefined) {
      if (n > 0) missing--;
      need.set(ch, n - 1); // negative: a surplus
    }
    while (missing === 0) {
      if (hi - lo + 1 < bestLen) {
        bestLo = lo;
        bestLen = hi - lo + 1;
      }
      const out = s.charAt(lo++);
      const m = need.get(out);
      if (m !== undefined) {
        need.set(out, m + 1);
        if (m + 1 > 0) missing++;
      }
    }
  }
  return bestLen === Infinity
    ? ""
    : s.slice(bestLo, bestLo + bestLen);
}
 
minWindow("ADOBECODEBANC", "ABC"); // "BANC"
minWindow("a", "aa"); // ""

Is subsequence

When s must appear in t in order, not necessarily contiguously.

function isSubsequence(s: string, t: string): boolean {
  let i = 0;
  for (let j = 0; j < t.length && i < s.length; j++) {
    if (s.charCodeAt(i) === t.charCodeAt(j)) i++;
  }
  return i === s.length;
}
 
isSubsequence("ace", "abcde"); // true
isSubsequence("aec", "abcde"); // false

One pass over t, O(len(t)). For many s against one t, store each character's positions and binary search into them.

Longest common prefix

When finding the shared start of several strings.

function commonPrefix(words: string[]): string {
  if (words.length === 0) return "";
  let lo = words[0];
  let hi = words[0];
  for (const w of words) {
    if (w < lo) lo = w;
    if (w > hi) hi = w;
  }
  let i = 0;
  while (i < lo.length && lo[i] === hi[i]) i++;
  return lo.slice(0, i);
}
 
commonPrefix(["flower", "flow", "flight"]); // "fl"
commonPrefix(["dog", "racecar"]); // ""

The common prefix of the smallest and the largest string (in code-unit order) is shared by every string between them, so one pass finds both and one comparison loop finishes.

Every match: KMP

When you need every occurrence of a pattern, overlaps included, in O(n + m).

function prefixFunction(p: string): number[] {
  const pi = new Array<number>(p.length).fill(0);
  let k = 0;
  for (let i = 1; i < p.length; i++) {
    // on a mismatch, fall back to a shorter border
    while (k > 0 && p[i] !== p[k]) k = pi[k - 1];
    if (p[i] === p[k]) k++;
    pi[i] = k;
  }
  return pi;
}
 
function findAll(text: string, p: string): number[] {
  if (p === "") {
    return [...Array(text.length + 1).keys()];
  }
  const pi = prefixFunction(p);
  const out: number[] = [];
  let k = 0;
  for (let i = 0; i < text.length; i++) {
    while (k > 0 && text[i] !== p[k]) k = pi[k - 1];
    if (text[i] === p[k]) k++;
    if (k === p.length) {
      out.push(i - k + 1);
      k = pi[k - 1]; // keep going: overlaps count
    }
  }
  return out;
}
 
prefixFunction("abab"); // [0, 0, 1, 2]
prefixFunction("ababaca"); // [0, 0, 1, 2, 3, 0, 1]
findAll("aaaa", "aa"); // [0, 1, 2]

pi[i] is the length of the longest proper prefix of p.slice(0, i + 1) that is also its suffix. The same table answers "shortest repeating unit": n - pi[n - 1], when it divides n.

i p = pi = 0 1 2 3 4 5 6 a b a b a c a 0 pi[0] = 0: no proper prefix of "a" is also its suffix a b a b a c a 0 0 pi[1] = 0: no proper prefix of "ab" is also its suffix a b a b a c a 0 0 1 pi[2] = 1: "a" is a prefix and a suffix of "aba" a b a b a c a 0 0 1 2 pi[3] = 2: "ab" is a prefix and a suffix of "abab" a b a b a c a 0 0 1 2 3 pi[4] = 3: "aba" is a prefix and a suffix of "ababa" a b a b a c a 0 0 1 2 3 0 pi[5] = 0: no proper prefix of "ababac" is also its suffix a b a b a c a 0 0 1 2 3 0 1 pi[6] = 1: "a" is a prefix and a suffix of "ababaca" prefix same text as a suffix

1/7

pi[0] is always 0: a single character has no proper prefix.

Rolling hash (Rabin–Karp)

When comparing every substring of length k: repeated DNA sequences, or longest duplicate substring with a binary search on k.

function repeated(s: string, k: number): string[] {
  if (k <= 0 || k > s.length) return [];
  // Every product stays below 2 ** 53, so it stays exact
  const B = 131;
  const M = 1_000_000_007;
  let top = 1; // B ** (k - 1) % M: the oldest char's weight
  for (let i = 1; i < k; i++) top = (top * B) % M;
  const seen = new Map<number, number[]>(); // hash -> starts
  const out = new Set<string>();
  let h = 0;
  for (let i = 0; i < s.length; i++) {
    if (i >= k) {
      const old = (s.charCodeAt(i - k) * top) % M;
      h = (h - old + M) % M; // drop s[i - k]
    }
    h = (h * B + s.charCodeAt(i)) % M;
    if (i < k - 1) continue;
    const start = i - k + 1;
    const bucket = seen.get(h);
    if (bucket === undefined) {
      seen.set(h, [start]);
      continue;
    }
    const w = s.slice(start, i + 1);
    // compare only on a hash hit, so collisions are harmless
    if (bucket.some((j) => s.startsWith(w, j))) out.add(w);
    else bucket.push(start);
  }
  return [...out];
}
 
repeated("AAAAACCCCCAAAAACCCCCCAAAAAGGGTTT", 10);
// ["AAAAACCCCC", "CCCCCAAAAA"]
 
const M = 1_000_000_007;
Number.isSafeInteger((M - 1) * 131 + 65535); // true
Number.isSafeInteger((2 ** 61 - 2) * 131); // false
2 ** 53 + 1 === 2 ** 53; // true: rounded away

Why a modulus near 10⁹ rather than Python's 2⁶¹ − 1: JS numbers are doubles, and integer arithmetic is exact only below 2⁵³. With M = 1_000_000_007, h * B + code stays under 1.4 × 10¹¹ and code * top under 6.6 × 10¹³, both exact; with a 61-bit modulus the products would be silently rounded, and two equal windows could get different hashes. BigInt keeps a large modulus exact but runs several times slower. Characters are compared on every hash hit, so a collision can cost time but never give a wrong answer.

On Node 24, with 100,000 random ACGT characters, a Set of slices was faster at k = 10, and the rolling hash was about 5 to 10 times faster at k = 1000 and k = 5000, where hashing each slice costs O(k). On Node 26 and Bun the gap was two times or less: measure on your runtime.

Decode k[...] with a stack

When expanding nested repeat counts such as 3[a2[c]].

function decode(s: string): string {
  const stack: [string[], number][] = [];
  let cur: string[] = [];
  let k = 0;
  for (const ch of s) {
    if (ch >= "0" && ch <= "9") {
      k = k * 10 + Number(ch); // counts can be multi-digit
    } else if (ch === "[") {
      stack.push([cur, k]); // save the outer state
      cur = [];
      k = 0;
    } else if (ch === "]") {
      const [outer, times] = stack.pop()!;
      outer.push(cur.join("").repeat(times));
      cur = outer;
    } else {
      cur.push(ch);
    }
  }
  return cur.join("");
}
 
decode("3[a2[c]]"); // "accaccacc"
decode("2[abc]3[cd]ef"); // "abcabccdcdcdef"

String compression

When run-length encoding repeated characters, as in "aabcccccaaa" → "a2b1c5a3".

function compress(s: string): string {
  const out: string[] = [];
  let i = 0;
  while (i < s.length) {
    let j = i;
    while (j < s.length && s[j] === s[i]) j++;
    out.push(s[i], String(j - i));
    i = j;
  }
  const packed = out.join("");
  return packed.length < s.length ? packed : s;
}
 
compress("aabcccccaaa"); // "a2b1c5a3"
compress("abc"); // "abc": encoding would be longer

Slugify

When turning a title into a URL segment.

const slugify = (text: string, sep = "-") =>
  (
    text
      .normalize("NFKD")
      .replace(/\p{M}/gu, "") // drop combining marks
      .toLowerCase()
      .match(/[a-z0-9]+/g) ?? []
  ).join(sep);
 
slugify("Crème Brûlée: 10 Tips!"); // "creme-brulee-10-tips"

Case conversions

When mapping between camelCase, snake_case and kebab-case keys.

const WORDS = /[A-Z]?[a-z0-9]+|[A-Z]+(?![a-z])/g;
const words = (s: string) =>
  (s.match(WORDS) ?? []).map((w) => w.toLowerCase());
 
const snake = (s: string) => words(s).join("_");
const kebab = (s: string) => words(s).join("-");
const camel = (s: string) =>
  words(s)
    .map((w, i) =>
      i === 0 ? w : w[0].toUpperCase() + w.slice(1),
    )
    .join("");
 
snake("parseHTTPResponse2"); // "parse_http_response2"
kebab("user_id"); // "user-id"
camel("created-at_utc"); // "createdAtUtc"

References