Collections — Array, Map, Set & Friends

Which container, what each operation costs, and the traps that differ from Swift.

  • node
  • browser
  • typescript >=5.6
  • node >=22.18
  • any ES2023+ engine; the examples run on Node.js

Sources: Keyed collections — MDN JavaScript Guide · Indexed collections — MDN JavaScript Guide

Pick the container

The need decides the type. Plain objects are records, not dictionaries.

NeedUseWhy
Ordered list, index accessT[] / readonly T[]contiguous in practice; readonly removes the mutating methods from the type
Key → value, any key typeMap<K, V>keeps insertion order, no prototype keys, size is O(1)
Fixed record shapeobject / interfaceknown keys checked by the compiler; not a dictionary
Membership, dedupeSet<T>O(1) has; compares objects by identity
Numbers in bulkFloat64Array, Int32Array…fixed length, unboxed, no holes
Cache keyed by an objectWeakMap<object, V>entries vanish with their key; not iterable
FIFO queuearray + head index, or a ring buffershift() re-indexes the whole array: O(n)

What operations cost

The language specification does not mandate complexities; these are what V8, SpiderMonkey and JavaScriptCore deliver.

OperationArrayMap / Set
read by index / keyO(1)O(1) average
push / pop / set / addO(1) amortisedO(1) average
shift / unshift / splice in the middleO(n)—
includes / indexOf / hasO(n)O(1) average
sort / toSortedO(n log n), stable since ES2019sort [...map] then rebuild
deleteleaves a hole — use spliceO(1) average

Map for dictionaries, objects for records

A plain object inherits keys like `__proto__`; a Map has none and keeps insertion order.

const counts = new Map<string, number>();
for (const word of "the cat and the hat".split(" ")) {
  counts.set(word, (counts.get(word) ?? 0) + 1);
}
console.log([...counts]);                    // insertion order
console.log(counts.get("the"), counts.has("dog"));

const record: Record<string, number> = {};
record["__proto__"] = 1;                     // hits the inherited __proto__ setter: no key is added
console.log(Object.keys(record).length);
Result
[ [ 'the', 2 ], [ 'cat', 1 ], [ 'and', 1 ], [ 'hat', 1 ] ]
2 false
0
Setup
  1. Node.js 22.18 or later (it runs .mts files directly by stripping the types)
  2. Save as main.mts and run node main.mts
  3. Optional type-check: npm i -D typescript @types/node, then npx tsc --noEmit --strict --target esnext --module nodenext main.mts

Ran with Node 24.16.0, TypeScript 5.9.3 on 2026-11-11: the output matched the result shown.

Sources: Map — MDN

Set algebra, and identity

The ES2025 set methods return new sets. Two equal-looking objects are two members.

const a = new Set([1, 2, 3]);
const b = new Set([2, 3, 4]);
console.log([...a.union(b)], [...a.intersection(b)], [...a.difference(b)]);
console.log(a.isSubsetOf(new Set([1, 2, 3, 4])));

const points = new Set([{ x: 1 }, { x: 1 }]);   // compared by reference, not by value
console.log(points.size);
Result
[ 1, 2, 3, 4 ] [ 2, 3 ] [ 1 ]
true
2
Setup
  1. Node.js 22.18 or later (it runs .mts files directly by stripping the types)
  2. Save as main.mts and run node main.mts
  3. Optional type-check: npm i -D typescript @types/node, then npx tsc --noEmit --strict --target esnext --module nodenext main.mts

Ran with Node 24.16.0, TypeScript 5.9.3 on 2026-11-11: the output matched the result shown.

  • ecmascript 2025 (Set methods)
  • typescript >=5.5
  • node >=22

Sources: Set — MDN

The default sort compares strings

Always pass a comparator for numbers. `toSorted` copies; `sort` mutates in place.

const nums = [10, 9, 1, 100];
console.log(nums.toSorted());                // [1, 10, 100, 9]: compared as text
console.log(nums.toSorted((a, b) => a - b));
console.log(nums);                           // unchanged
Result
[ 1, 10, 100, 9 ]
[ 1, 9, 10, 100 ]
[ 10, 9, 1, 100 ]
Setup
  1. Node.js 22.18 or later (it runs .mts files directly by stripping the types)
  2. Save as main.mts and run node main.mts
  3. Optional type-check: npm i -D typescript @types/node, then npx tsc --noEmit --strict --target esnext --module nodenext main.mts

Ran with Node 24.16.0, TypeScript 5.9.3 on 2026-11-11: the output matched the result shown.

Sources: Array.prototype.toSorted — MDN

Read-only arrays and typed arrays

`readonly T[]` is a compile-time promise; a typed array is a runtime container of fixed length.

function total(xs: readonly number[]): number {
  // xs.push(1);   // error TS2339: Property 'push' does not exist on type 'readonly number[]'
  return xs.reduce((sum, x) => sum + x, 0);
}

const samples = new Float64Array([0.5, 1.5, 2]);
console.log(total([1, 2, 3]), total(Array.from(samples)), samples.length);
Result
6 4 3
Setup
  1. Node.js 22.18 or later (it runs .mts files directly by stripping the types)
  2. Save as main.mts and run node main.mts
  3. Optional type-check: npm i -D typescript @types/node, then npx tsc --noEmit --strict --target esnext --module nodenext main.mts

Ran with Node 24.16.0, TypeScript 5.9.3 on 2026-11-11: the output matched the result shown.

Sources: TypedArray — MDN · ReadonlyArray — TypeScript Handbook

Pitfalls

Where code that reads like Swift behaves differently.

  • queue.shift() in a loop is O(n²) overall — keep a head index or use a ring buffer.
  • delete xs[i] leaves a hole (length unchanged); splice(i, 1) removes.
  • [1, 2] === [1, 2] is false: arrays and objects compare by reference, also as Set/Map keys.
  • JSON.stringify(new Map()) is {} — serialise [...map] or Object.fromEntries(map).
  • Use Map for anything keyed at runtime, Set for membership, and readonly in signatures.
  • Arrays have no value semantics: const ys = xs aliases. Copy with [...xs] or structuredClone(xs).

Collections — Operation Cookbook

Transform, fold, search, sort, group, slice and dedupe — copy or mutate, eager or lazy.

  • node
  • browser
  • typescript >=5.6
  • node >=22.18
  • any ES2023+ engine; the examples run on Node.js

Sources: Array — MDN

I want to…

The method that says what you mean.

GoalMethodCopies?
transform eachmap, flatMapyes
keep somefilteryes
fold to one valuereduce—
first / last matchfind, findLast, findIndex—
testsome, every, includes—
sort / reverse / replace onetoSorted, toReversed, with, toSplicedyes (ES2023)
… in placesort, reverse, spliceno — mutates
groupObject.groupBy, Map.groupByyes (ES2024)
dedupe[...new Set(xs)]yes
index by keynew Map(xs.map((x) => [x.id, x]))yes

Transform and fold

Chains read top to bottom; each step allocates a new array.

const orders = [
  { id: 1, customer: "ana", total: 30 },
  { id: 2, customer: "bo", total: 12 },
  { id: 3, customer: "ana", total: 8 },
];
const big = orders.filter((o) => o.total >= 10).map((o) => o.id);
const revenue = orders.reduce((sum, o) => sum + o.total, 0);
console.log(big, revenue);
Result
[ 1, 2 ] 50
Setup
  1. Node.js 22.18 or later (it runs .mts files directly by stripping the types)
  2. Save as main.mts and run node main.mts
  3. Optional type-check: npm i -D typescript @types/node, then npx tsc --noEmit --strict --target esnext --module nodenext main.mts

Ran with Node 24.16.0, TypeScript 5.9.3 on 2026-11-11: the output matched the result shown.

Group and index

`Object.groupBy` returns a null-prototype object whose values may be `undefined` to the type checker.

const people = [
  { name: "Ana", team: "web" },
  { name: "Bo", team: "data" },
  { name: "Cy", team: "web" },
];
const byTeam = Object.groupBy(people, (p) => p.team);
console.log(Object.keys(byTeam), byTeam.web?.map((p) => p.name));

const byName = new Map(people.map((p) => [p.name, p]));
console.log(byName.get("Bo")?.team);
Result
[ 'web', 'data' ] [ 'Ana', 'Cy' ]
data
Setup
  1. Node.js 22.18 or later (it runs .mts files directly by stripping the types)
  2. Save as main.mts and run node main.mts
  3. Optional type-check: npm i -D typescript @types/node, then npx tsc --noEmit --strict --target esnext --module nodenext main.mts

Ran with Node 24.16.0, TypeScript 5.9.3 on 2026-11-11: the output matched the result shown.

  • ecmascript 2024 (groupBy)
  • typescript >=5.4
  • node >=21

Sources: Object.groupBy — MDN

Search and test

`find` returns `undefined` on a miss, `findIndex` returns `-1`.

const xs = [3, 8, 12, 5, 20];
console.log(xs.find((x) => x > 10), xs.findLast((x) => x > 10), xs.findIndex((x) => x > 100));
console.log(xs.some((x) => x % 2 === 0), xs.every((x) => x > 0), xs.includes(5));
Result
12 20 -1
true true true
Setup
  1. Node.js 22.18 or later (it runs .mts files directly by stripping the types)
  2. Save as main.mts and run node main.mts
  3. Optional type-check: npm i -D typescript @types/node, then npx tsc --noEmit --strict --target esnext --module nodenext main.mts

Ran with Node 24.16.0, TypeScript 5.9.3 on 2026-11-11: the output matched the result shown.

Sort by several keys

The sort is stable; chain keys with `||`, and use `localeCompare` for text.

type Player = { name: string; score: number };
const players: Player[] = [
  { name: "cy", score: 7 },
  { name: "ana", score: 9 },
  { name: "bo", score: 7 },
];
const ranked = players.toSorted((a, b) => b.score - a.score || a.name.localeCompare(b.name));
console.log(ranked.map((p) => `${p.name}:${p.score}`).join(" "));
Result
ana:9 bo:7 cy:7
Setup
  1. Node.js 22.18 or later (it runs .mts files directly by stripping the types)
  2. Save as main.mts and run node main.mts
  3. Optional type-check: npm i -D typescript @types/node, then npx tsc --noEmit --strict --target esnext --module nodenext main.mts

Ran with Node 24.16.0, TypeScript 5.9.3 on 2026-11-11: the output matched the result shown.

Chunk, window and dedupe

Not built in — two small generic helpers cover both.

function chunk<T>(xs: readonly T[], size: number): T[][] {
  return Array.from({ length: Math.ceil(xs.length / size) }, (_, i) => xs.slice(i * size, i * size + size));
}
function windows<T>(xs: readonly T[], size: number): T[][] {
  return xs.slice(0, Math.max(0, xs.length - size + 1)).map((_, i) => xs.slice(i, i + size));
}

console.log(chunk([1, 2, 3, 4, 5], 2));
console.log(windows([1, 2, 3, 4], 3));
console.log([...new Set(["b", "a", "b", "c", "a"])]);   // first occurrence wins
Result
[ [ 1, 2 ], [ 3, 4 ], [ 5 ] ]
[ [ 1, 2, 3 ], [ 2, 3, 4 ] ]
[ 'b', 'a', 'c' ]
Setup
  1. Node.js 22.18 or later (it runs .mts files directly by stripping the types)
  2. Save as main.mts and run node main.mts
  3. Optional type-check: npm i -D typescript @types/node, then npx tsc --noEmit --strict --target esnext --module nodenext main.mts

Ran with Node 24.16.0, TypeScript 5.9.3 on 2026-11-11: the output matched the result shown.

Copy or mutate

The ES2023 copying methods leave the source alone; `splice` and `sort` do not.

const xs = [3, 1, 2];
const ys = xs.toSorted().toReversed().with(0, 99);
console.log(xs, ys);
xs.splice(1, 1);                             // removes 1 element at index 1, in place
console.log(xs);
Result
[ 3, 1, 2 ] [ 99, 2, 1 ]
[ 3, 2 ]
Setup
  1. Node.js 22.18 or later (it runs .mts files directly by stripping the types)
  2. Save as main.mts and run node main.mts
  3. Optional type-check: npm i -D typescript @types/node, then npx tsc --noEmit --strict --target esnext --module nodenext main.mts

Ran with Node 24.16.0, TypeScript 5.9.3 on 2026-11-11: the output matched the result shown.

Sources: Copying methods and mutating methods — MDN

Lazy pipelines with iterator helpers

Iterator helpers pull one value at a time, so an infinite generator is fine.

function* naturals() {
  let n = 1;
  while (true) yield n++;
}
const firstOddSquares = naturals()
  .map((n) => n * n)
  .filter((n) => n % 2 === 1)
  .take(3)
  .toArray();
console.log(firstOddSquares);
Result
[ 1, 9, 25 ]
Setup
  1. Node.js 22.18 or later (it runs .mts files directly by stripping the types)
  2. Save as main.mts and run node main.mts
  3. Optional type-check: npm i -D typescript @types/node, then npx tsc --noEmit --strict --target esnext --module nodenext main.mts

Ran with Node 24.16.0, TypeScript 5.9.3 on 2026-11-11: the output matched the result shown.

  • ecmascript 2025 (iterator helpers)
  • typescript >=5.6
  • node >=22

Sources: Iterator — MDN

Async/Await, Promises & Cancellation

One thread, one event loop — sequential vs concurrent awaits, combinators, AbortSignal, async iteration.

  • node
  • browser
  • typescript >=5.6
  • node >=22.18
  • any ES2024+ engine; the examples run on Node.js

Sources: Using promises — MDN · AbortSignal — MDN

Sequential vs concurrent

A promise starts when it is created; `await` only waits. Start both, then await both.

const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
async function load(name: string, ms: number): Promise<string> {
  await sleep(ms);
  return name;
}

let start = performance.now();
const a = await load("user", 100);           // total = a + b (about 200 ms)
const b = await load("posts", 100);
const sequential = performance.now() - start;

start = performance.now();
const [c, d] = await Promise.all([load("user", 100), load("posts", 100)]);   // total = max(a, b) (about 100 ms)
const concurrent = performance.now() - start;
console.log(a, b, "|", c, d);
console.log("concurrent took well under the sequential time:", concurrent < sequential * 0.75);
Result
user posts | user posts
concurrent took well under the sequential time: true
Setup
  1. Node.js 22.18 or later (it runs .mts files directly by stripping the types)
  2. Save as main.mts and run node main.mts (top-level await needs a module: the .mts extension makes it one)
  3. Optional type-check: npm i -D typescript @types/node, then npx tsc --noEmit --strict --target esnext --module nodenext main.mts

Ran with Node 24.16.0, TypeScript 5.9.3 on 2026-11-11: the output matched the result shown.

all, allSettled, any

`all` fails fast on the first rejection; `allSettled` never rejects; `any` takes the first success.

const ok = (v: number) => Promise.resolve(v);
const fail = (why: string) => Promise.reject(new Error(why));

const settled = await Promise.allSettled([ok(1), fail("boom"), ok(3)]);
console.log(settled.map((r) => (r.status === "fulfilled" ? r.value : r.reason.message)));

try {
  await Promise.all([ok(1), fail("first failure wins")]);
} catch (e) {
  console.log((e as Error).message);
}
console.log(await Promise.any([fail("a"), ok(2), ok(3)]));
Result
[ 1, 'boom', 3 ]
first failure wins
2
Setup
  1. Node.js 22.18 or later (it runs .mts files directly by stripping the types)
  2. Save as main.mts and run node main.mts (top-level await needs a module: the .mts extension makes it one)
  3. Optional type-check: npm i -D typescript @types/node, then npx tsc --noEmit --strict --target esnext --module nodenext main.mts

Ran with Node 24.16.0, TypeScript 5.9.3 on 2026-11-11: the output matched the result shown.

Sources: Promise — MDN

Cancellation with AbortSignal

Promises cannot be cancelled; work is. Pass a signal down, honour it at every await, and detach the listener when the wait ends normally.

function delay(ms: number, signal?: AbortSignal): Promise<void> {
  return new Promise((resolve, reject) => {
    if (signal?.aborted) return reject(signal.reason);
    const onAbort = () => {
      clearTimeout(timer);
      reject(signal!.reason);
    };
    const timer = setTimeout(() => {
      signal?.removeEventListener("abort", onAbort);   // no listener left behind per tick
      resolve();
    }, ms);
    signal?.addEventListener("abort", onAbort, { once: true });
  });
}

async function poll(signal: AbortSignal, onTick: (i: number) => void) {
  for (let i = 1; ; i++) {
    await delay(50, signal);                 // each await is a cancellation point
    console.log("tick", i);
    onTick(i);
  }
}

const controller = new AbortController();
try {
  await poll(controller.signal, (i) => {
    if (i === 2) controller.abort();          // e.g. the user left the screen
  });
} catch (e) {
  console.log((e as DOMException).name);     // AbortSignal.timeout(ms) rejects the same way, as TimeoutError
}
Result
tick 1
tick 2
AbortError
Setup
  1. Node.js 22.18 or later (it runs .mts files directly by stripping the types)
  2. Save as main.mts and run node main.mts (top-level await needs a module: the .mts extension makes it one)
  3. Optional type-check: npm i -D typescript @types/node, then npx tsc --noEmit --strict --target esnext --module nodenext main.mts

Ran with Node 24.16.0, TypeScript 5.9.3 on 2026-11-11: the output matched the result shown.

Async iteration

An async generator is a pull-based stream; `break` calls its `return()`, so no further page is fetched.

async function* pages(total: number, size: number) {
  for (let offset = 0; offset < total; offset += size) {
    await new Promise((r) => setTimeout(r, 10));        // stand-in for a fetch
    yield Array.from({ length: Math.min(size, total - offset) }, (_, i) => offset + i);
  }
}

let seen = 0;
for await (const page of pages(7, 3)) {
  seen += page.length;
  console.log(page);
  if (seen >= 6) break;
}
Result
[ 0, 1, 2 ]
[ 3, 4, 5 ]
Setup
  1. Node.js 22.18 or later (it runs .mts files directly by stripping the types)
  2. Save as main.mts and run node main.mts (top-level await needs a module: the .mts extension makes it one)
  3. Optional type-check: npm i -D typescript @types/node, then npx tsc --noEmit --strict --target esnext --module nodenext main.mts

Ran with Node 24.16.0, TypeScript 5.9.3 on 2026-11-11: the output matched the result shown.

Sources: for await...of — MDN

Bridging callbacks

`promisify` for Node-style callbacks; `Promise.withResolvers` for anything that fires once.

import { readFile } from "node:fs";
import { promisify } from "node:util";

const readFileAsync = promisify(readFile);
const self = await readFileAsync(new URL(import.meta.url), "utf8");
console.log(self.startsWith("import"));

function once(target: EventTarget, type: string): Promise<Event> {
  const { promise, resolve } = Promise.withResolvers<Event>();
  target.addEventListener(type, resolve, { once: true });
  return promise;
}
const target = new EventTarget();
setTimeout(() => target.dispatchEvent(new Event("ready")), 10);
console.log((await once(target, "ready")).type);
Result
true
ready
Setup
  1. Node.js 22.18 or later (it runs .mts files directly by stripping the types)
  2. Save as main.mts and run node main.mts (top-level await needs a module: the .mts extension makes it one)
  3. Optional type-check: npm i -D typescript @types/node, then npx tsc --noEmit --strict --target esnext --module nodenext main.mts

Ran with Node 24.16.0, TypeScript 5.9.3 on 2026-11-11: the output matched the result shown.

  • node
  • node >=22.18
  • typescript >=5.4

Sources: util.promisify — Node.js · Promise.withResolvers — MDN

The forEach trap

`forEach` ignores the promises its callback returns; `for…of` awaits each in turn.

const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const log: string[] = [];

[30, 10].forEach(async (ms) => {             // fire-and-forget: nothing waits for these
  await sleep(ms);
  log.push(`forEach ${ms}`);
});
log.push("after forEach");

for (const ms of [30, 10]) {
  await sleep(ms);
  log.push(`for-of ${ms}`);
}
log.push("after for-of");
console.log(log.join("\n"));
Result
after forEach
forEach 10
forEach 30
for-of 30
for-of 10
after for-of
Setup
  1. Node.js 22.18 or later (it runs .mts files directly by stripping the types)
  2. Save as main.mts and run node main.mts (top-level await needs a module: the .mts extension makes it one)
  3. Optional type-check: npm i -D typescript @types/node, then npx tsc --noEmit --strict --target esnext --module nodenext main.mts

Ran with Node 24.16.0, TypeScript 5.9.3 on 2026-11-11: the output matched the result shown.

Swift concurrency → TypeScript

The same ideas on a single-threaded event loop.

SwiftTypeScriptDifference
async let / await (a, b)start promises, then await Promise.all([a, b])promises start eagerly
withThrowingTaskGroupPromise.all(items.map(work))no automatic cancellation of siblings
Task.isCancelled, try Task.checkCancellation()signal.aborted, signal.throwIfAborted()cancellation is a value you pass, not ambient
AsyncStream / for awaitasync generator / for awaitsame pull model
withCheckedContinuationnew Promise(...) / Promise.withResolvers()resolving twice is ignored, not a crash
@MainActor—one thread: no data races on ordinary objects

Rules

What reviewers flag first.

  • A floating promise (no await, no return, no .catch) loses its errors — Node then exits on the unhandled rejection.
  • Independent work: Promise.all. Partial failure acceptable: Promise.allSettled.
  • Accept an AbortSignal in every long-running function and pass it to fetch, timers and streams.
  • AbortSignal.timeout(ms) and AbortSignal.any([a, b]) compose deadlines without timers of your own.
  • CPU-bound work blocks everything: move it to a Worker (browser) or worker_threads (Node).

Algorithmic Patterns & Complexity

The built-ins' costs, then the patterns that solve most problems — with no standard heap or deque.

  • node
  • browser
  • typescript >=5.0
  • node >=22.18
  • any ES2023+ engine; the examples run on Node.js

Sources: Getting things sorted in V8 — v8.dev · Array — MDN

Built-in costs

Engine behaviour, not a specification guarantee — measure when it matters.

NeedBuilt-inCostInstead
stackpush / popO(1) amortised—
queuepush / shiftO(n) per shiftarray + head index
priority queuenone—a binary heap (below): O(log n)
membershipincludesO(n)Set#has: O(1) average
sorttoSorted (TimSort in V8)O(n log n), stable—
build a string+= in a loopusually fine (ropes in V8)parts.join("") when in doubt
big integersbigintarbitrary precisionnumber is exact only to 2^53 − 1

Two pointers

On sorted input, move the pointer that brings the sum closer. O(n), O(1) space.

function pairWithSum(sorted: readonly number[], target: number): [number, number] | null {
  let lo = 0;
  let hi = sorted.length - 1;
  while (lo < hi) {
    const sum = sorted[lo]! + sorted[hi]!;
    if (sum === target) return [lo, hi];
    if (sum < target) lo++;
    else hi--;
  }
  return null;
}
console.log(pairWithSum([1, 3, 4, 6, 9], 10), pairWithSum([1, 2], 7));
Result
[ 0, 4 ] null
Setup
  1. Node.js 22.18 or later (it runs .mts files directly by stripping the types)
  2. Save as main.mts and run node main.mts
  3. Optional type-check: npm i -D typescript @types/node, then npx tsc --noEmit --strict --target esnext --module nodenext main.mts

Ran with Node 24.16.0, TypeScript 5.9.3 on 2026-11-11: the output matched the result shown.

Sliding window

Longest substring without repeats — grow the right edge, jump the left past the last repeat. O(n).

function longestUnique(s: string): number {
  const lastSeen = new Map<string, number>();
  let start = 0;
  let best = 0;
  for (let i = 0; i < s.length; i++) {
    const prev = lastSeen.get(s[i]!);
    if (prev !== undefined && prev >= start) start = prev + 1;
    lastSeen.set(s[i]!, i);
    best = Math.max(best, i - start + 1);
  }
  return best;
}
console.log(longestUnique("abcabcbb"), longestUnique("pwwkew"));
Result
3 3
Setup
  1. Node.js 22.18 or later (it runs .mts files directly by stripping the types)
  2. Save as main.mts and run node main.mts
  3. Optional type-check: npm i -D typescript @types/node, then npx tsc --noEmit --strict --target esnext --module nodenext main.mts

Ran with Node 24.16.0, TypeScript 5.9.3 on 2026-11-11: the output matched the result shown.

Heap — top k

Keep a min-heap of size k; its root is the smallest of the k largest. O(n log k).

class MinHeap {
  private items: number[] = [];
  get size() { return this.items.length; }
  push(x: number) {
    const a = this.items;
    a.push(x);
    for (let i = a.length - 1; i > 0; ) {
      const p = (i - 1) >> 1;
      if (a[p]! <= a[i]!) break;
      [a[p], a[i]] = [a[i]!, a[p]!];
      i = p;
    }
  }
  pop(): number | undefined {
    const a = this.items;
    const top = a[0];
    const last = a.pop();
    if (a.length && last !== undefined) {
      a[0] = last;
      for (let i = 0; ; ) {
        const l = 2 * i + 1;
        const r = l + 1;
        let m = i;
        if (l < a.length && a[l]! < a[m]!) m = l;
        if (r < a.length && a[r]! < a[m]!) m = r;
        if (m === i) break;
        [a[m], a[i]] = [a[i]!, a[m]!];
        i = m;
      }
    }
    return top;
  }
}

function topK(xs: readonly number[], k: number): number[] {
  const heap = new MinHeap();
  for (const x of xs) {
    heap.push(x);
    if (heap.size > k) heap.pop();
  }
  const out: number[] = [];
  while (heap.size) out.push(heap.pop()!);
  return out.reverse();
}
console.log(topK([5, 1, 9, 3, 7, 9, 2], 3));
Result
[ 9, 9, 7 ]
Setup
  1. Node.js 22.18 or later (it runs .mts files directly by stripping the types)
  2. Save as main.mts and run node main.mts
  3. Optional type-check: npm i -D typescript @types/node, then npx tsc --noEmit --strict --target esnext --module nodenext main.mts

Ran with Node 24.16.0, TypeScript 5.9.3 on 2026-11-11: the output matched the result shown.

Breadth-first search on a grid

BFS finds shortest paths in unweighted graphs. Dequeue by index — `shift()` would make it O(n²).

function shortestPath(grid: string[], from: [number, number], to: [number, number]): number {
  const rows = grid.length;
  const cols = grid[0]!.length;
  const dist = new Map<string, number>([[from.join(","), 0]]);
  const queue: [number, number][] = [from];
  for (let head = 0; head < queue.length; head++) {
    const [r, c] = queue[head]!;
    const here = dist.get(`${r},${c}`)!;
    if (r === to[0] && c === to[1]) return here;
    for (const [dr, dc] of [[1, 0], [-1, 0], [0, 1], [0, -1]] as const) {
      const nr = r + dr;
      const nc = c + dc;
      const key = `${nr},${nc}`;
      if (nr < 0 || nc < 0 || nr >= rows || nc >= cols || grid[nr]![nc] === "#" || dist.has(key)) continue;
      dist.set(key, here + 1);
      queue.push([nr, nc]);
    }
  }
  return -1;
}
const maze = ["..#.", ".##.", "...."];
console.log(shortestPath(maze, [0, 0], [0, 3]));
Result
7
Setup
  1. Node.js 22.18 or later (it runs .mts files directly by stripping the types)
  2. Save as main.mts and run node main.mts
  3. Optional type-check: npm i -D typescript @types/node, then npx tsc --noEmit --strict --target esnext --module nodenext main.mts

Ran with Node 24.16.0, TypeScript 5.9.3 on 2026-11-11: the output matched the result shown.

Working a problem out loud

The habits that matter more than the language.

  • State the brute force and its cost first, then the bottleneck the better pattern removes.
  • Name the edge cases before coding: empty input, one element, duplicates, negative numbers, NaN.
  • Comparing with sort() without a comparator, or summing money in floating point.
  • Say the complexity of the built-ins you lean on (includes is O(n), shift is O(n)).

Testing Approaches in TypeScript

Node's built-in runner and assertions, seams for time and I/O, fakes over mocks.

  • node
  • typescript >=5.0
  • node >=22.18
  • Node.js test runner (node:test)

Sources: Test runner — Node.js · Assert — Node.js

Tooling matrix

Node ships a runner, assertions, mocks and fake timers; the rest is a choice.

NeedBuilt into NodeCommon alternative
runnernode --test, node:testVitest, Jest
assertionsnode:assert/strictexpect (Vitest, Jest)
spies and mocksmock.fn, mock.methodvi.fn, jest.fn
fake timersmock.timersvi.useFakeTimers()
DOM—jsdom or happy-dom; Playwright for real browsers
coverage--experimental-test-coverage@vitest/coverage-v8, c8

node:test basics

Running the file runs its tests; any failure makes the process exit 1.

import assert from "node:assert/strict";
import { test } from "node:test";

function slugify(title: string): string {
  return title.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
}

test("joins words with single hyphens", () => {
  assert.equal(slugify("  Hello, World!  "), "hello-world");
});

test("deep equality compares structure", () => {
  assert.deepEqual([slugify("A b"), slugify("--x--")], ["a-b", "x"]);
});
Result

Both tests pass — ✔ joins words with single hyphens, ✔ deep equality compares structure, then tests 2, pass 2, fail 0 — and the process exits 0 (the timings vary).

Setup
  1. Node.js 22.18 or later (it runs .mts files directly by stripping the types)
  2. Save as main.mts and run node main.mts — or name test files *.test.mts and run them all with node --test
  3. Optional type-check: npm i -D typescript @types/node, then npx tsc --noEmit --strict --target esnext --module nodenext main.mts

Ran with Node 24.16.0, TypeScript 5.9.3 on 2026-11-11: it exited 0.

Design for testability — inject the clock

Code that reads time through a parameter can be tested without sleeping.

import assert from "node:assert/strict";
import { test } from "node:test";

type Clock = () => number;

function makeSession(clock: Clock, ttlMs: number) {
  const started = clock();
  return { expired: () => clock() - started >= ttlMs };
}

test("expires after its time to live", () => {
  let now = 1_000;
  const session = makeSession(() => now, 500);
  assert.equal(session.expired(), false);
  now += 500;                                // the test owns time
  assert.equal(session.expired(), true);
});
Result

✔ expires after its time to live, pass 1, exit status 0.

Setup
  1. Node.js 22.18 or later (it runs .mts files directly by stripping the types)
  2. Save as main.mts and run node main.mts — or name test files *.test.mts and run them all with node --test
  3. Optional type-check: npm i -D typescript @types/node, then npx tsc --noEmit --strict --target esnext --module nodenext main.mts

Ran with Node 24.16.0, TypeScript 5.9.3 on 2026-11-11: it exited 0.

Spies and fake timers

`mock.timers` replaces `setTimeout`, so a 300 ms debounce is tested in microseconds.

import assert from "node:assert/strict";
import { mock, test } from "node:test";

function debounce<A extends unknown[]>(fn: (...args: A) => void, ms: number) {
  let timer: ReturnType<typeof setTimeout> | undefined;
  return (...args: A) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), ms);
  };
}

test("debounce calls once, with the last arguments", () => {
  mock.timers.enable({ apis: ["setTimeout"] });
  const save = mock.fn((_text: string) => {});
  const onInput = debounce(save, 300);
  onInput("h");
  onInput("he");
  onInput("hey");
  mock.timers.tick(299);
  assert.equal(save.mock.callCount(), 0);
  mock.timers.tick(1);
  assert.deepEqual(save.mock.calls.map((c) => c.arguments), [["hey"]]);
  mock.timers.reset();
});
Result

✔ debounce calls once, with the last arguments, pass 1, exit status 0.

Setup
  1. Node.js 22.18 or later (it runs .mts files directly by stripping the types)
  2. Save as main.mts and run node main.mts — or name test files *.test.mts and run them all with node --test
  3. Optional type-check: npm i -D typescript @types/node, then npx tsc --noEmit --strict --target esnext --module nodenext main.mts

Ran with Node 24.16.0, TypeScript 5.9.3 on 2026-11-11: it exited 0.

Sources: Mocking timers — Node.js test runner

Asserting on async failures

`assert.rejects` awaits the promise; a test function that returns a promise is awaited too.

import assert from "node:assert/strict";
import { test } from "node:test";

async function parsePort(text: string): Promise<number> {
  const n = Number(text);
  if (!Number.isInteger(n) || n < 1 || n > 65535) throw new RangeError(`not a port: ${text}`);
  return n;
}

test("accepts a port and rejects text", async () => {
  assert.equal(await parsePort("8080"), 8080);
  await assert.rejects(parsePort("http"), { name: "RangeError", message: "not a port: http" });
});
Result

✔ accepts a port and rejects text, pass 1, exit status 0.

Setup
  1. Node.js 22.18 or later (it runs .mts files directly by stripping the types)
  2. Save as main.mts and run node main.mts — or name test files *.test.mts and run them all with node --test
  3. Optional type-check: npm i -D typescript @types/node, then npx tsc --noEmit --strict --target esnext --module nodenext main.mts

Ran with Node 24.16.0, TypeScript 5.9.3 on 2026-11-11: it exited 0.

Principles worth stating

The same in every language.

  • Fakes you own (an in-memory repository, a clock function) over mocks of code you do not.
  • Test behaviour through the public surface; a refactor should not break a test.
  • Real timers, real network and shared state between tests — the three sources of flakiness.
  • Type-check the tests as strictly as the code: tsc --noEmit catches a wrong mock shape.

TypeScript Language Essentials

Structural types, unions and narrowing, null and undefined, generics, errors, exhaustiveness.

  • node
  • browser
  • typescript >=5.0
  • node >=22.18
  • TypeScript compiler; the examples run on Node.js

Sources: The TypeScript Handbook · Narrowing — TypeScript Handbook

Swift idea → TypeScript

Types are erased at runtime — they check, they never convert.

SwiftTypeScriptWatch out
protocolinterface / typestructural: any value of the right shape fits
enum with associated valuesdiscriminated unionswitch on the tag; never proves exhaustiveness
Optional<T>, ?., ??T | undefined, ?., ??there are two empties: null and undefined
generics with wheregenerics with extendsconstraints are structural too
struct value semanticsnone — objects are referencescopy with spread or structuredClone
throws / do-catchthrow anything / catch (e: unknown)the type system does not track throws
some P / any P—no existential distinction; inference does the work

Structural typing

A value fits a type when it has the members — no declaration of conformance needed.

interface Point { x: number; y: number }
function length(p: Point): number {
  return Math.hypot(p.x, p.y);
}

const reading = { x: 3, y: 4, label: "sensor" };
console.log(length(reading));                 // extra members are fine on a variable…
// length({ x: 3, y: 4, label: "s" });        // …but an object literal with them is error TS2353
Result
5
Setup
  1. Node.js 22.18 or later (it runs .mts files directly by stripping the types)
  2. Save as main.mts and run node main.mts
  3. Optional type-check: npm i -D typescript @types/node, then npx tsc --noEmit --strict --target esnext --module nodenext main.mts

Ran with Node 24.16.0, TypeScript 5.9.3 on 2026-11-11: the output matched the result shown.

Narrowing, null and undefined

`typeof`, `in`, `instanceof` and `== null` narrow a union; `?.` and `??` handle both empties.

function describe(value: string | number | null | undefined): string {
  if (value == null) return "nothing";       // == null matches null and undefined
  if (typeof value === "string") return `text of ${value.length}`;
  return `number ${value.toFixed(1)}`;
}
console.log(describe("hey"), "|", describe(2), "|", describe(undefined));

const user: { name?: string; address?: { city?: string } } = {};
console.log(user.address?.city ?? "unknown city", user.name?.toUpperCase());
Result
text of 3 | number 2.0 | nothing
unknown city undefined
Setup
  1. Node.js 22.18 or later (it runs .mts files directly by stripping the types)
  2. Save as main.mts and run node main.mts
  3. Optional type-check: npm i -D typescript @types/node, then npx tsc --noEmit --strict --target esnext --module nodenext main.mts

Ran with Node 24.16.0, TypeScript 5.9.3 on 2026-11-11: the output matched the result shown.

Generics and constraints

`K extends keyof T` ties a key to its object; the compiler rejects a key that does not exist.

function maxBy<T>(items: readonly T[], key: (item: T) => number): T | undefined {
  let best: T | undefined;
  let bestKey = -Infinity;
  for (const item of items) {
    const k = key(item);
    if (k > bestKey) {
      best = item;
      bestKey = k;
    }
  }
  return best;
}
function pluck<T, K extends keyof T>(items: readonly T[], key: K): T[K][] {
  return items.map((item) => item[key]);
}

const files = [{ name: "a.txt", size: 10 }, { name: "b.png", size: 2048 }];
console.log(maxBy(files, (f) => f.size)?.name, pluck(files, "name"));
// pluck(files, "owner");   // error TS2345: '"owner"' is not assignable to '"name" | "size"'
Result
b.png [ 'a.txt', 'b.png' ]
Setup
  1. Node.js 22.18 or later (it runs .mts files directly by stripping the types)
  2. Save as main.mts and run node main.mts
  3. Optional type-check: npm i -D typescript @types/node, then npx tsc --noEmit --strict --target esnext --module nodenext main.mts

Ran with Node 24.16.0, TypeScript 5.9.3 on 2026-11-11: the output matched the result shown.

Sources: Generics — TypeScript Handbook

Errors — thrown, or returned as values

A catch variable is `unknown`; a Result union makes failure part of the signature.

type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };

function parsePort(text: string): Result<number> {
  const n = Number(text);
  return Number.isInteger(n) && n > 0 && n < 65536
    ? { ok: true, value: n }
    : { ok: false, error: new RangeError(`not a port: ${text}`) };
}

for (const input of ["8080", "http"]) {
  const r = parsePort(input);
  console.log(r.ok ? `port ${r.value}` : r.error.message);
}

try {
  JSON.parse("{bad");
} catch (e: unknown) {                       // narrow before use
  console.log(e instanceof SyntaxError ? "SyntaxError" : "something else");
}
Result
port 8080
not a port: http
SyntaxError
Setup
  1. Node.js 22.18 or later (it runs .mts files directly by stripping the types)
  2. Save as main.mts and run node main.mts
  3. Optional type-check: npm i -D typescript @types/node, then npx tsc --noEmit --strict --target esnext --module nodenext main.mts

Ran with Node 24.16.0, TypeScript 5.9.3 on 2026-11-11: the output matched the result shown.

Exhaustive switch with never

Assigning the leftover to `never` turns a forgotten case into a compile error.

type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "rect"; width: number; height: number };

function area(s: Shape): number {
  switch (s.kind) {
    case "circle":
      return Math.PI * s.radius ** 2;
    case "rect":
      return s.width * s.height;
    default: {
      const unreachable: never = s;          // a new Shape without a case fails here
      return unreachable;
    }
  }
}
console.log(area({ kind: "rect", width: 2, height: 3 }), area({ kind: "circle", radius: 1 }).toFixed(2));
Result
6 3.14
Setup
  1. Node.js 22.18 or later (it runs .mts files directly by stripping the types)
  2. Save as main.mts and run node main.mts
  3. Optional type-check: npm i -D typescript @types/node, then npx tsc --noEmit --strict --target esnext --module nodenext main.mts

Ran with Node 24.16.0, TypeScript 5.9.3 on 2026-11-11: the output matched the result shown.

References and copies

Spread copies one level; `structuredClone` copies deeply; `Object.freeze` is shallow too.

const a = { tags: ["x"] };
const shallow = { ...a };
const deep = structuredClone(a);
a.tags.push("y");
console.log(shallow.tags, deep.tags, a === shallow);

const frozen = Object.freeze({ n: 1 });
// frozen.n = 2;   // error TS2540: Cannot assign to 'n' because it is a read-only property
console.log(Object.isFrozen(frozen));
Result
[ 'x', 'y' ] [ 'x' ] false
true
Setup
  1. Node.js 22.18 or later (it runs .mts files directly by stripping the types)
  2. Save as main.mts and run node main.mts
  3. Optional type-check: npm i -D typescript @types/node, then npx tsc --noEmit --strict --target esnext --module nodenext main.mts

Ran with Node 24.16.0, TypeScript 5.9.3 on 2026-11-11: the output matched the result shown.

Sources: structuredClone — MDN

Modeling with the Type System

Branded ids, satisfies, utility and mapped types, and validating what the types cannot see.

  • node
  • browser
  • typescript >=5.0
  • node >=22.18
  • TypeScript compiler; the examples run on Node.js

Sources: Utility types — TypeScript Handbook · Mapped types — TypeScript Handbook

Branded ids

Structural typing makes every `string` interchangeable; a brand makes `UserId` and `OrderId` distinct at no runtime cost.

type Brand<T, B extends string> = T & { readonly __brand: B };
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;

const userId = (raw: string) => raw as UserId;     // the one place a raw string becomes an id
function ordersOf(user: UserId): string {
  return `orders of ${user}`;
}

console.log(ordersOf(userId("u-42")));
// ordersOf("o-7" as OrderId);   // error TS2345: 'OrderId' is not assignable to 'UserId'
Result
orders of u-42
Setup
  1. Node.js 22.18 or later (it runs .mts files directly by stripping the types)
  2. Save as main.mts and run node main.mts
  3. Optional type-check: npm i -D typescript @types/node, then npx tsc --noEmit --strict --target esnext --module nodenext main.mts

Ran with Node 24.16.0, TypeScript 5.9.3 on 2026-11-11: the output matched the result shown.

satisfies — check without widening

An annotation would widen the keys to `string`; `satisfies` checks the shape and keeps the literal keys.

type Route = { path: string; auth: boolean };
const routes = {
  home: { path: "/", auth: false },
  settings: { path: "/settings", auth: true },
} satisfies Record<string, Route>;

console.log(routes.settings.path, Object.keys(routes));   // routes.home is known to exist
Result
/settings [ 'home', 'settings' ]
Setup
  1. Node.js 22.18 or later (it runs .mts files directly by stripping the types)
  2. Save as main.mts and run node main.mts
  3. Optional type-check: npm i -D typescript @types/node, then npx tsc --noEmit --strict --target esnext --module nodenext main.mts

Ran with Node 24.16.0, TypeScript 5.9.3 on 2026-11-11: the output matched the result shown.

  • typescript >=4.9
  • node >=22.18

Sources: The satisfies operator — TypeScript 4.9 release notes

Utility types

Derive the variants from one source of truth instead of copying fields.

interface User { id: string; name: string; email: string; admin: boolean }
type UserPatch = Partial<Omit<User, "id">>;  // the id never changes
type PublicUser = Pick<User, "id" | "name">;

function apply(user: User, patch: UserPatch): Readonly<User> {
  return { ...user, ...patch };
}
const ana: User = { id: "1", name: "Ana", email: "ana@example.com", admin: false };
const shown: PublicUser = { id: ana.id, name: ana.name };
console.log(apply(ana, { admin: true }).admin, Object.keys(shown));
Result
true [ 'id', 'name' ]
Setup
  1. Node.js 22.18 or later (it runs .mts files directly by stripping the types)
  2. Save as main.mts and run node main.mts
  3. Optional type-check: npm i -D typescript @types/node, then npx tsc --noEmit --strict --target esnext --module nodenext main.mts

Ran with Node 24.16.0, TypeScript 5.9.3 on 2026-11-11: the output matched the result shown.

Mapped and template literal types

Generate a family of keys from a union; the compiler then knows every one of them.

type EventName = "click" | "focus";
type Handlers = { [E in EventName as `on${Capitalize<E>}`]: (at: number) => void };

const handlers: Handlers = {
  onClick: (at) => console.log("click at", at),
  onFocus: (at) => console.log("focus at", at),
};
handlers.onClick(10);
// handlers.onHover(1);   // error TS2339: Property 'onHover' does not exist on type 'Handlers'
Result
click at 10
Setup
  1. Node.js 22.18 or later (it runs .mts files directly by stripping the types)
  2. Save as main.mts and run node main.mts
  3. Optional type-check: npm i -D typescript @types/node, then npx tsc --noEmit --strict --target esnext --module nodenext main.mts

Ran with Node 24.16.0, TypeScript 5.9.3 on 2026-11-11: the output matched the result shown.

Sources: Template literal types — TypeScript Handbook

Validate what the types cannot see

`JSON.parse` returns `any`. Treat it as `unknown` and narrow with a type guard (or a schema library such as zod).

interface Config { port: number; hosts: string[] }

function isConfig(x: unknown): x is Config {
  if (typeof x !== "object" || x === null) return false;
  const c = x as Record<string, unknown>;
  return typeof c.port === "number" && Array.isArray(c.hosts) && c.hosts.every((h) => typeof h === "string");
}

for (const text of ['{"port":8080,"hosts":["a"]}', '{"port":"8080"}']) {
  const data: unknown = JSON.parse(text);
  console.log(isConfig(data) ? `ok ${data.port}` : "rejected");
}
Result
ok 8080
rejected
Setup
  1. Node.js 22.18 or later (it runs .mts files directly by stripping the types)
  2. Save as main.mts and run node main.mts
  3. Optional type-check: npm i -D typescript @types/node, then npx tsc --noEmit --strict --target esnext --module nodenext main.mts

Ran with Node 24.16.0, TypeScript 5.9.3 on 2026-11-11: the output matched the result shown.

Rules

Make illegal states unrepresentable — within what erasure allows.

  • A discriminated union over a bag of optional fields: { status: "error"; error: E } cannot lack its error.
  • String-literal unions over enum: they erase completely (Node's type stripping rejects enum).
  • as is an assertion, not a conversion — x as User on unchecked data is a bug waiting.
  • any switches checking off for everything it touches; unknown forces a check.
  • Types vanish at runtime: anything crossing a boundary (network, storage, user input) needs validation.

Node.js — Files, Text & Atomic I/O

fs/promises, atomic replace, streaming lines, pipelines with backpressure, append-only journals.

  • node
  • node >=22.18
  • typescript >=5.0
  • Node.js

Sources: File system — Node.js · Stream — Node.js

Read and write a small text file

Whole-file reads are right for small files; say the encoding or you get a Buffer.

import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";

const dir = await mkdtemp(join(tmpdir(), "demo-"));
const file = join(dir, "notes.txt");
await writeFile(file, "first line\nsecond line\n", "utf8");
const text = await readFile(file, "utf8");
console.log(text.split("\n").filter(Boolean).length, "lines");
await rm(dir, { recursive: true });
Result
2 lines
Setup
  1. Node.js 22.18 or later (it runs .mts files directly by stripping the types)
  2. Save as main.mts and run node main.mts — it works in a fresh temporary directory and removes it
  3. Optional type-check: npm i -D typescript @types/node, then npx tsc --noEmit --strict --target esnext --module nodenext main.mts

Ran with Node 24.16.0, TypeScript 5.9.3 on 2026-11-11: the output matched the result shown.

Writing atomically

Write a temporary file in the same directory, flush it, then rename over the target: readers see the old file or the new one, never half.

import { randomUUID } from "node:crypto";
import { mkdtemp, open, readFile, rename, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { basename, dirname, join } from "node:path";

async function writeFileAtomic(path: string, data: string): Promise<void> {
  const temp = join(dirname(path), `.${basename(path)}.${randomUUID()}.tmp`);   // same file system, unique per call
  const handle = await open(temp, "wx");     // exclusive: never truncates another writer's file
  try {
    await handle.writeFile(data, "utf8");
    await handle.sync();                     // on the device before the rename publishes it
  } finally {
    await handle.close();
  }
  await rename(temp, path);                  // atomic replace on POSIX file systems
}

const dir = await mkdtemp(join(tmpdir(), "atomic-"));
const settings = join(dir, "settings.json");
await writeFileAtomic(settings, JSON.stringify({ theme: "dark" }));
await writeFileAtomic(settings, JSON.stringify({ theme: "light" }));
console.log(JSON.parse(await readFile(settings, "utf8")).theme);
await rm(dir, { recursive: true });
Result
light
Setup
  1. Node.js 22.18 or later (it runs .mts files directly by stripping the types)
  2. Save as main.mts and run node main.mts — it works in a fresh temporary directory and removes it
  3. Optional type-check: npm i -D typescript @types/node, then npx tsc --noEmit --strict --target esnext --module nodenext main.mts

Ran with Node 24.16.0, TypeScript 5.9.3 on 2026-11-11: the output matched the result shown.

Sources: fsPromises.rename — Node.js · filehandle.sync — Node.js

Streaming a large file line by line

`readline` over a read stream holds one line at a time, whatever the file size.

import { createReadStream } from "node:fs";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createInterface } from "node:readline";

const dir = await mkdtemp(join(tmpdir(), "lines-"));
const log = join(dir, "app.log");
await writeFile(log, Array.from({ length: 10_000 }, (_, i) => `${i % 10 === 0 ? "ERROR" : "INFO"} event ${i}`).join("\n"));

let errors = 0;
const lines = createInterface({ input: createReadStream(log, "utf8"), crlfDelay: Infinity });
for await (const line of lines) {
  if (line.startsWith("ERROR")) errors++;
}
console.log(errors, "errors");
await rm(dir, { recursive: true });
Result
1000 errors
Setup
  1. Node.js 22.18 or later (it runs .mts files directly by stripping the types)
  2. Save as main.mts and run node main.mts — it works in a fresh temporary directory and removes it
  3. Optional type-check: npm i -D typescript @types/node, then npx tsc --noEmit --strict --target esnext --module nodenext main.mts

Ran with Node 24.16.0, TypeScript 5.9.3 on 2026-11-11: the output matched the result shown.

Sources: Readline — Node.js

Pipelines and backpressure

`pipeline` pauses a fast reader for a slow writer, and destroys every stream if one fails.

import { createReadStream, createWriteStream } from "node:fs";
import { mkdtemp, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { pipeline } from "node:stream/promises";
import { createGzip } from "node:zlib";

const dir = await mkdtemp(join(tmpdir(), "gzip-"));
const src = join(dir, "data.csv");
await writeFile(src, "id,value\n".repeat(50_000));
await pipeline(createReadStream(src), createGzip(), createWriteStream(`${src}.gz`));
const [before, after] = await Promise.all([stat(src), stat(`${src}.gz`)]);
console.log(after.size * 100 < before.size ? "compressed over 100x" : "barely compressed");
await rm(dir, { recursive: true });
Result
compressed over 100x
Setup
  1. Node.js 22.18 or later (it runs .mts files directly by stripping the types)
  2. Save as main.mts and run node main.mts — it works in a fresh temporary directory and removes it
  3. Optional type-check: npm i -D typescript @types/node, then npx tsc --noEmit --strict --target esnext --module nodenext main.mts

Ran with Node 24.16.0, TypeScript 5.9.3 on 2026-11-11: the output matched the result shown.

Sources: stream.pipeline — Node.js

An append-only journal

One JSON object per line (JSON Lines) — appends never rewrite what is already there.

import { appendFile, mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";

const dir = await mkdtemp(join(tmpdir(), "journal-"));
const journal = join(dir, "events.jsonl");
for (const event of ["start", "save", "stop"]) {
  await appendFile(journal, `${JSON.stringify({ event })}\n`);
}
const events = (await readFile(journal, "utf8")).trim().split("\n").map((l) => JSON.parse(l).event);
console.log(events);
await rm(dir, { recursive: true });
Result
[ 'start', 'save', 'stop' ]
Setup
  1. Node.js 22.18 or later (it runs .mts files directly by stripping the types)
  2. Save as main.mts and run node main.mts — it works in a fresh temporary directory and removes it
  3. Optional type-check: npm i -D typescript @types/node, then npx tsc --noEmit --strict --target esnext --module nodenext main.mts

Ran with Node 24.16.0, TypeScript 5.9.3 on 2026-11-11: the output matched the result shown.

Rules that prevent data loss

Most file bugs are about the moment between two writes.

  • Replace a file by writing a temporary sibling and rename-ing it; never truncate the original first.
  • await every write and close handles in finally; a crash between writes is the case to design for.
  • readFileSync on a request path blocks every other client of the process.
  • On Windows rename over an open file can fail with EPERM/EBUSY — retry, or keep readers short-lived.
  • fs.watch events are hints, not a log: re-read the file instead of trusting the event payload.

Browser — State, Events & Requests

Framework-free state with EventTarget, rendering from state, cancelling superseded requests, persisting preferences.

  • browser
  • typescript >=5.5
  • ecmascript 2024
  • browsers Baseline 2024
  • a browser page (the DOM)

Sources: EventTarget — MDN · Using the Fetch API — MDN

A store with EventTarget

One owner of the state, replaced (never mutated) on every update, and a change event to re-render from. `EventTarget` exists in Node too, so this one runs there.

type State = { items: string[]; filter: string };

class Store extends EventTarget {
  #state: State = { items: [], filter: "" };
  get state(): Readonly<State> {
    return this.#state;
  }
  update(change: Partial<State>) {
    this.#state = { ...this.#state, ...change };   // a new object: `old !== new` detects change
    this.dispatchEvent(new Event("change"));
  }
}

const store = new Store();
store.addEventListener("change", () => {
  const { items, filter } = store.state;
  console.log(items.filter((i) => i.includes(filter)).join(", ") || "(none)");
});
store.update({ items: ["apples", "pears", "plums"] });
store.update({ filter: "um" });
store.update({ filter: "x" });
Result
apples, pears, plums
plums
(none)
Setup
  1. Node.js 22.18 or later (it runs .mts files directly by stripping the types), or any modern browser
  2. Save as main.mts and run node main.mts
  3. Optional type-check: npm i -D typescript @types/node, then npx tsc --noEmit --strict --target esnext --module nodenext main.mts

Ran with Node 24.16.0, TypeScript 5.9.3 on 2026-11-11: the output matched the result shown.

  • browser
  • node
  • typescript >=5.0
  • node >=22.18

Render from state

Rebuild the list from data on every change; `textContent`, never `innerHTML`, for anything a user typed.

const list = document.querySelector<HTMLUListElement>("#todos")!;
const input = document.querySelector<HTMLInputElement>("#filter")!;
const todos = ["Write tests", "Ship it", "Write docs"];

function render(filter: string) {
  const shown = todos.filter((t) => t.toLowerCase().includes(filter.toLowerCase()));
  list.replaceChildren(...shown.map((t) => Object.assign(document.createElement("li"), { textContent: t })));
}
input.addEventListener("input", () => render(input.value));
render("");
Result

The list shows all three to-dos; typing write leaves Write tests and Write docs.

Setup
  1. A page containing <input id="filter"> and <ul id="todos"></ul>
  2. Compile with tsc (lib including DOM) or a bundler, and load the output with <script type="module">

Compiled with TypeScript 5.9.3 (lib DOM) on 2026-11-11; not run here — running it needs a browser page with the elements named in the setup.

Sources: Element.replaceChildren — MDN

Cancel superseded requests

Each keystroke aborts the previous search, so a slow old response can never overwrite a newer one.

let inFlight: AbortController | undefined;

async function search(query: string): Promise<string[]> {
  inFlight?.abort();                         // supersede the previous request
  const controller = new AbortController();
  inFlight = controller;
  const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
    signal: AbortSignal.any([controller.signal, AbortSignal.timeout(5_000)]),
  });
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
  return (await response.json()) as string[];
}

const box = document.querySelector<HTMLInputElement>("#search")!;
box.addEventListener("input", async () => {
  try {
    console.log(await search(box.value));
  } catch (e) {
    if (e instanceof DOMException && e.name === "AbortError") return;   // superseded, not a failure
    throw e;
  }
});
Result

Only the response to the latest input is logged; earlier requests show as cancelled in the network panel.

Setup
  1. A page containing <input id="search">, served by an origin with a /api/search?q= endpoint returning a JSON array of strings
  2. Compile with tsc (lib including DOM) or a bundler, and load the output with <script type="module">

Compiled with TypeScript 5.9.3 (lib DOM) on 2026-11-11; not run here — running it needs a browser page served with a /api/search endpoint.

Sources: AbortSignal.any — MDN

Persist preferences safely

Storage can be disabled, full or hold an old shape — read defensively and version the key.

type Prefs = { theme: "light" | "dark" };
const KEY = "prefs:v1";
const DEFAULTS: Prefs = { theme: "light" };

function loadPrefs(): Prefs {
  try {
    const parsed: unknown = JSON.parse(localStorage.getItem(KEY) ?? "null");
    return typeof parsed === "object" && parsed !== null && "theme" in parsed && (parsed.theme === "light" || parsed.theme === "dark")
      ? { theme: parsed.theme }
      : DEFAULTS;
  } catch {
    return DEFAULTS;                         // private mode, quota, corrupt JSON
  }
}

function savePrefs(prefs: Prefs): void {
  try {
    localStorage.setItem(KEY, JSON.stringify(prefs));
  } catch {
    // keep working without persistence
  }
}

const prefs = loadPrefs();
document.documentElement.dataset.theme = prefs.theme;
savePrefs({ theme: prefs.theme === "light" ? "dark" : "light" });
Result

The page takes the stored theme (light on first load) and the next load starts in the other one.

Setup
  1. A page containing <input id="filter"> and <ul id="todos"></ul>
  2. Compile with tsc (lib including DOM) or a bundler, and load the output with <script type="module">

Compiled with TypeScript 5.9.3 (lib DOM) on 2026-11-11; not run here — running it needs a browser page with the elements named in the setup.

Sources: Window.localStorage — MDN

Rules

What UI frameworks automate, done by hand.

  • One owner per piece of state; everything else derives from it on change.
  • Abort what the user has moved on from — requests, timers, listeners ({ signal } on addEventListener).
  • innerHTML with data: an injection hole. Build nodes, set textContent.
  • React, Vue and Svelte own this layer in real apps; these patterns are what they do underneath.

Coverage against the Swift Catalog

Every Swift topic, and what this catalog has for it — an equivalent, a partial counterpart, or the reason there is none.

How to read the matrix

Nothing here is a translation made to fill a gap.

  • equivalent — a sheet here covers the same concept and shares its concept id, so the language selector lands on it from the Swift sheet.
  • partial — a sheet here covers part of it under its own concept id, so the selector does not land on it: the Here column names it and the reason says what is missing.
  • no counterpart — nothing here; the reason says whether the topic does not exist in this language or is simply not written yet.

Swift topic by topic

In the Swift catalog's reading order.

Swift topicDispositionHereWhy
SwiftUI — State & Data FlowpartialBrowser — State, Events & Requestsframework-free state and rendering; the property-wrapper decision table belongs to UI frameworks (React, Vue, Svelte), not to TypeScript
SwiftUI — Views, Layout & Animationno counterpart—layout and animation are HTML, CSS and a UI framework's business, not the language's
SwiftUI vs UIKitno counterpart—a comparison of two Apple UI frameworks; the web has no such pair in the language
TCA — Core Loopno counterpart—The Composable Architecture is a Swift library; Redux-style reducers are not written up here yet
TCA — Composition, Dependencies, Testingno counterpart—a Swift library's API
TCA + SwiftUI — Navigation & UIno counterpart—a Swift library's API on an Apple UI framework
Modern Swift Concurrency — CoreequivalentAsync/Await, Promises & Cancellationasync/await, fan-out, cancellation, async iteration and callback bridging on the event loop
Actors, Sendable & Isolationno counterpart—each JavaScript realm runs on one thread, so ordinary objects cannot race; workers share only messages and SharedArrayBuffer
Swift 6 Concurrency — Do's & Don'tsno counterpart—about migrating to Swift 6's strict concurrency checking
Multithreading — GCD vs Combine vs async/awaitpartialAsync/Await, Promises & Cancellationone event loop: the callback → promise → async/await progression is there, thread pools are not
Combine — CorepartialAsync/Await, Promises & Cancellationasync iterables cover pull-based streams; push-based operator libraries (RxJS) are not part of the language and not covered
Timers & Async Patterns — Combine vs async/awaitpartialAsync/Await, Promises & Cancellationdelays, timeouts, polling and fan-out are covered; there is no Combine side to compare
SwiftUI + Combine — Wiring Them Togetherno counterpart—specific to two Apple frameworks
Timing Operators — Combine vs Swift ConcurrencypartialTesting Approaches in TypeScripta hand-written debounce tested with fake timers; the language has no timing operators
Clean Code in Swiftno counterpart—not written yet; the principles are language-neutral and the Swift sheet's advice carries over
SOLID in Swiftno counterpart—not written yet; the principles are language-neutral
Functional Programming in SwiftpartialModeling with the Type Systemunions that make illegal states unrepresentable; higher-order functions are in the operation cookbook; no composition or currying card
Swift Language EssentialsequivalentTypeScript Language Essentialsthe same questions — generics, optionals, errors, value vs reference, pattern matching — answered for TypeScript
Collections — Types & ProtocolsequivalentCollections — Array, Map, Set & Friendswhich container and what it costs
Collections — Operation CookbookequivalentCollections — Operation Cookbooktransform, fold, search, sort, group, slice, dedupe, lazy pipelines
Swift Engineering Best Practicesno counterpart—not written yet
Architectural Approachesno counterpart—not written yet; web architectures are framework-specific
Modularization Approachesno counterpart—not written yet; ES modules and package boundaries are a different mechanism from Swift packages
Testing Approaches on iOSequivalentTesting Approaches in TypeScriptrunner, assertions, test doubles, injectable time and async assertions with node:test
AI Workflows for Swift (Cursor + Claude)no counterpart—Swift-specific by design: it is about why Swift is harder for models and wiring Xcode to an agent
Algorithmic Patterns & ComplexityequivalentAlgorithmic Patterns & Complexitybuilt-in costs and five core patterns; fewer patterns than the Swift sheet
Streaming & Time-Series Patternsno counterpart—not written yet; the algorithms (ring buffers, sketches, reservoir sampling) are language-neutral
Files, Text & Atomic I/OequivalentNode.js — Files, Text & Atomic I/Othe same file-safety questions on Node's fs
Core Bluetooth (BLE)no counterpart—an Apple framework; Web Bluetooth exists only in Chromium-based browsers and is not covered