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
| Syntax | Notes |
|---|---|
'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 moduleconst 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><b></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
| Expression | Result | Notes |
|---|---|---|
s.charCodeAt(i) | 0 to 65535 | one UTF-16 code unit; NaN out of range, never undefined |
s.codePointAt(i) | number | undefined | the whole code point when i starts a surrogate pair |
s.charCodeAt(i) - 97 | 0 to 25 for a to z | the slot in a 26-entry count array ("a" is 97) |
String.fromCharCode(97 + k) | the k-th lowercase letter | from code units |
String.fromCodePoint(0x1f600) | "😀" | from code points; RangeError above 0x10ffff |
s.charCodeAt(i) - 48 | 0 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 digit | what "alphanumeric" means in most problem statements |
/\s/.test(ch) | whitespace | also matches "\u00a0" (no-break space) and other Unicode spaces |
ch.toUpperCase() | case mapping | can change the length: "ß".toUpperCase() is "SS" |
[...s], for (const ch of s) | code points | s.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(); // false1/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" gives | Notes |
|---|---|---|
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"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); // trueMethods
| Method | Does | Example → 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 terminators | no "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) | boolean | includes(/re/) throws a TypeError |
search(re) | index of the first regex match or -1 | "ab12".search(/\d/) → 2 |
match(re), matchAll(re) | regex matches | see 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 match | a 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 positive | see Searching & comparing |
at, charAt, charCodeAt, codePointAt | read one position | see 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
| Expression | Returns | Notes |
|---|---|---|
s.includes(t) | boolean | substring test |
s.indexOf(t, from) | index or -1 | from searches from an offset without slicing |
s.lastIndexOf(t, from) | index or -1 | searches backward, starting at from |
s.startsWith(t, i) | boolean | is t at offset i |
s.split(t).length - 1 | count | non-overlapping |
a === b | boolean | by value, one code unit at a time |
a < b | boolean | lexicographic by UTF-16 code unit: "Z" < "a", "apple" < "apples", "10" < "9" |
[...s].sort().join("") | sorted characters | the default sort compares code units as well |
a.localeCompare(b) | number | locale 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"; // truePython 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,
); // truesensitivity | Treats 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"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.
| Operation | Cost | Notes |
|---|---|---|
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 += t | O(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 === t | O(n) | O(1) when the lengths differ |
includes, indexOf, split, replace | O(n) in practice | see 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 key | O(n) to hash the first time | V8 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).
| Job | TypeScript | Watch out | More |
|---|---|---|---|
| Char ↔ slot | s.charCodeAt(i) - 97, String.fromCharCode(97 + k) | assumes lowercase ASCII: read the constraints | Strings |
| Count characters | const 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 keys | Group anagrams |
| Edit characters | const 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 | |
| Palindrome | two pointers comparing s.charCodeAt(i) and s.charCodeAt(j) | a reversed copy costs O(n) memory | Two pointers |
| Match at an offset | s.startsWith(w, i) | s.slice(i, i + w.length) === w builds a slice first | Word Break |
| Longest window | last.set(ch, i); on a repeat, lo = prev + 1 when prev >= lo | store indexes, not substrings | Sliding window |
| Window with counts | need counts plus a missing total | update the total as characters enter and leave instead of comparing count arrays | Minimum window |
| Custom alphabet | a rank table, then a comparator | sort() with no comparator compares code units | Custom order |
| Digits and binary | Number(s), parseInt(s, 2), n.toString(2).padStart(k, "0") | parseInt("12px") is 12; Number("") is 0 | Numbers |
| Tokenize | expr.match(/\d+|[-+*/()]/g) ?? [] | match returns null, not [], when nothing matches | Stacks |
| Brackets | a string[] stack and a Record<string, string> of pairs | check the stack is empty at the end | Balanced brackets |
| Memo over a string | key the memo on index i (or i * (n + 1) + j) | a key like s.slice(i) hashes O(n) code units per lookup | Top-down DP |
| Many same-length substrings | a Set of slices, or a rolling hash | keep every product below 2⁵³ | Rolling hash |
| Prefix lookups | a trie of nested Maps | startsWith against every word is O(words × k) | Tries |
| Counts and sets of characters | Map, Set, a last-index Map | Maps & 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"]); // true1/8
i = 0: "a" is new, so the window is s.slice(0, 1) = "a" and best is 1.
Regular expressions
| Flag | Name | Effect |
|---|---|---|
g | global | find all matches; enables lastIndex |
i | ignoreCase | case-insensitive |
m | multiline | ^ and $ match at every line break |
s | dotAll | . also matches newlines |
u | unicode | code-point matching, \u{…}, \p{…} property escapes |
v | unicodeSets | u plus set operations --, && and \q{…} (ES2024) |
y | sticky | match only at lastIndex (tokenizers) |
d | hasIndices | adds indices (start and end) for each group |
| Syntax | Meaning |
|---|---|
(?<year>\d{4}) | named group, read as m.groups.year |
\k<year>, \1 | backreference inside the pattern |
(?:…) | non-capturing group |
x(?=y), x(?!y) | lookahead, negative lookahead |
(?<=y)x, (?<!y)x | lookbehind, negative lookbehind (any width) |
\p{L}, \p{Lu}, \p{Nd} | any letter, uppercase letter, decimal digit (u or v) |
a*?, a+? | lazy quantifiers |
| Call | Returns |
|---|---|
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?"); // trueThese patterns work in the replacement string of replace and replaceAll:
| Pattern | Inserts |
|---|---|
$$ | a literal dollar sign |
$& | the whole match |
$`, $' | the text before, after the match |
$1 … $99 | a 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.
| Goal | Pattern |
|---|---|
| digits only | /^\d+$/ |
| integer with a sign | /^[+-]?\d+$/ |
| collapse whitespace | s.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" | 1 | 1 | 1 |
"é" (NFC, "\u00e9") | 1 | 1 | 1 |
"é" (NFD, "e\u0301") | 2 | 2 | 1 |
"😀" | 2 | 1 | 1 |
"👍🏽" (thumbs up, skin tone) | 4 | 2 | 1 |
"👨👩👧" (family) | 8 | 5 (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| Task | Tool |
|---|---|
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 accents | s.normalize("NFD").replace(/\p{M}/gu, "") |
| Case-insensitive compare | Intl.Collator with sensitivity: "base", or toLowerCase() on both for ASCII |
| Check or repair lone surrogates | s.isWellFormed(), s.toWellFormed() (ES2024) |
| User-perceived characters | Intl.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
| Constructor | Formats | Example output |
|---|---|---|
Intl.NumberFormat | numbers, currency, units, percent | $1,234.50, 1.2M, 50 km/h |
Intl.DateTimeFormat | dates and times in a time zone | Sep 25, 2026, 10:30 AM |
Intl.RelativeTimeFormat | relative time | yesterday, in 3 weeks |
Intl.ListFormat | lists | a, b, and c, tea or coffee |
Intl.PluralRules | picks a plural category | "one", "few", "other" |
Intl.Collator | comparison for sorting | see Searching & comparing |
Intl.Segmenter | graphemes, words, sentences | see 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
| Type | Accepts |
|---|---|
string | any 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 |
String | the 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 str | TypeScript string |
|---|---|
len(s) counts code points: len("😀") is 1 | s.length counts UTF-16 code units: "😀".length is 2; [...s].length counts code points |
s[i], s[-1]; IndexError out of range | s[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 s | s.includes(t) |
s.find(t), s.rfind(t) | s.indexOf(t), s.lastIndexOf(t) |
s.index(t) raises ValueError | no 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 match | s.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 * 3 | s.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 point | strings 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"); // falseOne 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.
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 awayWhy 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 longerSlugify
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
- MDN: String (opens in a new tab): every method, with UTF-16 notes
- MDN: Regular expressions guide (opens in a new tab) and RegExp.escape (opens in a new tab)
- MDN: Intl (opens in a new tab), Intl.Collator (opens in a new tab) and Intl.Segmenter (opens in a new tab)
- ECMAScript: The String type (opens in a new tab): strings as sequences of 16-bit code units
- TypeScript handbook: Template literal types (opens in a new tab)
- TSConfig: noUncheckedIndexedAccess (opens in a new tab)
- V8 source: string.h (opens in a new tab):
ConsStringandSlicedString, both withkMinLength = 13 - V8 source: string-search.h (opens in a new tab): linear, Boyer–Moore–Horspool and Boyer–Moore substring search
- cp-algorithms: Prefix function, KMP (opens in a new tab) and String hashing (opens in a new tab): the theory behind the KMP and rolling-hash recipes
- Zod: branded types (opens in a new tab)