Maps & sets
Map, Set, WeakMap and WeakSet in TypeScript 7.0 (also checked with 6.0) under strict, with
ES2025 built-ins, run on Node 24 LTS (and on Node 26 and Bun 1.4): creating, reading and writing,
iteration order, what makes two keys the same, Map versus plain objects, set operations, weak
collections, how V8 stores a Map, costs, the hash-map idioms DS&A solutions are built from, and
typing. Plain objects as records are in Objects; the Python side is
in Dictionaries & sets.
Creating
A Map maps keys of any type to values and remembers insertion order. A Set holds unique values,
also in insertion order.
| Expression | Result | Notes |
|---|---|---|
new Map<string, number>() | an empty Map | annotate it: a bare new Map() is Map<any, any> |
new Map([["a", 1], ["b", 2]]) | from pairs | inferred Map<string, number>; a repeated key keeps its first place and its last value |
new Map(xs.map((x, i) => [x, i])) | value → index | the last index wins for repeated values; build the pairs inline (see below) |
new Map(Object.entries(obj)) | from an object | the keys are strings |
Map.groupBy(xs, f) | Map<K, T[]> | ES2024; Object.groupBy makes a null-prototype object instead |
new Set<number>(), new Set(xs) | a set | duplicates collapse; a bare new Set() is Set<unknown> |
new Set("hello") | Set {"h", "e", "l", "o"} | a string iterates by code point |
new Map(m), new Set(s) | a shallow copy | values that are objects are shared |
structuredClone(m) | a deep copy | throws on functions; class instances come back as plain objects |
const words = ["apple", "bob", "apple", "cy"];
const lengths = new Map(
words.map((w) => [w, w.length]),
);
// Map(3) {"apple" => 5, "bob" => 3, "cy" => 2}
const last = new Map(words.map((w, i) => [w, i]));
// Map(3) {"apple" => 2, "bob" => 1, "cy" => 3}
const firstSeen = new Set(words);
// Set(3) {"apple", "bob", "cy"}
const byLen = Map.groupBy(words, (w) => w.length);
[...byLen.keys()]; // [5, 3, 2]
byLen.get(5); // ["apple", "apple"]TypeScript turns [w, i] into a [string, number] tuple only when the array literal sits where a
pair is expected. Built on its own line, it is (string | number)[], and new Map rejects it:
const names = ["ann", "bo"];
const loose = names.map((n, i) => [n, i]);
// (string | number)[][]: not pairs
const pairs = names.map((n, i) => [n, i] as const);
const ids = new Map(pairs); // Map<string, number>
ids.get("bo"); // 1
const any = new Map(); // Map<any, any>: nothing checked
any.set("a", 1);
const oops: string = any.get("a"); // compiles anywayReading & writing
| Operation | Returns | Notes |
|---|---|---|
m.get(k) | V | undefined | never throws; undefined for a missing key and for a stored undefined |
m.has(k) | boolean | the only way to tell those two apart |
m.set(k, v) | the map | chainable; a new key goes last, an overwritten key keeps its place |
m.delete(k) | boolean | true if the key was there |
m.size | number | a getter, not a method; O(1) |
m.clear() | undefined | empties it for every reference to it |
m.getOrInsert(k, v) | the value now stored | inserts v only when k is missing; Node 26 and Bun 1.4, not Node 24 |
m.getOrInsertComputed(k, f) | the value now stored | calls f(k) only when k is missing; same runtimes |
s.add(x), s.has(x), s.delete(x) | the set, boolean, boolean |
const stock = new Map([["apple", 3], ["pear", 0]]);
stock.get("apple"); // 3
stock.get("kiwi"); // undefined
stock.get("kiwi") ?? 0; // 0
stock.has("pear"); // true
stock.set("kiwi", 5).set("apple", 4); // chained
[...stock.keys()]; // ["apple", "pear", "kiwi"]
stock.delete("pear"); // true
stock.delete("plum"); // false: wasn't there
stock.size; // 2
const counts = new Map<string, number>();
for (const ch of "banana") {
counts.set(ch, (counts.get(ch) ?? 0) + 1);
}
counts; // Map(3) {"b" => 1, "a" => 3, "n" => 2}getOrInsert and getOrInsertComputed (the "upsert" proposal) replace the get-then-set dance.
TypeScript 6.0 and 7.0 type them in lib: ["esnext"]; they run on Node 26 and Bun 1.4, not Node 24.
const groups = new Map<string, string[]>();
for (const w of ["eat", "tea", "tan"]) {
const key = [...w].sort().join("");
groups.getOrInsertComputed(key, () => []).push(w);
}
groups; // Map(2) {"aet" => ["eat", "tea"], "ant" => ["tan"]}The same on every runtime, reading once so TypeScript can narrow:
const groups = new Map<string, string[]>();
for (const w of ["eat", "tea", "tan"]) {
const key = [...w].sort().join("");
let list = groups.get(key);
if (!list) groups.set(key, (list = []));
list.push(w);
}
groups; // Map(2) {"aet" => ["eat", "tea"], "ant" => ["tan"]}getOrInsert(k, []) builds a new array on every call, even when k exists; the Computed form calls
its function only for a missing key.
Iterating
| Expression | Gives | Notes |
|---|---|---|
for (const [k, v] of m) | entries | insertion order, always |
m.keys(), m.values(), m.entries() | live iterators | one pass each; spread them to reuse the result |
m.forEach((v, k) => …) | the value comes first | |
[...m], Array.from(m) | [K, V][] | a snapshot |
m.keys().filter(f).toArray() | iterator helpers | ES2025: map, filter, take, some, find, reduce, … |
[...m].sort((a, b) => b[1] - a[1]) | pairs by value | a Map can't be sorted in place; new Map(sorted) rebuilds one |
m.keys().next().value | the oldest key | undefined when empty; typed K | undefined |
Object.fromEntries(m) | a plain object | keys are converted to strings |
Math.max(...m.values()) | spreads every value as an argument: V8 throws RangeError past about 125,000 |
const score = new Map([["ann", 90], ["bo", 72], ["cy", 90]]);
const keys = score.keys(); // a live iterator
score.set("di", 85);
[...keys]; // ["ann", "bo", "cy", "di"]
[...keys]; // []: used up
const ranked = [...score].sort(
(a, b) => b[1] - a[1] || a[0].localeCompare(b[0]),
);
// [["ann", 90], ["cy", 90], ["di", 85], ["bo", 72]]
let best = "";
let top = -Infinity;
for (const [name, s] of score) {
if (s > top) [best, top] = [name, s];
}
best; // "ann": the first of the ties
score.values().filter((s) => s >= 85).toArray();
// [90, 90, 85]Changing a Map or Set while looping over it never throws, unlike a Python dict. The iterator walks
the entries in order and sees the changes:
- an entry added during the loop is visited;
- an entry deleted before the loop reaches it is skipped;
- an overwrite (
seton an existing key) keeps the key's place; - a key deleted and then set again moves to the end and is visited again. Doing that to the current key on every pass never ends.
const live = new Map([["a", 1], ["b", 2], ["c", 3]]);
const seen: string[] = [];
for (const [k] of live) {
seen.push(k);
if (k === "a") {
live.delete("b"); // not reached yet: skipped
live.set("d", 4); // added: visited
}
}
seen; // ["a", "c", "d"]Keys & equality
Map and Set compare keys with SameValueZero: ===, except that NaN equals NaN. Nothing is
converted and nothing is compared by content, so objects and arrays are keys by reference.
| Key | Is the same key as | Notes |
|---|---|---|
NaN | NaN | NaN === NaN is false; a Map finds it anyway |
-0 | 0 | the Map stores it as +0 |
1 | not "1", not 1n | no conversion, unlike object keys (obj[1] is obj["1"]) |
[1, 2], { x: 1 } | only that very object | a fresh [1, 2] is a different key |
"1,2" | any equal string | strings compare by value: the usual composite key |
r * cols + c | any equal number | a cheaper composite key when the parts are small integers |
const m = new Map<unknown, string>();
m.set(NaN, "nan").set(-0, "zero").set(1, "one");
m.get(NaN); // "nan"
m.get(0); // "zero"
Object.is([...m.keys()][1], -0); // false: stored as +0
m.get("1"); // undefined: no conversion
const k = [1, 2];
m.set(k, "array");
m.get([1, 2]); // undefined: another array
m.get(k); // "array": the same array
m.set("1,2", "pair");
m.get(`${1},${2}`); // "pair": strings match by valueA composite key needs either a string with a separator or a number that packs the parts. Without a separator, different pairs can collide:
const glued = (r: number, c: number) => `${r}${c}`;
glued(1, 12) === glued(11, 2); // true: "112" twice
const key = (r: number, c: number) => `${r},${c}`;
key(1, 12) === key(11, 2); // false
const COLS = 4;
const walls = new Set([0 * COLS + 1, 1 * COLS + 3]);
walls.has(1 * COLS + 3); // true: row 1, column 3
const cell = 7;
[Math.floor(cell / COLS), cell % COLS]; // [1, 3]On Node 24, filling and reading a 600 × 600 grid of keys ran about 4 times faster with
r * cols + c numbers than with `${r},${c}` strings, which are built and hashed on every lookup.
When the cells are dense, a Uint8Array(rows * cols) of flags beats both by far.
Map vs object
Plain object, Record<K, V> | Map<K, V> | |
|---|---|---|
| Key types | strings and symbols; numbers become strings | any value |
| A missing key | undefined, but inherited names such as "constructor" are found | undefined |
| Order | integer-like keys first, ascending; then insertion order | insertion order |
| Size | Object.keys(o).length: builds an array | m.size: O(1) |
| Iterating | Object.entries(o) builds an array | iterate the map directly |
| Frequent add and delete | delete can push V8 into a slower dictionary mode | built for it |
| JSON | native | JSON.stringify(m) is "{}": convert with Object.fromEntries(m) first |
| Typing | Record<string, number> claims every key exists | get returns V | undefined |
| Most entries | no fixed limit | 2^24 = 16,777,216 in V8, then RangeError |
Prefer a Map for anything keyed by data: counts, indexes, memo tables, graphs. Keep objects for
records with known fields. More on objects as dictionaries is in
Objects.
const tally: Record<string, number> = {};
for (const w of ["constructor", "a"]) {
tally[w] = (tally[w] ?? 0) + 1;
}
typeof tally.constructor; // "string": Object's function + 1
typeof tally.a; // "number"
const obj: Record<number, string> = { 2: "b", 1: "a" };
Object.keys(obj); // ["1", "2"]: strings, sorted
const map = new Map([[2, "b"], [1, "a"]]);
[...map.keys()]; // [2, 1]: numbers, insertion order
JSON.stringify(map); // "{}"
JSON.stringify(Object.fromEntries(map));
// '{"1":"a","2":"b"}': an object sorts them againSets
| Expression | Gives | Notes |
|---|---|---|
new Set(xs) | a set | duplicates (by SameValueZero) collapse; first-seen order |
s.add(x) | the set | chainable |
s.has(x), s.delete(x) | boolean | O(1) average: the reason to use a set |
s.size | number | new Set(xs).size !== xs.length means xs has a repeat |
[...new Set(xs)] | deduped array | arrays and objects are deduped by reference only |
a.union(b) | in a or b | ES2025; every operation returns a new Set |
a.intersection(b) | in both | the result's order depends on which set is smaller |
a.difference(b) | in a, not in b | |
a.symmetricDifference(b) | in exactly one | |
a.isSubsetOf(b), a.isSupersetOf(b) | boolean | |
a.isDisjointFrom(b) | boolean | no shared elements |
const a = new Set([1, 2, 3]);
const b = new Set([3, 4]);
a.union(b); // Set(4) {1, 2, 3, 4}
a.intersection(b); // Set(1) {3}
a.difference(b); // Set(2) {1, 2}
a.symmetricDifference(b); // Set(3) {1, 2, 4}
new Set([1, 2]).isSubsetOf(a); // true
a.isDisjointFrom(b); // false: 3 is shared
const ids = new Map([[3, "c"], [9, "z"]]);
a.intersection(ids); // Set(1) {3}: a Map is set-like
a.union(new Set([5, 6])); // an array must be wrapped1/6
a = new Set([1, 2, 3]) and b = new Set([3, 4]). Only 3 is in both.
The argument of every set method must be set-like: it needs a numeric size, a has method and a
keys method. A Set or a Map qualifies (a Map counts its keys); an array has no size, so
a.union([5, 6]) is a type error and throws TypeError at runtime.
Bitmasks as sets
When every possible member maps to a small integer (26 letters, up to about 20 items for bitmask DP),
one number can hold the set: bit i set means member i is in. JavaScript's bitwise operators work
on 32-bit integers, so a mask holds at most 32 members, and 1 << 31 is negative; use BigInt masks
past that. The bit tricks themselves are in Numbers.
| Set operation | Bitmask |
|---|---|
empty, all of n members | 0, (1 << n) - 1 (for n up to 30) |
add i, remove i | m | (1 << i), m & ~(1 << i) |
has i | (m >> i) & 1 |
| union, intersection, difference | a | b, a & b, a & ~b |
| no shared members | (a & b) === 0 |
| subset | (a & b) === a |
const letters = (w: string) => {
let m = 0;
for (const ch of w) m |= 1 << (ch.charCodeAt(0) - 97);
return m;
};
letters("abcab").toString(2); // "111"
(letters("abc") & letters("xyz")) === 0; // true
(letters("ab") & letters("cab")) === letters("ab");
// true: a subset
const popcount = (x: number) => {
let n = 0;
for (; x !== 0; x &= x - 1) n++;
return n;
};
popcount(letters("hello")); // 4: h, e, l, oWeakMap, WeakSet & WeakRef
| Type | Holds | Iterable, size | Use for |
|---|---|---|---|
WeakMap<K, V> | keys that are objects or non-registered symbols | no | data attached to objects you don't own; memo tables keyed by object |
WeakSet<T> | the same kinds of values | no | "have I seen this object?" without keeping it alive |
WeakRef<T> | one object | a cache entry the garbage collector may clear; deref() may return undefined | |
FinalizationRegistry | cleanup callbacks | best-effort cleanup after collection, with no timing guarantee |
A weak key doesn't keep its object alive: once nothing else refers to the object, the entry can
disappear. That is why weak collections can't be iterated or counted. A primitive key throws
TypeError: Invalid value used as weak map key in V8.
const meta = new WeakMap<object, number>();
const node = { id: 1 };
meta.set(node, 42);
meta.get(node); // 42
meta.has({ id: 1 }); // false: another object
const sums = new WeakMap<readonly number[], number>();
function total(xs: readonly number[]): number {
let t = sums.get(xs);
if (t === undefined) {
t = xs.reduce((s, x) => s + x, 0);
sums.set(xs, t);
}
return t;
}
const data = [1, 2, 3];
total(data); // 6: computed
total(data); // 6: cached until data is collectedDS&A solutions rarely need weak collections: an ordinary Map that lives for one call is collected
with everything else when the call returns.
How Map works inside
The spec only asks for average lookups faster than linear and iteration in insertion order. V8 (Node, Chrome, Deno) meets both with a deterministic hash table, a design credited to Tyler Close: a small array of buckets, and a separate array of entries kept in insertion order. Each bucket holds the index of the newest entry that hashes to it, and each entry holds its key, its value and the index of the next entry in the same bucket. These are V8 engine details, not part of the language.
1/9
new Map() gets 2 buckets and room for 4 entries: always 2 entries per bucket. Both buckets point nowhere yet.
What node --allow-natives-syntax and %DebugPrint(m) report for the backing table on Node 24 and 26
(three header slots, then the buckets, then three slots per entry; a Set entry has no value slot):
| Map state | Table | Buckets | Entry slots |
|---|---|---|---|
new Map() | OrderedHashMap[17] | 2 | 4 |
after 5 sets | OrderedHashMap[31] | 4 | 8 |
after 100 sets | OrderedHashMap[451] | 64 | 128 |
then 90 deletes | OrderedHashMap[115] | 16 | 32 |
new Set([1, 2, 3, 4, 5]) | OrderedHashSet[23] | 4 | 8 |
- A
setinto a full table (live entries plus holes fill every slot) rehashes: to the same size if at least half the slots are holes, otherwise to double the size. The live entries are copied in order. - A
deletethat leaves fewer live entries than a quarter of the slots rehashes to half the size. - An iterator that was mid-walk during a rehash carries on in the new table, which is how the mutation rules in Iterating hold.
- V8 caps a
MaporSetat 2^24 = 16,777,216 entries: the nextsetthrowsRangeError: Map maximum size exceeded. Bun 1.4 has no such cap: here it ran out of memory past 50 million entries instead.
Costs & memory
| Operation | Average | Notes |
|---|---|---|
get, set, has, delete | O(1) | plus the cost of hashing the key: O(length) for a new string |
size | O(1) | |
for…of, spread, new Map(m), new Set(s) | O(n) | |
"is some value v in the map?" | O(n) | scan m.values(), or keep a reverse map |
a.union(b), a.symmetricDifference(b) | O(a + b) | |
a.intersection(b), a.isDisjointFrom(b) | O(min(a, b)) lookups | iterates the smaller side |
a.difference(b) | O(a) | copies a, then removes |
a.isSubsetOf(b) | O(a) | false at once when a.size > b.size |
Map.groupBy(xs, f) | O(n) | |
`${r},${c}` as a key | O(length) per lookup | builds and hashes a new string every time |
Node runs V8 without pointer compression, so every slot is 8 bytes. A Map entry is 3 slots plus half
a bucket, about 28 bytes, and a Set entry about 20, before counting the keys and values themselves.
Measured on Node 24 with a million integer keys: 29 bytes per Map entry and 21 per Set entry,
against 8 per element of a number[] and 4 of an Int32Array.
| Measured on Node 24 (one run) | Time |
|---|---|
360,000 grid cells, Map with `${r},${c}` keys | 211 ms |
the same, r * cols + c number keys | 55 ms |
the same, a Uint8Array of flags | under 1 ms |
letter counts of 1.75 million characters, Map | 54 ms |
the same, Int32Array(26) indexed by charCodeAt(i) - 97 | 4 ms |
Reach for a typed array when the keys are small dense integers; reach for a Map when they are not.
DS&A idioms
The hash-map moves that problem solutions are built from, and the trap in each. The patterns themselves, with TypeScript, JavaScript and Python side by side, are in the DS&A sheets (linked in the last column).
| Job | TypeScript | Watch out | More |
|---|---|---|---|
| Count items | m.set(x, (m.get(x) ?? 0) + 1) | no Counter: write a counter helper | Counter & TS equivalents |
| Complement lookup (two-sum) | const j = seen.get(t - x); if (j !== undefined) …; then seen.set(x, i) | if (j) fails for index 0; set after the check, or x pairs with itself | Hash maps & sets |
| Value → index | new Map(xs.map((x, i) => [x, i])) | the last index wins; if (!pos.has(x)) pos.set(x, i) keeps the first | |
| Group by a key | Map.groupBy(words, (w) => [...w].sort().join("")) | the key must be a string or number: an array key never matches | Group anagrams |
| Visited set | seen.add(v) when v is enqueued | marking on dequeue lets one node enter the queue many times | BFS |
| Grid cells | r * cols + c in a Set<number>, or a Uint8Array | `${r},${c}` works but is about 4 times slower | Keys & equality |
| Duplicates | new Set(xs).size !== xs.length | reads all of xs; loop with has and add to stop at the first repeat | Hash maps & sets |
| Common to every array | arrs.map((a) => new Set(a)).reduce((x, y) => x.intersection(y)) | reduce without a seed throws on an empty list | |
| Cycle detection | while (!seen.has(x)) { seen.add(x); x = step(x); } | O(n) memory; fast and slow pointers need O(1) | Fast & slow pointers |
| Small universe | an integer bitmask | 32 bits at most, and 1 << 31 is negative | Bitmasks as sets |
| Adjacency list | Map<number, number[]>, push both ways if undirected | adj.get(v) ?? [] for nodes without edges | Setup, Graphs |
| Prefix-sum counts | new Map([[0, 1]]), add count.get(pre - k) ?? 0, then count pre | the [0, 1] seed counts subarrays that start at index 0 | Prefix sums |
| Window, last-seen index | lo = Math.max(lo, (last.get(ch) ?? -1) + 1), then last.set(ch, i) | lo only moves right: an index from before the window must not pull it back | Sliding window |
| Window, distinct count | decrement, and delete the key at zero | a zero count is still a key, so size overcounts | Sliding window |
| Memoization | memo.get(i * (n + 1) + j), or a `${i},${j}` key | an array key never hits | Top-down DP |
| LRU cache | refresh with m.delete(k); m.set(k, v); evict m.keys().next().value | set on an existing key does not move it | LRU cache |
| Top k frequent | count, then buckets indexed by count | sorting the entries is O(n log n) | Top k frequent |
| Union-find | Int32Array parents for ids 0..n-1, a Map for anything else | union by size and path halving keep it near O(1) | Union-find |
| Trie | Map<string, TrieNode> children per node | an object's "constructor" child would already exist | Tries |
| Longest consecutive run | s = new Set(xs); count up only from an x with !s.has(x - 1) | without that check the scan is O(n²) | Recipe |
| Dedupe, keeping order | [...new Set(xs)] | arrays and objects are compared by reference | |
| Float keys | exact keys: a slope as `${dy / g},${dx / g}` with g the gcd | 0.1 + 0.2 and 0.3 are two different keys |
function twoSum(nums: number[], target: number): number[] {
const seen = new Map<number, number>(); // value -> index
for (const [i, x] of nums.entries()) {
const j = seen.get(target - x);
if (j !== undefined) return [j, i];
seen.set(x, i); // after the check: no self-pairs
}
return [];
}
function subarraySum(nums: number[], k: number): number {
const count = new Map([[0, 1]]); // the empty prefix
let pre = 0;
let ans = 0;
for (const x of nums) {
pre += x;
ans += count.get(pre - k) ?? 0;
count.set(pre, (count.get(pre) ?? 0) + 1);
}
return ans;
}
twoSum([3, 2, 4], 6); // [1, 2]
subarraySum([1, 1, 1], 2); // 21/5
subarraySum([1, 1, 1], 2) starts with count = Map {0 => 1}: the empty prefix sums to 0. pre and ans are 0.
String-shaped map problems (the longest substring without repeats, anagram windows) are on the Strings sheet.
Typing
| Type | Accepts | Use for |
|---|---|---|
Map<K, V>, Set<T> | maps and sets | values you build and mutate; annotate empty ones |
ReadonlyMap<K, V>, ReadonlySet<T> | any Map or Set | parameters you only read: no set, add, delete or clear |
Record<K, V> | objects | records with known keys; it claims every key is present |
WeakMap<K extends WeakKey, V>, WeakSet<T extends WeakKey> | objects, non-registered symbols | |
Iterable<readonly [K, V]> | anything new Map accepts | constructor-like helpers |
has doesn't narrow get: TypeScript treats the two calls as unrelated, so get is still
V | undefined after a has check. Read once and test the result instead.
function firstOf(m: Map<string, number[]>, k: string) {
if (m.has(k)) {
// @ts-expect-error: get() is still number[] | undefined
return m.get(k)[0];
}
return undefined;
}
function firstOrNull(m: Map<string, number[]>, k: string) {
const list = m.get(k); // read once
return list ? list[0] : null; // narrowed
}
firstOrNull(new Map([["a", [7]]]), "a"); // 7A counter and a default map, typed once and reused:
function counter<T>(xs: Iterable<T>): Map<T, number> {
const m = new Map<T, number>();
for (const x of xs) m.set(x, (m.get(x) ?? 0) + 1);
return m;
}
counter("abca"); // Map(3) {"a" => 2, "b" => 1, "c" => 1}
class DefaultMap<K, V> extends Map<K, V> {
readonly #make: (key: K) => V;
constructor(make: (key: K) => V) {
super();
this.#make = make;
}
override get(key: K): V {
let v = super.get(key);
if (v === undefined) {
v = this.#make(key);
this.set(key, v);
}
return v;
}
}
const graph = new DefaultMap<string, string[]>(() => []);
graph.get("a").push("b"); // typed string[]: no check
graph.get("c"); // [], and "c" is now a key
[...graph.keys()]; // ["a", "c"]Like Python's defaultdict, DefaultMap inserts on every read of a missing key; use has to test
membership.
Method parameters in TypeScript are checked bivariantly, so a Map<string, number> is accepted where
a Map<string, number | string> is expected, and the callee can then store a string in your map of
numbers. Take ReadonlyMap when you only read:
const nums = new Map<string, number>([["a", 1]]);
function tag(m: Map<string, number | string>) {
m.set("note", "oops");
}
tag(nums); // compiles
typeof nums.get("note"); // "string", in a number map
function size(m: ReadonlyMap<string, number | string>) {
return m.size; // nothing to write with
}
size(nums); // 2Mixed value types need the type spelled out: new Map([["a", 1], ["b", "x"]]) is an error, because
TypeScript infers Map<string, number> from the first pair; write
new Map<string, number | string>(…).
Coming from Python
| Python | TypeScript |
|---|---|
d: dict[str, int] = {} | const m = new Map<string, number>() |
d[k] (raises KeyError) | m.get(k): undefined, never an exception |
d.get(k, 0) | m.get(k) ?? 0 |
k in d | m.has(k); k in m checks the Map object's properties, not its keys |
d[k] = v | m.set(k, v); m[k] = v sets a property and is a type error |
del d[k], d.pop(k, None) | m.delete(k), which returns boolean |
len(d) | m.size |
d.setdefault(k, []) | m.getOrInsertComputed(k, () => []) on Node 26 and Bun, else get, test, set |
Counter(xs) | a counting loop or a counter helper; no built-in |
defaultdict(list) | Map.groupBy for a one-shot grouping, or a DefaultMap |
od.move_to_end(k) | m.delete(k) then m.set(k, v) |
od.popitem(last=False) | m.delete(m.keys().next().value!) when not empty |
for k, v in d.items() | for (const [k, v] of m) |
sorted(d.items(), key=…) | [...m].sort((a, b) => …) |
tuple keys (r, c) | `${r},${c}` or r * cols + c; array keys compare by reference |
1, 1.0 and True are one key | 1 and 1.0 are one number; true is a different key |
| changing a dict while looping raises | a Map never throws: see Iterating |
set(), {1, 2} | new Set(), new Set([1, 2]) |
a | b, a & b, a - b, a ^ b | a.union(b), a.intersection(b), a.difference(b), a.symmetricDifference(b) |
a <= b, a.isdisjoint(b) | a.isSubsetOf(b), a.isDisjointFrom(b) |
frozenset as a key | no hashable set: a sorted, joined string, or a bitmask |
weakref.WeakKeyDictionary | WeakMap |
json.dumps(d) | JSON.stringify(Object.fromEntries(m)) |
const m = new Map<string, number>([["a", 1]]);
"a" in m; // false: checks properties, not keys
m.has("a"); // true
// @ts-expect-error: a Map has no index signature
m["b"] = 2;
m.size; // 1: that wrote a property, not an entryRecipes
LRU cache
When a fixed-size cache must evict the least recently used key. A Map already keeps keys in order:
re-inserting a key on every use moves it to the end, so the first key is always the stalest.
class LRUCache<K, V> {
readonly #cap: number;
readonly #m = new Map<K, V>();
constructor(capacity: number) {
this.#cap = capacity;
}
get(key: K): V | undefined {
const v = this.#m.get(key);
if (v === undefined) return undefined;
this.#m.delete(key); // move to the newest end
this.#m.set(key, v);
return v;
}
put(key: K, value: V): void {
this.#m.delete(key);
this.#m.set(key, value);
if (this.#m.size > this.#cap) {
const oldest = this.#m.keys().next().value!;
this.#m.delete(oldest);
}
}
}
const cache = new LRUCache<number, number>(2);
cache.put(1, 1);
cache.put(2, 2);
cache.get(1); // 1
cache.put(3, 3); // evicts 2
cache.get(2); // undefined
cache.put(4, 4); // evicts 1
cache.get(1); // undefined
cache.get(3); // 3
cache.get(4); // 41/10
new LRUCache(2): an empty Map. Its first key will always be the least recently used.
set on an existing key keeps its old place, so put deletes first. Each operation is O(1).
Group anagrams
When words that are rearrangements of each other must land together.
function groupAnagrams(words: string[]): string[][] {
const groups = Map.groupBy(words, (w) =>
[...w].sort().join(""),
);
return [...groups.values()];
}
groupAnagrams(["eat", "tea", "tan", "ate", "nat", "bat"]);
// [["eat", "tea", "ate"], ["tan", "nat"], ["bat"]]Sorting each word costs O(L log L). For lowercase words, a key built from the 26 letter counts, joined with commas, costs O(L).
Longest consecutive sequence
When you need the longest run of consecutive integers in unsorted input, in O(n).
function longestRun(nums: number[]): number {
const s = new Set(nums);
let best = 0;
for (const x of s) {
if (s.has(x - 1)) continue; // not the start of a run
let y = x;
while (s.has(y + 1)) y++;
best = Math.max(best, y - x + 1);
}
return best;
}
longestRun([100, 4, 200, 1, 3, 2]); // 4: 1 to 4Loop over the set, not the array: repeated values in the input would otherwise repeat the work.
Top k frequent
When you need the k most common items in O(n), without sorting all the counts.
function topK(nums: number[], k: number): number[] {
const count = new Map<number, number>();
for (const x of nums) {
count.set(x, (count.get(x) ?? 0) + 1);
}
// buckets[c] holds the values seen exactly c times
const buckets: number[][] = Array.from(
{ length: nums.length + 1 },
() => [],
);
for (const [x, c] of count) buckets[c]!.push(x);
const out: number[] = [];
for (let c = nums.length; c > 0; c--) {
for (const x of buckets[c]!) {
out.push(x);
if (out.length === k) return out;
}
}
return out;
}
topK([1, 1, 1, 2, 2, 3], 2); // [1, 2]A count can't exceed nums.length, so the buckets cover every count. The ! after buckets[c]
satisfies noUncheckedIndexedAccess; without that flag it is unnecessary but harmless.
First unique character
When you need the first item that appears exactly once.
function firstUnique(s: string): number {
const count = new Map<string, number>();
for (const ch of s) {
count.set(ch, (count.get(ch) ?? 0) + 1);
}
for (let i = 0; i < s.length; i++) {
if (count.get(s[i]!) === 1) return i;
}
return -1;
}
firstUnique("leetcode"); // 0
firstUnique("loveleetcode"); // 2
firstUnique("aabb"); // -1The second pass walks the string, not the map, so "first" means first in the input. Walking the map
would also work, because a Map keeps the order in which keys were first inserted.
Nearby duplicate
When a repeat only counts if the two positions are at most k apart.
function nearbyDuplicate(nums: number[], k: number) {
const lastAt = new Map<number, number>();
for (const [i, x] of nums.entries()) {
const j = lastAt.get(x);
if (j !== undefined && i - j <= k) return true;
lastAt.set(x, i);
}
return false;
}
nearbyDuplicate([1, 2, 3, 1], 3); // true
nearbyDuplicate([1, 2, 3, 1, 2, 3], 2); // falseIsomorphic strings
When two strings must map onto each other one character to one character.
function isomorphic(s: string, t: string): boolean {
if (s.length !== t.length) return false;
const st = new Map<string, string>();
const ts = new Map<string, string>();
for (let i = 0; i < s.length; i++) {
const a = s[i]!;
const b = t[i]!;
if ((st.get(a) ?? b) !== b) return false;
if ((ts.get(b) ?? a) !== a) return false;
st.set(a, b);
ts.set(b, a);
}
return true;
}
isomorphic("egg", "add"); // true
isomorphic("foo", "bar"); // false
isomorphic("badc", "baba"); // false: d and b both map to bOne map alone accepts "badc" and "baba"; the reverse map catches two characters mapping to the
same one.
Happy number
When a process might loop: remember every state you've seen.
function isHappy(n: number): boolean {
const seen = new Set<number>();
while (n !== 1 && !seen.has(n)) {
seen.add(n);
let next = 0;
for (; n > 0; n = Math.floor(n / 10)) {
next += (n % 10) ** 2;
}
n = next;
}
return n === 1;
}
isHappy(19); // true
isHappy(2); // falseThe seen set costs memory for every state; Floyd's fast and slow pointers find the same cycle in
O(1) space (Fast & slow pointers).
At most k distinct in a window
When the number of distinct keys inside a sliding window is the constraint.
function longestKDistinct(s: string, k: number): number {
const count = new Map<string, number>();
let lo = 0;
let best = 0;
for (let hi = 0; hi < s.length; hi++) {
const ch = s[hi]!;
count.set(ch, (count.get(ch) ?? 0) + 1);
while (count.size > k) {
const gone = s[lo++]!;
const left = count.get(gone)! - 1;
if (left === 0) count.delete(gone); // else size lies
else count.set(gone, left);
}
best = Math.max(best, hi - lo + 1);
}
return best;
}
longestKDistinct("eceba", 2); // 3: "ece"Can one multiset cover another
When checking a ransom note against a magazine, or a word against a rack of letter tiles.
function canBuild(note: string, letters: string): boolean {
const have = new Map<string, number>();
for (const ch of letters) {
have.set(ch, (have.get(ch) ?? 0) + 1);
}
for (const ch of note) {
const n = have.get(ch) ?? 0;
if (n === 0) return false;
have.set(ch, n - 1);
}
return true;
}
canBuild("aab", "baa"); // true
canBuild("aa", "ab"); // falseReferences
- MDN: Map (opens in a new tab) and Set (opens in a new tab): every method, key equality, iteration order
- MDN: Set composition methods (opens in a new tab) and set-like objects (opens in a new tab)
- MDN: Map.groupBy() (opens in a new tab)
- MDN: WeakMap (opens in a new tab), WeakSet (opens in a new tab) and WeakRef (opens in a new tab)
- ECMAScript: SameValueZero (opens in a new tab), Map objects (opens in a new tab) and Set objects (opens in a new tab)
- TC39 proposal: Set methods (opens in a new tab) and Upsert (opens in a new tab) (
getOrInsert) - V8 source: ordered-hash-table.h (opens in a new tab): the table layout and its growth rules
- Jason Orendorff: Deterministic hash tables (opens in a new tab): the design V8 follows
- TypeScript handbook: Generics (opens in a new tab): typing helpers such as
counter<T>