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
| Form | Result |
|---|---|
{ 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; // 2Object.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; // {}| Operator | Falls back when the left side is | Example |
|---|---|---|
a ?? b | null or undefined | count ?? 10 keeps 0 |
a || b | any falsy value: 0, "", false, NaN, nullish | name || "anon" |
a ??= b | assigns only if a is nullish | memo[k] ??= solve(k) |
a ||= b | assigns only if a is falsy | title ||= "Untitled" |
a &&= b | assigns only if a is truthy | user &&= 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.
| Job | Object | Notes |
|---|---|---|
| create | {}, or Object.create(null) | the second has no inherited keys |
| read | o[k] | undefined when missing, but an inherited value when k names one |
| read with a default | o[k] ?? 0 | |
| write | o[k] = v | k is converted to a string |
| has | Object.hasOwn(o, k) | k in o also finds inherited keys |
| delete | delete o[k] | returns true even when k was missing; slows V8 objects down |
| size | Object.keys(o).length | O(n): it builds an array of every key |
| clear | o = {} |
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.
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; // falseOn 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.
| Check | Own only | Includes inherited | Notes |
|---|---|---|---|
Object.hasOwn(o, k) | yes | no | the one to use (ES2022) |
o.hasOwnProperty(k) | yes | no | not on Object.create(null) objects, and a key named hasOwnProperty shadows it |
k in o | no | yes | also narrows unions in TypeScript |
o[k] !== undefined | no | yes | wrong 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"]; // 5For 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):
- array indexes (
"0"to"4294967294") in ascending numeric order, - every other string key, in insertion order,
- symbols, in insertion order (only
Reflect.ownKeysandObject.getOwnPropertySymbolslist 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.
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"]1/5
const a = { n: 1, tags: ["x"] } makes two objects: the outer object, and the array that its tags property points to.
| Technique | Depth | Prototype kept | Getters | Symbol keys | Date, Map, Set | Cycles | Functions |
|---|---|---|---|---|---|---|---|
{ ...o } | shallow | no | value copied | yes | shared | n/a | shared |
Object.assign(t, o) | shallow | t's | value copied; runs t's setters | yes | shared | n/a | shared |
structuredClone(o) | deep | no: class instances come back plain | value copied | dropped | cloned | kept | throw DataCloneError |
JSON.parse(JSON.stringify(o)) | deep | no | value copied | dropped | Date becomes a string, Map becomes {} | throws TypeError | dropped |
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.is | SameValueZero (Map, Set, includes) |
|---|---|---|---|---|
NaN, NaN | false | false | true | true |
0, -0 | true | true | false | true |
null, undefined | true | false | false | false |
"1", 1 | true | false | false | false |
{}, {} | false | false | false | false |
a, a (one reference) | true | true | true | true |
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.
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] }); // falseshallowEqual 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| API | Own | Inherited | Non-enumerable | Symbols |
|---|---|---|---|---|
for...in | yes | yes | no | no |
Object.keys, values, entries | yes | no | no | no |
Object.getOwnPropertyNames | yes | no | yes | no |
Object.getOwnPropertySymbols | yes | no | yes | only |
Reflect.ownKeys | yes | no | yes | yes |
for...inwalks inherited enumerable keys too, and its keys are always strings, even on arrays ("0","1"). Preferfor...ofoverObject.entries.Object.keysreturnsstring[], 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...inwalks 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 overObject.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; // 41/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 constructor | same map |
{ x, y } built in the order x, y and in the order y, x | different maps |
the literal { x: 1, y: 2 } and {} filled with .x, .y | different maps: literals start from their own map |
delete of any property, even the last one added | dictionary mode |
o.k = undefined instead of delete o.k | keeps the map |
{} filled with 19 distinct computed string keys, then a 20th | dictionary 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.
| Operation | Cost | Notes |
|---|---|---|
o.k, o[k] read or write | O(1) average | a fixed offset for stable shapes, a hash lookup in dictionary mode |
Object.hasOwn(o, k), k in o | O(1) average | in may walk the prototype chain too |
delete o[k] | O(1) average | V8 objects drop to dictionary mode |
Object.keys, values, entries | O(n) | a new array every call: don't use it in a loop for the size |
{ ...o }, Object.assign | O(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| Operation | preventExtensions | seal | freeze |
|---|---|---|---|
| add properties | no | no | no |
| delete properties | yes | no | no |
| change values | yes | yes | no |
| reconfigure descriptors | yes | no | no |
| check | !Object.isExtensible(o) | Object.isSealed(o) | Object.isFrozen(o) |
| TypeScript return type | T | T | Readonly<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| Fact | Detail |
|---|---|
| getter only | typed readonly; assigning throws in strict mode |
spread, Object.assign, structuredClone | call the getter and copy the value |
JSON.stringify | serializes own enumerable getters on literals, not class getters (they live on the prototype) |
| getter and setter types | may differ (TypeScript 5.1+) |
| on an existing object | Object.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"}'| Value | In an object | In an array | Top level |
|---|---|---|---|
undefined, a function, a symbol | key omitted | null | returns undefined |
NaN, Infinity | null | null | "null" |
Date | ISO string (via toJSON) | ISO string | ISO string |
Map, Set | {} | {} | "{}" |
BigInt | throws TypeError | throws | throws |
| a circular reference | throws TypeError | throws | throws |
| symbol keys, non-enumerable properties | ignored | n/a | n/a |
an object with toJSON() | its return value | same | same |
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; // trueJSON 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
| Feature | Object | Map |
|---|---|---|
| key types | string, symbol; numbers become strings | any value, compared by SameValueZero |
| order | array indexes first, then insertion order | insertion order |
| size | Object.keys(o).length, O(n) | m.size, O(1) |
| iteration | through Object.entries(o) | directly: for (const [k, v] of m) |
| inherited keys | yes, unless Object.create(null) | none |
| JSON | native | convert with Object.fromEntries(m) |
| many adds and deletes | slower (dictionary mode) | built for it |
| TypeScript read type | V (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"); // undefined1/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.
| Job | TypeScript | Watch out | More |
|---|---|---|---|
| Count strings | c[w] = (c[w] ?? 0) + 1 on Object.create(null) | on {}, "constructor" and "__proto__" break the count | Objects as dictionaries |
| Count small ints or letters | new Int32Array(26), c[ch.charCodeAt(0) - 97]++ | beats any object or Map | Counter equivalents |
| Complement lookup (two-sum) | const seen: Record<number, number> = {}; read seen[target - x], then store x | numeric keys come back as strings from Object.keys | Hash maps & sets |
| Group by a key | Object.groupBy(xs, keyFn) | groups are T[] | undefined; the result has no prototype | Group anagrams |
| Grid cell as a key | `${r},${c}`, or r * cols + c | `${r}${c}` without a separator collides | Keys are strings |
| Memoization | memo[key] ??= solve(...) with a string key | ??= recomputes a cached undefined or null; check Object.hasOwn or use a Map when those are real answers | Top-down DP |
| Visited set | seen[key] = true | a Set says the same thing with has and add | Maps & sets |
| First unique, first repeated | count, then rescan the input in order | Object.keys puts array-index keys first | Key order |
| Adjacency list by name | const g: Record<string, string[]> = {}, (g[u] ??= []).push(v) | names like "constructor" need a null-prototype object | Graph representations |
| Trie | nested { children, end } nodes | word-sized keys need Object.create(null) or a Map | Tries |
| Records in a heap or queue | { dist, node } objects from one factory | build every record with the same keys in the same order | Shapes & performance |
| Sort records by several fields | xs.sort((a, b) => a.cost - b.cost || a.id - b.id) | || falls through only on 0, a tie | Arrays |
| State snapshots (BFS over states) | structuredClone(state), or rebuild with spread | spread is shallow: nested arrays stay shared | References, copying & equality |
| Lookup tables | const DIRS = { up: [-1, 0], ... } as const | as const is deep and type-only; Object.freeze is shallow | Typing 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"]| Type | Accepts |
|---|---|
object | any non-primitive |
{} | anything except null and undefined, even 1 |
Object | same 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; // 0Lookup 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; // 1Excess 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 theresatisfies 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?: T | yes | yes (no with exactOptionalPropertyTypes) |
a?: T | undefined | yes | yes |
a: T | undefined | no | yes |
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| Pattern | Meaning |
|---|---|
[K in keyof T] | every key of T, modifiers kept |
[K in U] | every member of a union U |
readonly, -readonly | add, remove readonly |
?, -? | add, remove optional |
as NewKey | rename the key (4.1+) |
as ... ? K : never | filter 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
| Tool | Level | Depth | Notes |
|---|---|---|---|
readonly property | types only | shallow | erased at runtime |
Readonly<T> | types only | shallow | every property readonly |
readonly T[], ReadonlyMap, ReadonlySet | types only | shallow | hides the mutating methods |
as const | types only | deep | literal types, readonly all the way down |
Object.freeze | runtime and types | shallow | returns Readonly<T> |
structuredClone, then edit | runtime | deep | copy-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 copyThe non-mutating array methods (toSorted, toReversed, toSpliced, with) are in
Arrays.
Coming from Python
| Python | TypeScript |
|---|---|
d = {}, d[k] = v | const o: Record<string, V> = {}, or a Map for non-string keys |
d.get(k, 0) | o[k] ?? 0 |
k in d | Object.hasOwn(o, k); k in o also finds inherited keys such as "toString" |
d[k] raising KeyError | o[k] is undefined, no error |
1 and "1" are different keys | o[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 key | array-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 loop | Object.groupBy(xs, keyFn) |
TypedDict, @dataclass | type or interface; a class when you need methods |
NamedTuple | an 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; // 4Group 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"); // trueWord 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"); // trueBoth 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); // trueIt 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
- MDN: Working with objects (opens in a new tab)
- MDN:
Object(opens in a new tab),Object.groupBy(opens in a new tab),Object.hasOwn(opens in a new tab),structuredClone(opens in a new tab),JSON.stringify(opens in a new tab) - MDN:
Object.prototype.__proto__(opens in a new tab) - MDN: Equality comparisons and sameness (opens in a new tab)
- MDN: Enumerability and ownership of properties (opens in a new tab)
- ECMAScript: OrdinaryOwnPropertyKeys (opens in a new tab) (the key order)
- V8: Fast properties in V8 (opens in a new tab) (hidden classes, dictionary mode)
- TypeScript Handbook: Object Types (opens in a new tab), Mapped Types (opens in a new tab),
noUncheckedIndexedAccess(opens in a new tab) - Zod (opens in a new tab)