../

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.

ExpressionResultNotes
new Map<string, number>()an empty Mapannotate it: a bare new Map() is Map<any, any>
new Map([["a", 1], ["b", 2]])from pairsinferred Map<string, number>; a repeated key keeps its first place and its last value
new Map(xs.map((x, i) => [x, i]))value → indexthe last index wins for repeated values; build the pairs inline (see below)
new Map(Object.entries(obj))from an objectthe 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 setduplicates 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 copyvalues that are objects are shared
structuredClone(m)a deep copythrows 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 anyway

Reading & writing

OperationReturnsNotes
m.get(k)V | undefinednever throws; undefined for a missing key and for a stored undefined
m.has(k)booleanthe only way to tell those two apart
m.set(k, v)the mapchainable; a new key goes last, an overwritten key keeps its place
m.delete(k)booleantrue if the key was there
m.sizenumbera getter, not a method; O(1)
m.clear()undefinedempties it for every reference to it
m.getOrInsert(k, v)the value now storedinserts v only when k is missing; Node 26 and Bun 1.4, not Node 24
m.getOrInsertComputed(k, f)the value now storedcalls 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

ExpressionGivesNotes
for (const [k, v] of m)entriesinsertion order, always
m.keys(), m.values(), m.entries()live iteratorsone 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 helpersES2025: map, filter, take, some, find, reduce, …
[...m].sort((a, b) => b[1] - a[1])pairs by valuea Map can't be sorted in place; new Map(sorted) rebuilds one
m.keys().next().valuethe oldest keyundefined when empty; typed K | undefined
Object.fromEntries(m)a plain objectkeys 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 (set on 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.

KeyIs the same key asNotes
NaNNaNNaN === NaN is false; a Map finds it anyway
-00the Map stores it as +0
1not "1", not 1nno conversion, unlike object keys (obj[1] is obj["1"])
[1, 2], { x: 1 }only that very objecta fresh [1, 2] is a different key
"1,2"any equal stringstrings compare by value: the usual composite key
r * cols + cany equal numbera 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 value
lookup entries of m m.get(NaN) "nan" "nan" NaN m.get(-0) "zero" "zero" 0 m.get("1") undefined "one" 1 m.get([1, 2]) undefined m.get(k) "array" "array" m.get(`${1},${2}`) "pair" "pair" "1,2" [1, 2] another [1, 2] array k no entry has this array SameValueZero: NaN finds NaN -0 is stored as +0 "1" is not 1: no conversion found missed objects are keys by identity, strings and numbers by value
Lookups compare keys by SameValueZero: values for primitives, identity for objects

A 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 typesstrings and symbols; numbers become stringsany value
A missing keyundefined, but inherited names such as "constructor" are foundundefined
Orderinteger-like keys first, ascending; then insertion orderinsertion order
SizeObject.keys(o).length: builds an arraym.size: O(1)
IteratingObject.entries(o) builds an arrayiterate the map directly
Frequent add and deletedelete can push V8 into a slower dictionary modebuilt for it
JSONnativeJSON.stringify(m) is "{}": convert with Object.fromEntries(m) first
TypingRecord<string, number> claims every key existsget returns V | undefined
Most entriesno fixed limit2^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 again

Sets

ExpressionGivesNotes
new Set(xs)a setduplicates (by SameValueZero) collapse; first-seen order
s.add(x)the setchainable
s.has(x), s.delete(x)booleanO(1) average: the reason to use a set
s.sizenumbernew Set(xs).size !== xs.length means xs has a repeat
[...new Set(xs)]deduped arrayarrays and objects are deduped by reference only
a.union(b)in a or bES2025; every operation returns a new Set
a.intersection(b)in boththe 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)booleanno 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 wrapped
a.union(b) → Set {1, 2, 3, 4} in a or b a.intersection(b) → Set {3} in both a.difference(b) → Set {1, 2} in a, not in b a.symmetricDifference(b) → Set {1, 2, 4} in exactly one a.isDisjointFrom(b) → false they share 3 a = new Set([1, 2, 3]), b = new Set([3, 4]) 3 is in both a b 1 2 3 4

1/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 operationBitmask
empty, all of n members0, (1 << n) - 1 (for n up to 30)
add i, remove im | (1 << i), m & ~(1 << i)
has i(m >> i) & 1
union, intersection, differencea | 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, o

WeakMap, WeakSet & WeakRef

TypeHoldsIterable, sizeUse for
WeakMap<K, V>keys that are objects or non-registered symbolsnodata attached to objects you don't own; memo tables keyed by object
WeakSet<T>the same kinds of valuesno"have I seen this object?" without keeping it alive
WeakRef<T>one objecta cache entry the garbage collector may clear; deref() may return undefined
FinalizationRegistrycleanup callbacksbest-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 collected

DS&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.

buckets [0] [1] slot key value chain 0 1 2 3 – – size 0 0 – 'a' 1 – size 1 0 1 'a' 1 – 'b' 2 – size 2 2 1 'a' 1 – 'b' 2 – 'c' 3 0 size 3 2 1 'a' 1 – 'b' 2 – 'c' 3 0 size 3 2 1 'a' 1 – hole hole – 'c' 3 0 size 2 2 3 'a' 1 – hole hole – 'c' 3 0 'd' 4 1 size 3 2 3 'a' 1 – hole hole – 'c' 3 0 'd' 4 1 size 3 2 3 'a' 1 – hole hole – 'c' 3 0 'd' 4 1 iteration walks the slots in order, skipping holes rehash new table: 4 buckets, 8 slots [0] 0 [1] 2 [2] 1 [3] 3 slot key value chain 0 'a' 1 – 1 'c' 3 – 2 'd' 4 – 3 'e' 5 – 4 5 6 7 size 4 const m = new Map() m.set('a', 1) m.set('b', 2) m.set('c', 3) m.get('a') // 1 m.delete('b') // true m.set('d', 4) [...m.keys()] // ['a', 'c', 'd'] m.set('e', 5) new checked found

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 stateTableBucketsEntry slots
new Map()OrderedHashMap[17]24
after 5 setsOrderedHashMap[31]48
after 100 setsOrderedHashMap[451]64128
then 90 deletesOrderedHashMap[115]1632
new Set([1, 2, 3, 4, 5])OrderedHashSet[23]48
  • A set into 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 delete that 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 Map or Set at 2^24 = 16,777,216 entries: the next set throws RangeError: Map maximum size exceeded. Bun 1.4 has no such cap: here it ran out of memory past 50 million entries instead.

Costs & memory

OperationAverageNotes
get, set, has, deleteO(1)plus the cost of hashing the key: O(length) for a new string
sizeO(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)) lookupsiterates 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 keyO(length) per lookupbuilds 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}` keys211 ms
the same, r * cols + c number keys55 ms
the same, a Uint8Array of flagsunder 1 ms
letter counts of 1.75 million characters, Map54 ms
the same, Int32Array(26) indexed by charCodeAt(i) - 974 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).

JobTypeScriptWatch outMore
Count itemsm.set(x, (m.get(x) ?? 0) + 1)no Counter: write a counter helperCounter & 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 itselfHash maps & sets
Value → indexnew 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 keyMap.groupBy(words, (w) => [...w].sort().join(""))the key must be a string or number: an array key never matchesGroup anagrams
Visited setseen.add(v) when v is enqueuedmarking on dequeue lets one node enter the queue many timesBFS
Grid cellsr * cols + c in a Set<number>, or a Uint8Array`${r},${c}` works but is about 4 times slowerKeys & equality
Duplicatesnew Set(xs).size !== xs.lengthreads all of xs; loop with has and add to stop at the first repeatHash maps & sets
Common to every arrayarrs.map((a) => new Set(a)).reduce((x, y) => x.intersection(y))reduce without a seed throws on an empty list
Cycle detectionwhile (!seen.has(x)) { seen.add(x); x = step(x); }O(n) memory; fast and slow pointers need O(1)Fast & slow pointers
Small universean integer bitmask32 bits at most, and 1 << 31 is negativeBitmasks as sets
Adjacency listMap<number, number[]>, push both ways if undirectedadj.get(v) ?? [] for nodes without edgesSetup, Graphs
Prefix-sum countsnew Map([[0, 1]]), add count.get(pre - k) ?? 0, then count prethe [0, 1] seed counts subarrays that start at index 0Prefix sums
Window, last-seen indexlo = 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 backSliding window
Window, distinct countdecrement, and delete the key at zeroa zero count is still a key, so size overcountsSliding window
Memoizationmemo.get(i * (n + 1) + j), or a `${i},${j}` keyan array key never hitsTop-down DP
LRU cacherefresh with m.delete(k); m.set(k, v); evict m.keys().next().valueset on an existing key does not move itLRU cache
Top k frequentcount, then buckets indexed by countsorting the entries is O(n log n)Top k frequent
Union-findInt32Array parents for ids 0..n-1, a Map for anything elseunion by size and path halving keep it near O(1)Union-find
TrieMap<string, TrieNode> children per nodean object's "constructor" child would already existTries
Longest consecutive runs = 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 keysexact keys: a slope as `${dy / g},${dx / g}` with g the gcd0.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); // 2
nums [0] 1 [1] 1 [2] 1 slice(0, 2), slice(1, 3) count pre = 0 ans = 0 0 => 1 pre = 1 ans = 0 0 => 1 1 => 1 count.get(-1): undefined, ans += 0 then count.set(1, 1) pre = 2 ans = 1 0 => 1 1 => 1 2 => 1 count.get(0): 1, so ans += 1 then count.set(2, 1) pre = 3 ans = 2 0 => 1 1 => 1 2 => 1 3 => 1 count.get(1): 1, so ans += 1 then count.set(3, 1) pre = 3 ans = 2 0 => 1 1 => 1 2 => 1 3 => 1 k = 2; count starts as Map {0 => 1} 2 subarrays sum to k new key found missing

1/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

TypeAcceptsUse for
Map<K, V>, Set<T>maps and setsvalues you build and mutate; annotate empty ones
ReadonlyMap<K, V>, ReadonlySet<T>any Map or Setparameters you only read: no set, add, delete or clear
Record<K, V>objectsrecords 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 acceptsconstructor-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"); // 7

A 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); // 2

Mixed 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

PythonTypeScript
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 dm.has(k); k in m checks the Map object's properties, not its keys
d[k] = vm.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 key1 and 1.0 are one number; true is a different key
changing a dict while looping raisesa Map never throws: see Iterating
set(), {1, 2}new Set(), new Set([1, 2])
a | b, a & b, a - b, a ^ ba.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 keyno hashable set: a sorted, joined string, or a bitmask
weakref.WeakKeyDictionaryWeakMap
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 entry

Recipes

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); // 4
keys oldest newest capacity 2 const cache = new LRUCache(2) cache.put(1, 1) 1 => 1 cache.put(2, 2) 1 => 1 2 => 2 cache.get(1) → 1 2 => 2 1 => 1 cache.put(3, 3) → evicts 2 1 => 1 3 => 3 2 => 2 evicted cache.get(2) → undefined 1 => 1 3 => 3 cache.put(4, 4) → evicts 1 3 => 3 4 => 4 1 => 1 evicted cache.get(1) → undefined 3 => 3 4 => 4 cache.get(3) → 3 4 => 4 3 => 3 cache.get(4) → 4 3 => 3 4 => 4 touched evicted the Map's first key is always the least recently used

1/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 4

Loop 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"); // -1

The 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); // false

Isomorphic 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 b

One 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); // false

The 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"); // false

References