../

Objects

Plain objects in TypeScript 7.0 (also checked with 6.0) under strict, with ES2025 built-ins, run on Node 24 LTS (also Node 26 and Bun 1.4): creating and reading them, objects as dictionaries and the inherited keys that break them, key order, references and copies, shapes and costs, freezing, JSON, tries, the object idioms DS&A solutions are built from, and the TypeScript side (index signatures, Record, mapped types, immutability). Hash maps keyed by anything are in Maps & sets, basic types and generics in Fundamentals, and the Python side in Dictionaries & sets.

Creating

FormResult
{ a: 1 }plain object; its prototype is Object.prototype
{ a }shorthand for { a: a }
{ [expr]: v }computed key: a string, a number (stored as a string) or a symbol
{ m() {} }method shorthand; can use super
{ __proto__: p }literal whose prototype is p
Object.create(p)empty object whose prototype is p
Object.create(null)no prototype: no inherited keys, so a safe dictionary
Object.fromEntries(pairs)from [key, value] pairs: an array, a Map, any iterable
Object.groupBy(items, fn)null-prototype object of arrays (ES2024)
new Foo()a class instance, see OOP
const name = "Ada";
const key = "role";
 
const user = {
  name, // shorthand for name: name
  [key]: "admin", // computed key
  "first-name": "Ada", // quoted key
  greet() {
    return `Hi ${this.name}`;
  },
};
user.greet(); // "Hi Ada"
user.role; // "admin"
 
const proto = { hello: () => "hi" };
const child: typeof proto = Object.create(proto);
child.hello(); // "hi": found on the prototype
 
const prices = new Map([["apple", 1.2], ["pear", 0.8]]);
const obj = Object.fromEntries(prices);
obj; // { apple: 1.2, pear: 0.8 }
const doubled = Object.fromEntries(
  Object.entries(obj).map(([k, v]) => [k, v * 2]),
);
doubled; // { apple: 2.4, pear: 1.6 }
 
const people = [
  { name: "Ada", team: "core" },
  { name: "Lin", team: "web" },
  { name: "Max", team: "core" },
];
const byTeam = Object.groupBy(people, (p) => p.team);
byTeam.core?.length; // 2

Object.groupBy returns Partial<Record<K, T[]>>, so every group read is T[] | undefined. It runs on Node 21+ and every current browser, and needs lib set to es2024 or later.

Accessing & destructuring

type User = {
  name: string;
  address?: { city?: string };
  tags?: string[];
  onSave?: () => void;
};
const u: User = { name: "Ada", address: {} };
 
u.name; // "Ada": dot access
u["name"]; // "Ada": bracket access takes any expression
u.address?.city; // undefined: no error
u.tags?.[0]; // undefined: optional element access
u.onSave?.(); // undefined: the call is skipped
 
const city = u.address?.city ?? "Unknown";
city; // "Unknown"
 
const {
  name,
  address: { city: c = "?" } = {}, // nested, defaults
  ...rest // everything else
} = u;
c; // "?"
rest; // {}
OperatorFalls back when the left side isExample
a ?? bnull or undefinedcount ?? 10 keeps 0
a || bany falsy value: 0, "", false, NaN, nullishname || "anon"
a ??= bassigns only if a is nullishmemo[k] ??= solve(k)
a ||= bassigns only if a is falsytitle ||= "Untitled"
a &&= bassigns only if a is truthyuser &&= clean(user)

Objects as dictionaries

A plain object maps string keys to values, and many solutions use one as a hash map: counts, memos, visited marks, adjacency lists. Three things make it a leaky one: every key is a string, keys are also looked up on the prototype, and __proto__ is not an ordinary key.

JobObjectNotes
create{}, or Object.create(null)the second has no inherited keys
reado[k]undefined when missing, but an inherited value when k names one
read with a defaulto[k] ?? 0
writeo[k] = vk is converted to a string
hasObject.hasOwn(o, k)k in o also finds inherited keys
deletedelete o[k]returns true even when k was missing; slows V8 objects down
sizeObject.keys(o).lengthO(n): it builds an array of every key
clearo = {}
const words = ["the", "constructor", "the"];
const counts: Record<string, number> = {};
for (const w of words) {
  counts[w] = (counts[w] ?? 0) + 1;
}
counts.the; // 2
counts["constructor"];
// "function Object() { [native code] }1"

counts["constructor"] was not missing: it found Object.prototype.constructor, the Object function, and + 1 turned it into a string. TypeScript types it as number all along.

const counts: Record<string, number> = {} counts["the"] = (counts["the"] ?? 0) + 1 counts["constructor"] ?? 0 counts["constructor"] = (... ?? 0) + 1 const safe = Object.create(null) counts own keys (none) the: 1 constructor: "function Object() { [native code] }1" Object.prototype constructor Object() toString function hasOwnProperty function __proto__ getter/setter ... and 8 more null [[Prototype]] safe no prototype constructor: 1 no own keys; the chain is counts, Object.prototype, null not found anywhere: undefined ?? 0 is 0, so the count is 1 found on the prototype: the Object function, not nullish function + 1 joins strings: an own key now hides the inherited one searched: counts, Object.prototype, null

1/5

counts = {} has no own keys, but its [[Prototype]] link points to Object.prototype, which holds constructor, toString, hasOwnProperty and more. Its own prototype is null.

const tally: Record<string, number> = {};
tally["__proto__"] = (tally["__proto__"] ?? 0) + 1;
Object.keys(tally); // []: the count was lost
 
const safe: Record<string, number> = Object.create(null);
for (const w of ["constructor", "__proto__", "the"]) {
  safe[w] = (safe[w] ?? 0) + 1;
}
Object.keys(safe); // ["constructor", "__proto__", "the"]
 
const plain = {};
"toString" in plain; // true: inherited
Object.hasOwn(plain, "toString"); // false
"toString" in safe; // false

On an ordinary object, __proto__ is an accessor inherited from Object.prototype: reading it returns the prototype, and assigning a non-object to it is silently ignored. A null-prototype object has no such accessor, so "__proto__" is a plain key there.

CheckOwn onlyIncludes inheritedNotes
Object.hasOwn(o, k)yesnothe one to use (ES2022)
o.hasOwnProperty(k)yesnonot on Object.create(null) objects, and a key named hasOwnProperty shadows it
k in onoyesalso narrows unions in TypeScript
o[k] !== undefinednoyeswrong when the stored value is undefined

Keys are strings

Every non-symbol key is converted with String(key), so 1 and "1" are one key, and an array key becomes its comma-joined text.

const byId: Record<string, string> = {};
byId[1] = "a"; // stored under "1"
byId["1"]; // "a"
Object.keys(byId); // ["1"]
 
const grid: Record<string, number> = {};
const cell = [1, 2];
// @ts-expect-error: an array can't be an index type
grid[cell] = 5; // stored under "1,2"
grid["1,2"]; // 5

For a composite key, build the string yourself, with a separator: `${r},${c}`. Without one, `${1}${23}` and `${12}${3}` are both "123". For small grids, the number r * cols + c is faster still and works as an array index.

Switch to a Map when keys are not strings (numbers you want back as numbers, objects, tuples of ids), come from user input, are added and deleted all the time, or when you need the size often. See Objects vs Map.

Key order

Object.keys, for...in, JSON.stringify and spread all list an object's own keys in one fixed order (the spec's OrdinaryOwnPropertyKeys):

  1. array indexes ("0" to "4294967294") in ascending numeric order,
  2. every other string key, in insertion order,
  3. symbols, in insertion order (only Reflect.ownKeys and Object.getOwnPropertySymbols list them).
const o = { b: 1, 2: "x", a: 2, 1: "y" };
Object.keys(o); // ["1", "2", "b", "a"]
 
const ids = { 10: "x", 9: "y", "-1": "z", "01": "w" };
Object.keys(ids); // ["9", "10", "-1", "01"]

"-1" and "01" aren't array indexes (an index is the canonical text of a whole number below 2³² − 1), so they keep their insertion order.

const o = { b: 1, 2: "x", a: 2, 1: "y" } insertion order "b" 1. "2" 2. "a" 3. "1" 4. Object.keys(o) "1" "2" "b" "a" symbols indexes, ascending strings, as inserted ownKeys only
Insertion order on top, Object.keys order below

The order breaks "first unique" scans that loop over a count object's keys:

function firstUnique(s: string): string | undefined {
  const count: Record<string, number> = {};
  for (const ch of s) count[ch] = (count[ch] ?? 0) + 1;
  // wrong: array-index keys come out first
  return Object.keys(count).find((k) => count[k] === 1);
}
firstUnique("ba1"); // "1", though "b" came first
 
function firstUniqueFixed(s: string): string | undefined {
  const count: Record<string, number> = {};
  for (const ch of s) count[ch] = (count[ch] ?? 0) + 1;
  return [...s].find((ch) => count[ch] === 1); // in s order
}
firstUniqueFixed("ba1"); // "b"

Rescan the input, or use a Map, which keeps insertion order for every key.

References, copying & equality

A variable holds a reference. Assigning or passing an object never copies it; === asks whether two references point to the same object.

const a = { n: 1, tags: ["x"] };
const b = a; // the same object
b.n = 2;
a.n; // 2
a === b; // true
 
const c = { ...a }; // a new object, the same tags array
c.tags.push("y");
a.tags; // ["x", "y"]
a === c; // false
a.tags === c.tags; // true
 
const d = structuredClone(a); // copies all the way down
d.tags.push("z");
a.tags; // ["x", "y"]
d.tags; // ["x", "y", "z"]
const a = { n: 1, tags: ["x"] } const b = a; b.n = 2 const c = { ...a } c.tags.push("y") const d = structuredClone(a); d.tags.push("z") n: 1 n: 2 tags: a b "x" "y" "y" n: 2 tags: c n: 2 tags: d "x" "y" "z" two objects: the outer one and its tags array a === b: true one object, two names a === c: false a.tags === c.tags: true the shared array changed: a.tags is ["x", "y"] d has its own array: a.tags stays ["x", "y"]

1/5

const a = { n: 1, tags: ["x"] } makes two objects: the outer object, and the array that its tags property points to.

TechniqueDepthPrototype keptGettersSymbol keysDate, Map, SetCyclesFunctions
{ ...o }shallownovalue copiedyessharedn/ashared
Object.assign(t, o)shallowt'svalue copied; runs t's settersyessharedn/ashared
structuredClone(o)deepno: class instances come back plainvalue copieddroppedclonedkeptthrow DataCloneError
JSON.parse(JSON.stringify(o))deepnovalue copieddroppedDate becomes a string, Map becomes {}throws TypeErrordropped
const base = { a: 1, nested: { b: 2 } };
const merged = { ...base, a: 9, c: 3 }; // later keys win
merged; // { a: 9, nested: { b: 2 }, c: 3 }
const target = { z: 0 };
Object.assign(target, base); // mutates and returns target
target; // { z: 0, a: 1, nested: { b: 2 } }
 
const verbose = true;
const opts = { ...(verbose && { log: true }) };
opts; // { log: true }: spreading false adds nothing
 
class Pt {
  constructor(public x: number) {}
}
const pc = structuredClone(new Pt(2));
pc instanceof Pt; // false: a plain { x: 2 }

Spread and Object.assign copy only own enumerable properties.

Equality

Objects compare by reference: two identical-looking literals are never ===.

Comparison=====Object.isSameValueZero (Map, Set, includes)
NaN, NaNfalsefalsetruetrue
0, -0truetruefalsetrue
null, undefinedtruefalsefalsefalse
"1", 1truefalsefalsefalse
{}, {}falsefalsefalsefalse
a, a (one reference)truetruetruetrue

There is no built-in deep equality. Node has util.isDeepStrictEqual(a, b) (from node:util), Bun has Bun.deepEquals(a, b), test runners have expect(a).toEqual(b), and JSON.stringify(a) === JSON.stringify(b) is a hack that depends on key order and drops undefined. A short deep equal for plain data is in the recipes.

shallow-equal.ts
function shallowEqual(
  a: Record<string, unknown>,
  b: Record<string, unknown>,
): boolean {
  const ka = Object.keys(a);
  if (ka.length !== Object.keys(b).length) return false;
  return ka.every(
    (k) => Object.hasOwn(b, k) && Object.is(a[k], b[k]),
  );
}
shallowEqual({ x: 1 }, { x: 1 }); // true
shallowEqual({ x: [1] }, { x: [1] }); // false

shallowEqual is how React's memo and many state libraries decide whether props changed.

Iterating

const scores = { ada: 3, lin: 5, max: 4 };
 
Object.keys(scores); // ["ada", "lin", "max"]
Object.values(scores); // [3, 5, 4]
Object.entries(scores);
// [["ada", 3], ["lin", 5], ["max", 4]]
 
// sort entries by value, largest first
const ranked = Object.entries(scores)
  .sort(([, x], [, y]) => y - x)
  .map(([who]) => who);
ranked; // ["lin", "max", "ada"]
 
let total = 0;
for (const [, n] of Object.entries(scores)) total += n;
total; // 12
APIOwnInheritedNon-enumerableSymbols
for...inyesyesnono
Object.keys, values, entriesyesnonono
Object.getOwnPropertyNamesyesnoyesno
Object.getOwnPropertySymbolsyesnoyesonly
Reflect.ownKeysyesnoyesyes
  • for...in walks inherited enumerable keys too, and its keys are always strings, even on arrays ("0", "1"). Prefer for...of over Object.entries.
  • Object.keys returns string[], not (keyof T)[]: TypeScript types are open, so a value typed { a: number } may carry more keys at runtime. Cast only when you built the object yourself.
  • Changing an object while for...in walks it is allowed but loosely specified: a key deleted before it is reached is skipped, and a key added during the walk may or may not be visited. Loop over Object.keys(o), a snapshot, when you mutate.

Shapes & performance

Engines don't store most objects as hash tables. V8 gives each object a hidden class (V8 calls it a map; other engines say shape or structure) that records which keys it has and at which offset, and objects built with the same keys in the same order share one. A property read at a given line of code is fast while that line keeps seeing the same few shapes. None of this is in the spec; the facts below were measured on Node 24 and 26 with --allow-natives-syntax (%HaveSameMap, %HasFastProperties).

type Pt = { x: number; y: number };
// every Pt from this factory has one shape
const pt = (x: number, y: number): Pt => ({ x, y });
 
const p: Partial<Pt> = {};
p.x = 1;
p.y = 2; // shapes: empty, then x, then x and y
const r: Partial<Pt> = {};
r.y = 1;
r.x = 2; // another branch: y, then y and x
pt(1, 2).x + pt(3, 4).x; // 4
const p = {} p.x = 1 p.y = 2 const q = {}; q.x = 5; q.y = 6 const r = {}; r.y = 1; r.x = 2 delete p.x M0 no keys M1 x: slot 0 M2 x: 0, y: 1 M3 y: slot 0 M4 y: 0, x: 1 +x +y +y +x p p p q r p: dictionary mode, its own hash table p every {} literal starts at the same map, M0 a new key: a new map, and a recorded transition M0 to M1 p now has M2: x at slot 0, y at slot 1 same keys, same order: %HaveSameMap(p, q) is true same keys, other order: %HaveSameMap(p, r) is false

1/6

const p = {} starts at the shared map of empty object literals, M0: no keys yet.

Measured on V8 (Node 24, 26)Result
two objects built by the same factory or class constructorsame map
{ x, y } built in the order x, y and in the order y, xdifferent maps
the literal { x: 1, y: 2 } and {} filled with .x, .ydifferent maps: literals start from their own map
delete of any property, even the last one addeddictionary mode
o.k = undefined instead of delete o.kkeeps the map
{} filled with 19 distinct computed string keys, then a 20thdictionary mode from the 20th
integer keys (o[5] = x)kept in a separate elements store, not in the map

Dictionary mode is a hash table, which is exactly what a count or memo object needs, so it is no reason to avoid objects as dictionaries. It is a reason to build records (points, nodes, states) the same way every time, and to skip delete on them.

OperationCostNotes
o.k, o[k] read or writeO(1) averagea fixed offset for stable shapes, a hash lookup in dictionary mode
Object.hasOwn(o, k), k in oO(1) averagein may walk the prototype chain too
delete o[k]O(1) averageV8 objects drop to dictionary mode
Object.keys, values, entriesO(n)a new array every call: don't use it in a loop for the size
{ ...o }, Object.assignO(n)shallow
structuredClone(o), JSON.stringify(o)O(size of the whole graph)

Counting 2,000,000 strings over 10,000 distinct keys took about 65 ms with {} and 80 ms with a Map on Node 24; with integer keys, {} took 50 ms, a Map 90 ms and an Int32Array 30 ms. With 200,000 adds and deletes, the Map was twice as fast. On Bun 1.4, {} with string keys took 230 ms, but Object.create(null) took 60 ms. Treat these as rough: rerun them on your target before choosing for speed.

Descriptors, freeze & seal

Every property has a descriptor. Data properties have value and writable, accessor properties get and set, and both have enumerable and configurable.

const acct = { id: 1 };
Object.defineProperty(acct, "secret", {
  value: "s3cr3t",
  enumerable: false, // hidden from keys, JSON, spread
});
Object.keys(acct); // ["id"]
const idDesc = Object.getOwnPropertyDescriptor(acct, "id");
idDesc?.writable; // true
const sd = Object.getOwnPropertyDescriptor(acct, "secret");
sd?.writable; // false: defineProperty defaults to false
OperationpreventExtensionssealfreeze
add propertiesnonono
delete propertiesyesnono
change valuesyesyesno
reconfigure descriptorsyesnono
check!Object.isExtensible(o)Object.isSealed(o)Object.isFrozen(o)
TypeScript return typeTTReadonly<T>
const DIRS = Object.freeze({
  up: [-1, 0],
  down: [1, 0],
});
// @ts-expect-error: up is readonly
DIRS.up = [0, 0]; // throws TypeError in strict mode
DIRS.up.push(9); // allowed: freeze is shallow
DIRS.up; // [-1, 0, 9]

Writes to frozen or sealed objects throw TypeError in strict code (every ES module and class body) and are silently ignored in sloppy scripts. Freeze lookup tables such as direction vectors with as const instead when you only need the types: it is deep and costs nothing at runtime.

Getters & setters

const temp = {
  celsius: 20,
  get fahrenheit() {
    return (this.celsius * 9) / 5 + 32;
  },
  set fahrenheit(f: number) {
    this.celsius = ((f - 32) * 5) / 9;
  },
};
temp.fahrenheit = 212;
temp.celsius; // 100
 
class Size {
  #px = 0;
  get px(): number {
    return this.#px;
  }
  // a setter may accept a wider type than the getter
  set px(v: number | string) {
    this.#px = Number(v);
  }
  get label() {
    return `${this.#px}px`;
  }
}
const s = new Size();
s.px = "42";
s.px; // 42
// @ts-expect-error: a getter without a setter is readonly
s.label = "x"; // throws TypeError in strict mode
FactDetail
getter onlytyped readonly; assigning throws in strict mode
spread, Object.assign, structuredClonecall the getter and copy the value
JSON.stringifyserializes own enumerable getters on literals, not class getters (they live on the prototype)
getter and setter typesmay differ (TypeScript 5.1+)
on an existing objectObject.defineProperty(o, k, { get() {}, set(v) {} })

JSON

const payload = {
  id: 1,
  at: new Date(0),
  note: undefined,
  big: 10n,
};
const text = JSON.stringify(payload, (_key, value) =>
  typeof value === "bigint" ? value.toString() : value,
);
text;
// '{"id":1,"at":"1970-01-01T00:00:00.000Z","big":"10"}'
 
const back: unknown = JSON.parse(text);
JSON.stringify({ 1: "a" }); // '{"1":"a"}'
ValueIn an objectIn an arrayTop level
undefined, a function, a symbolkey omittednullreturns undefined
NaN, Infinitynullnull"null"
DateISO string (via toJSON)ISO stringISO string
Map, Set{}{}"{}"
BigIntthrows TypeErrorthrowsthrows
a circular referencethrows TypeErrorthrowsthrows
symbol keys, non-enumerable propertiesignoredn/an/a
an object with toJSON()its return valuesamesame

The replacer is (key, value) => newValue (return undefined to drop the key) or an allow-list array of keys; the third argument indents. A reviver runs bottom-up after parsing. Replacers see a Date after toJSON has made it a string.

JSON.parse returns any. Type the result unknown and validate it, for example with Zod:

import { z } from "zod";
 
const Entry = z.object({
  id: z.number(),
  at: z.coerce.date(),
  tags: z.array(z.string()).default([]),
});
type Entry = z.infer<typeof Entry>;
 
const json = '{"id":1,"at":"2026-01-01"}';
const entry: Entry = Entry.parse(JSON.parse(json));
entry.tags; // []
entry.at instanceof Date; // true

JSON as a key

JSON.stringify is a quick way to turn small plain data into a string key for a memo or a visited set, but it follows key order, so the same data built in a different order gives a different key. Sort the keys first when the order isn't fixed.

const ab = JSON.stringify({ a: 1, b: 2 });
const ba = JSON.stringify({ b: 2, a: 1 });
ab === ba; // false
 
const stableKey = (o: Record<string, unknown>): string =>
  JSON.stringify(Object.entries(o).sort(([x], [y]) =>
    x < y ? -1 : x > y ? 1 : 0,
  ));
stableKey({ b: 2, a: 1 }); // '[["a",1],["b",2]]'
stableKey({ a: 1, b: 2 }); // '[["a",1],["b",2]]'

Prefer a hand-built key such as `${i},${j}` in hot loops: it is shorter and much faster to make.

Objects vs Map

FeatureObjectMap
key typesstring, symbol; numbers become stringsany value, compared by SameValueZero
orderarray indexes first, then insertion orderinsertion order
sizeObject.keys(o).length, O(n)m.size, O(1)
iterationthrough Object.entries(o)directly: for (const [k, v] of m)
inherited keysyes, unless Object.create(null)none
JSONnativeconvert with Object.fromEntries(m)
many adds and deletesslower (dictionary mode)built for it
TypeScript read typeV (V | undefined with noUncheckedIndexedAccess)V | undefined

Use objects for records with a fixed shape (points, options, nodes, JSON) and for string-keyed tables built once; use a Map for open-ended dictionaries. The full story, with Set, WeakMap and the hash-map idioms, is in Maps & sets.

Tries from objects

A trie stores words one character per level; each node is an object whose children maps a character to the next node. Single-character keys are safe on ordinary objects: no property of Object.prototype has a one-character name. Tries keyed by whole words or path segments need Object.create(null) or a Map.

type TrieNode = {
  children: Record<string, TrieNode>;
  end: boolean; // a word ends here
};
const newNode = (): TrieNode => ({
  children: {},
  end: false,
});
 
function insert(root: TrieNode, word: string): void {
  let cur = root;
  for (const ch of word) {
    cur = cur.children[ch] ??= newNode();
  }
  cur.end = true;
}
 
function walk(root: TrieNode, s: string) {
  let cur: TrieNode | undefined = root;
  for (const ch of s) cur = cur?.children[ch];
  return cur; // undefined: no word starts with s
}
 
const root = newNode();
for (const w of ["cat", "car", "do"]) insert(root, w);
Object.keys(root.children); // ["c", "d"]
walk(root, "ca")?.end; // false: only a prefix
walk(root, "car")?.end; // true
walk(root, "cow"); // undefined
const root = newNode() insert(root, "cat") insert(root, "car") insert(root, "do") walk(root, "ca")?.end walk(root, "cow") "c" "a" "t" "r" "d" "o" root end end end end: false ? "o": undefined { children: {}, end: false } c, a and t are created; t.end = true c and a are reused; only r is new; r.end = true a second branch: d, then o; o.end = true ca is a prefix of cat and car, not a word: false c has no o child, so ?. makes the walk undefined

1/6

root = newNode() is { children: {}, end: false }: no words yet.

Insert and lookup cost O(length of the word), whatever the number of words stored. A trie class is in the recipes; trie problems are in Tries.

DS&A idioms

The object 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.

JobTypeScriptWatch outMore
Count stringsc[w] = (c[w] ?? 0) + 1 on Object.create(null)on {}, "constructor" and "__proto__" break the countObjects as dictionaries
Count small ints or lettersnew Int32Array(26), c[ch.charCodeAt(0) - 97]++beats any object or MapCounter equivalents
Complement lookup (two-sum)const seen: Record<number, number> = {}; read seen[target - x], then store xnumeric keys come back as strings from Object.keysHash maps & sets
Group by a keyObject.groupBy(xs, keyFn)groups are T[] | undefined; the result has no prototypeGroup anagrams
Grid cell as a key`${r},${c}`, or r * cols + c`${r}${c}` without a separator collidesKeys are strings
Memoizationmemo[key] ??= solve(...) with a string key??= recomputes a cached undefined or null; check Object.hasOwn or use a Map when those are real answersTop-down DP
Visited setseen[key] = truea Set says the same thing with has and addMaps & sets
First unique, first repeatedcount, then rescan the input in orderObject.keys puts array-index keys firstKey order
Adjacency list by nameconst g: Record<string, string[]> = {}, (g[u] ??= []).push(v)names like "constructor" need a null-prototype objectGraph representations
Trienested { children, end } nodesword-sized keys need Object.create(null) or a MapTries
Records in a heap or queue{ dist, node } objects from one factorybuild every record with the same keys in the same orderShapes & performance
Sort records by several fieldsxs.sort((a, b) => a.cost - b.cost || a.id - b.id)|| falls through only on 0, a tieArrays
State snapshots (BFS over states)structuredClone(state), or rebuild with spreadspread is shallow: nested arrays stay sharedReferences, copying & equality
Lookup tablesconst DIRS = { up: [-1, 0], ... } as constas const is deep and type-only; Object.freeze is shallowTyping objects
function twoSum(nums: number[], target: number): number[] {
  const seen: Record<number, number> = {}; // value -> index
  for (const [i, x] of nums.entries()) {
    const j = seen[target - x];
    if (j !== undefined) return [j, i];
    seen[x] = i; // after the check: no self-pairs
  }
  return [];
}
twoSum([3, 2, 4], 6); // [1, 2]
 
const g: Record<string, string[]> = Object.create(null);
for (const [u, v] of [["a", "b"], ["b", "c"]]) {
  (g[u] ??= []).push(v);
  (g[v] ??= []).push(u);
}
g["b"]; // ["a", "c"]

Typing objects

For type vs interface see Fundamentals.

interface Product {
  readonly sku: string; // no reassignment
  name: string;
  price?: number; // optional
  tags: readonly string[]; // a readonly array
}
 
// index signature: any string key
interface HeaderMap {
  [name: string]: string;
  "content-type": string; // known keys must fit it
}
 
// Record: a finite key set, or an open one
type Role = "admin" | "user";
const perms: Record<Role, string[]> = {
  admin: ["*"],
  user: ["read"],
};
const cache: Record<string, number> = {};
perms.user; // ["read"]
TypeAccepts
objectany non-primitive
{}anything except null and undefined, even 1
Objectsame as {}; avoid it
Record<string, unknown>an object with string keys: a good "some object" type
Record<K, V>exactly the keys in K, all required
Partial<Record<K, V>>any subset of K
{ [k: string]: V }same as Record<string, V>

Reading missing keys

With an index signature or Record<string, V>, o[k] is typed V, though it is undefined at runtime for a missing key. The noUncheckedIndexedAccess compiler option types it V | undefined instead, which forces a check or a default. DS&A code that always writes o[k] ?? fallback compiles and is correct either way. Properties declared by name (perms.user) are not affected by the option.

const freq: Record<string, number> = { a: 2 };
const n = freq["z"]; // number, but undefined at runtime
n === undefined; // true
const safeN = freq["z"] ?? 0; // 0 with or without the flag
safeN; // 0

Lookup tables with as const

const DIRS = {
  up: [-1, 0],
  down: [1, 0],
  left: [0, -1],
  right: [0, 1],
} as const;
type Dir = keyof typeof DIRS; // "up" | "down" | ...
 
function move(r: number, c: number, d: Dir) {
  const [dr, dc] = DIRS[d]; // readonly [-1, 0], ...
  return [r + dr, c + dc] as const;
}
move(2, 2, "left"); // [2, 1]

Recursive and tagged shapes

// a node type can mention itself
type Tree = { val: number; kids: Tree[] };
const t: Tree = { val: 1, kids: [{ val: 2, kids: [] }] };
 
// a tag property lets TypeScript narrow a union
type Shape =
  | { kind: "circle"; r: number }
  | { kind: "rect"; w: number; h: number };
const area = (s: Shape): number =>
  s.kind === "circle" ? Math.PI * s.r ** 2 : s.w * s.h;
area({ kind: "rect", w: 2, h: 3 }); // 6
t.kids.length; // 1

Excess property checks

Only fresh object literals are checked for unknown keys; the same object through a variable passes.

interface Point {
  x: number;
  y: number;
}
 
// @ts-expect-error: 'z' does not exist in type 'Point'
const p1: Point = { x: 1, y: 2, z: 3 };
 
const raw = { x: 1, y: 2, z: 3 };
const p2: Point = raw; // ok: structural, not fresh
p2; // { x: 1, y: 2, z: 3 }: z is still there

satisfies on objects

type Route = { path: `/${string}`; auth?: boolean };
 
const routes = {
  home: { path: "/" },
  admin: { path: "/admin", auth: true },
} satisfies Record<string, Route>;
 
routes.admin.auth; // true, typed boolean
// @ts-expect-error: no such route
routes.missing;

An annotation (: Record<string, Route>) would accept routes.missing and forget which keys exist.

Optional vs undefined

Declaration{} OK{ a: undefined } OK
a?: Tyesyes (no with exactOptionalPropertyTypes)
a?: T | undefinedyesyes
a: T | undefinednoyes

Mapped types & key remapping

A mapped type loops over a union of keys: { [K in Keys]: Type }. Over keyof T it is homomorphic and keeps the readonly and ? modifiers of T.

interface User {
  id: number;
  name: string;
  email?: string;
}
 
type Nullable<T> = { [K in keyof T]: T[K] | null };
 
// modifiers: + adds (the default), - removes
type Mutable<T> = { -readonly [K in keyof T]: T[K] };
type Complete<T> = { [K in keyof T]-?: T[K] };
type Frozen<T> = { +readonly [K in keyof T]: T[K] };
 
type C = Complete<User>; // email: string (required)

Key remapping with as

type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]:
    () => T[K];
};
type UG = Getters<{ id: number; name: string }>;
// getId: () => number; getName: () => string
 
// filter keys: map a key to never to drop it
type PickByValue<T, V> = {
  [K in keyof T as T[K] extends V ? K : never]: T[K];
};
type Strs = PickByValue<{ a: string; b: number }, string>;
// only a: string
 
// keys from a union, not from an object
type Handlers = {
  [E in "click" | "focus" as `on${Capitalize<E>}`]: (
    e: Event,
  ) => void;
};
// onClick and onFocus
PatternMeaning
[K in keyof T]every key of T, modifiers kept
[K in U]every member of a union U
readonly, -readonlyadd, remove readonly
?, -?add, remove optional
as NewKeyrename the key (4.1+)
as ... ? K : neverfilter keys out
as `prefix${string & K}`template literal keys (string & K drops symbols)
T[K]the property type for the current key

Handy compositions

interface User2 {
  id: number;
  name: string;
}
 
// make some keys optional
type Optional<T, K extends keyof T> = Omit<T, K> &
  Partial<Pick<T, K>>;
 
// flatten an intersection for nicer hovers
type Prettify<T> = { [K in keyof T]: T[K] } & {};
 
type NewUser = Prettify<Optional<User2, "id">>;
const nu: NewUser = { name: "Ada" }; // id?: number
nu.name; // "Ada"

Immutability patterns

ToolLevelDepthNotes
readonly propertytypes onlyshallowerased at runtime
Readonly<T>types onlyshallowevery property readonly
readonly T[], ReadonlyMap, ReadonlySettypes onlyshallowhides the mutating methods
as consttypes onlydeepliteral types, readonly all the way down
Object.freezeruntime and typesshallowreturns Readonly<T>
structuredClone, then editruntimedeepcopy-on-write by hand
type DeepReadonly<T> = T extends (
  ...args: never[]
) => unknown
  ? T
  : T extends object
    ? { readonly [K in keyof T]: DeepReadonly<T[K]> }
    : T;
 
type State = DeepReadonly<{
  user: { name: string; tags: string[] };
  count: number;
}>;
const state: State = {
  user: { name: "Ada", tags: ["b", "a"] },
  count: 0,
};
 
// update by copying the path you change
const next: State = {
  ...state,
  user: { ...state.user, tags: [...state.user.tags, "c"] },
  count: state.count + 1,
};
next.user.tags; // ["b", "a", "c"]
state.user.tags; // ["b", "a"]
state.user.tags.toSorted(); // ["a", "b"]: a sorted copy

The non-mutating array methods (toSorted, toReversed, toSpliced, with) are in Arrays.

Coming from Python

PythonTypeScript
d = {}, d[k] = vconst o: Record<string, V> = {}, or a Map for non-string keys
d.get(k, 0)o[k] ?? 0
k in dObject.hasOwn(o, k); k in o also finds inherited keys such as "toString"
d[k] raising KeyErroro[k] is undefined, no error
1 and "1" are different keyso[1] and o["1"] are the same key
(r, c) tuple keys`${r},${c}` string keys; an array key becomes "r,c" by accident
insertion order for every keyarray-index keys first, ascending; then insertion order
{**a, **b}, a | b{ ...a, ...b }
copy.copy(d), copy.deepcopy(d){ ...o }, structuredClone(o)
== compares contents=== compares references; no built-in deep equality
d.items(), d.keys(), d.values()Object.entries(o), Object.keys(o), Object.values(o): snapshots, not live views
defaultdict(list)(o[k] ??= []).push(x)
Counter(xs)a count loop on Object.create(null), or a Map
itertools.groupby after sorting, or a defaultdict loopObject.groupBy(xs, keyFn)
TypedDict, @dataclasstype or interface; a class when you need methods
NamedTuplean object type { x: number; y: number } or a labeled tuple
types.MappingProxyType(d)Readonly<T> (types only) or Object.freeze(o)
json.dumps(d, sort_keys=True)no sort_keys: sort the entries first, as in JSON as a key
getattr(o, name), hasattr(o, name)o[name], name in o

The Python side is in Dictionaries & sets and Tuples.

Recipes

Word frequency

When counting words or other strings that come from input.

function wordFreq(text: string): Record<string, number> {
  const count: Record<string, number> = Object.create(null);
  const words = text.toLowerCase().match(/[\w']+/g) ?? [];
  for (const w of words) count[w] = (count[w] ?? 0) + 1;
  return count;
}
const f = wordFreq("The constructor and the __proto__");
f["the"]; // 2
f["constructor"]; // 1
f["__proto__"]; // 1
Object.keys(f).length; // 4

Group anagrams

When bucketing strings by a canonical key.

function groupAnagrams(words: string[]): string[][] {
  const groups = Object.groupBy(words, (w) =>
    [...w].sort().join(""),
  );
  return Object.values(groups).filter((g) => !!g);
}
groupAnagrams(["eat", "tea", "tan", "ate", "nat", "bat"]);
// [["eat", "tea", "ate"], ["tan", "nat"], ["bat"]]

The groups come out in first-seen order of their keys, unless a key is an array index: sorted digit strings such as "12" would jump to the front.

Implement a trie

When you need insert, exact search and prefix search over many words.

type Node = { kids: Record<string, Node>; end: boolean };
 
class Trie {
  #root: Node = { kids: {}, end: false };
 
  insert(word: string): void {
    let cur = this.#root;
    for (const ch of word) {
      cur = cur.kids[ch] ??= { kids: {}, end: false };
    }
    cur.end = true;
  }
  #find(s: string): Node | undefined {
    let cur: Node | undefined = this.#root;
    for (const ch of s) cur = cur?.kids[ch];
    return cur;
  }
  search(word: string): boolean {
    return this.#find(word)?.end ?? false;
  }
  startsWith(prefix: string): boolean {
    return this.#find(prefix) !== undefined;
  }
}
const trie = new Trie();
trie.insert("apple");
trie.search("apple"); // true
trie.search("app"); // false
trie.startsWith("app"); // true

Word pattern

When two sequences must map one-to-one, like "abba" onto "dog cat cat dog".

function wordPattern(pattern: string, s: string): boolean {
  const words = s.split(" ");
  if (words.length !== pattern.length) return false;
  const toWord: Record<string, string> = Object.create(null);
  const toChar: Record<string, string> = Object.create(null);
  for (const [i, ch] of [...pattern].entries()) {
    const w = words[i] ?? "";
    if ((toWord[ch] ??= w) !== w) return false;
    if ((toChar[w] ??= ch) !== ch) return false;
  }
  return true;
}
wordPattern("abba", "dog cat cat dog"); // true
wordPattern("abba", "dog dog dog dog"); // false
wordPattern("ab", "constructor toString"); // true

Both directions are needed: with only toWord, "ab" would match "dog dog". The null-prototype objects keep words such as "constructor" from finding inherited values.

Deep equal

When comparing nested plain data (arrays, plain objects, primitives) by value.

function deepEqual(a: unknown, b: unknown): boolean {
  if (Object.is(a, b)) return true;
  if (typeof a !== "object" || typeof b !== "object") {
    return false;
  }
  if (a === null || b === null) return false;
  if (Array.isArray(a) !== Array.isArray(b)) return false;
  const ka = Object.keys(a);
  const kb = Object.keys(b);
  if (ka.length !== kb.length) return false;
  const ra = a as Record<string, unknown>;
  const rb = b as Record<string, unknown>;
  return ka.every(
    (k) => Object.hasOwn(rb, k) && deepEqual(ra[k], rb[k]),
  );
}
const x = { a: [1, { b: 2 }] };
deepEqual(x, { a: [1, { b: 2 }] }); // true
deepEqual({ a: 1, b: 2 }, { b: 2, a: 1 }); // true
deepEqual([1, 2], { 0: 1, 1: 2 }); // false
deepEqual(NaN, NaN); // true

It ignores prototypes, so a Date, Map or Set compares by its (empty) own keys: reach for util.isDeepStrictEqual or Bun.deepEquals when such values appear. Cyclic data recurses forever.

Flatten a nested object

When turning nested config into dotted keys, and back.

type Json = string | number | boolean | null | JsonObj;
type JsonObj = { [k: string]: Json };
 
function flatten(
  o: JsonObj,
  pre = "",
): Record<string, Json> {
  const out: Record<string, Json> = {};
  for (const [k, v] of Object.entries(o)) {
    const key = pre ? `${pre}.${k}` : k;
    if (v !== null && typeof v === "object") {
      Object.assign(out, flatten(v, key));
    } else out[key] = v;
  }
  return out;
}
 
function unflatten(flat: Record<string, Json>): JsonObj {
  const root: JsonObj = {};
  for (const [path, v] of Object.entries(flat)) {
    const parts = path.split(".");
    const last = parts.pop() ?? "";
    let cur = root;
    for (const p of parts) {
      const next = cur[p];
      cur = typeof next === "object" && next !== null
        ? next
        : (cur[p] = {});
    }
    cur[last] = v;
  }
  return root;
}
const cfg = { db: { host: "x", port: 5432 }, on: true };
const flat = flatten(cfg);
flat; // { "db.host": "x", "db.port": 5432, on: true }
unflatten(flat);
// { db: { host: "x", port: 5432 }, on: true }

Keys that contain dots don't survive the round trip; pick a separator that can't appear in a key.

Memoize on a string key

When a recursive function of small integers repeats work, as in counting grid paths.

function uniquePaths(rows: number, cols: number): number {
  const memo: Record<string, number> = {};
  const go = (r: number, c: number): number => {
    if (r === rows - 1 || c === cols - 1) return 1;
    const key = `${r},${c}`;
    return (memo[key] ??= go(r + 1, c) + go(r, c + 1));
  };
  return go(0, 0);
}
uniquePaths(3, 7); // 28
uniquePaths(10, 10); // 48620

??= evaluates the right side only when the key is missing, so each cell is solved once: O(rows × cols) calls instead of exponentially many. Recursion depth is rows + cols; for deep recursion see Call stack and depth limits.

Deep merge

When layering config objects and nested objects should merge instead of being replaced.

type Obj = Record<string, unknown>;
const isObj = (v: unknown): v is Obj =>
  typeof v === "object" && v !== null && !Array.isArray(v);
 
function deepMerge(a: Obj, b: Obj): Obj {
  const out: Obj = { ...a };
  for (const [k, v] of Object.entries(b)) {
    const old = out[k];
    out[k] = isObj(old) && isObj(v) ? deepMerge(old, v) : v;
  }
  return out;
}
const base = { db: { host: "x", port: 5432 }, debug: false };
const env = { db: { host: "prod" }, debug: true };
deepMerge(base, env);
// { db: { host: "prod", port: 5432 }, debug: true }

Values that aren't merged are shared with the inputs, not copied; structuredClone the result before mutating it. A merge that copies keys from untrusted JSON into an ordinary object can be fooled by a "__proto__" key; merge into Object.create(null) or skip that key.

References