Runtime + Language · Node.js & TypeScript · Field Guide

Node.js & TypeScript Pro Playbook

From the event loop to a typed production API — how Node really runs your code, and how TypeScript stops the bugs before they ship. Explained for students and pros.

The JavaScript/TypeScript backend layer of the Field Library
What it is Why it matters How it works In plain words ASCII diagram
Part I · Node.js Runtime & Core
01What Node.js actually is — V8 + libuv★ start here

Scenario: people say "Node is single-threaded" and also "Node handles thousands of connections at once." Both are true, and understanding why is the key to everything else — it's a single JS thread on top of a C++ engine and an async I/O library.

What

Node.js runs JavaScript outside the browser using Google's V8 engine, plus libuv for async I/O — one JS thread, a pool of background workers for I/O.

Why

It makes JS a real server platform, and its non-blocking model handles huge concurrency with one thread — perfect for I/O-heavy work (APIs, proxies).

How

Your JS runs on one thread; when it hits I/O (file, network, DB), libuv does the waiting in the background and calls you back when it's ready.

One JS thread on top of C++ machinery
  ┌─────────── your JavaScript (ONE thread) ───────────┐
  │  runs your code, never blocks on I/O               │
  └───────────────┬────────────────────────────────────┘
                  │ async I/O request
        ┌─────────▼──────────┐   ┌──────────────────────┐
        │   libuv (event      │   │  V8 engine           │
        │   loop + thread     │   │  (compiles & runs JS)│
        │   pool for I/O)     │   └──────────────────────┘
        └─────────────────────┘   background threads do the waiting
🧠
Analogy 1 — a chef who never waits at the oven: a single chef (JS thread) puts a dish in the oven and, instead of standing there, starts the next order. A timer (libuv) pings when the oven's done. One chef stays busy serving many tables — that's non-blocking I/O.
Analogy 2 — a barista taking many orders: the barista takes your order, starts the machine, and immediately takes the next order rather than watching the espresso pour. The machines (background threads) do the slow work; the barista (one thread) just coordinates. That's how one thread serves thousands.
One thread, non-blocking I/O
import { readFile } from "node:fs/promises";

console.log("1: start");
readFile("big.txt", "utf8").then(() => console.log("3: file done"));
console.log("2: end");           // runs BEFORE the file finishes
// output: 1: start, 2: end, 3: file done
// the JS thread never blocked — libuv read the file in the background
🧒
In plain wordsNode.js lets you run JavaScript on a server, not just in a web page. It uses one main worker for your code, but whenever that worker needs something slow (reading a file, calling a database), it hands the waiting to helpers in the background and keeps working on other things. That's why one Node process can juggle thousands of users at once without getting stuck.

✅ Do

  • Lean into Node for I/O-heavy work — APIs, gateways, real-time servers
  • Keep the single JS thread free; let libuv handle the waiting
  • Reach for node:-prefixed built-in modules explicitly

❌ Don't

  • Run heavy CPU work on the main thread — it blocks everything (topic 10)
  • Assume "single-threaded" means "one thing at a time" — I/O is concurrent
Gotcha: "single-threaded" fools people into two opposite mistakes. First: assuming Node can't do concurrency — it does, for I/O, via libuv's thread pool. Second, more dangerous: assuming it can freely do CPU work — a single for loop crunching numbers blocks the one JS thread, and every other request freezes until it finishes. Node is built for waiting on I/O, not for heavy computation (topics 2, 10).
02The event loop — how Node schedules everything★ core idea

Scenario: you console.log before and after a setTimeout(fn, 0) and a Promise.then, and the order surprises you. The event loop decides when your callbacks run — and its phases and queues explain every "why did this run in that order?"

What

The event loop is the loop that runs your callbacks. It processes phases (timers, I/O, etc.), and between steps drains microtasks (promises) before macrotasks (timers, I/O).

Why

It's why Node is non-blocking, and understanding queue priority explains ordering bugs, starvation, and why a promise loop can freeze timers.

How

Sync code runs first. Then microtasks (Promise.then, queueMicrotask) drain fully. Then one macrotask (timer/I/O callback), then microtasks again, forever.

Microtasks drain before the next macrotask
  [ run all synchronous code ]
        │
        ▼
  ┌──────────────── loop ────────────────┐
  │ drain ALL microtasks (promises)       │  ← higher priority
  │   then take ONE macrotask:            │
  │   timers → pending → poll(I/O) → check │  ← setTimeout, I/O, setImmediate
  └───────────────────────────────────────┘
  order: sync → promises → timers/I/O → promises → ...
🧠
Analogy 1 — a receptionist with an urgent tray and a normal tray: the receptionist finishes everything in the "urgent" tray (microtasks/promises) before touching one item from the "normal" tray (timers/I/O). New urgent items jump ahead. That priority is why promises resolve before a setTimeout(…, 0).
🎡
Analogy 2 — a ride that empties its express line first: each time the ride loops, staff clear the entire express queue (microtasks), then let on one person from the regular line (a macrotask). If the express line never empties, the regular line waits — that's microtask starvation.
Guess the output — then run it
console.log("A: sync");
setTimeout(() => console.log("D: timer (macrotask)"), 0);
Promise.resolve().then(() => console.log("C: promise (microtask)"));
console.log("B: sync");
// output: A, B, C, D
// sync first (A,B) → microtasks drain (C) → then a macrotask (D)
🧒
In plain wordsNode has a to-do loop that decides what runs next. First it does all your regular code. Then it does promise follow-ups (high priority). Then it does one timer or file callback. Then back to promises, and so on. That priority order is why a .then() often runs before a setTimeout(…, 0), even though the timer "looks" like it should go first.

✅ Do

  • Understand microtasks (promises) outrank macrotasks (timers, I/O)
  • Break big work into chunks so the loop can breathe (topic 10)
  • Use setImmediate vs setTimeout(…,0) knowingly — different phases

❌ Don't

  • Run a tight synchronous loop — you block the loop and every request stalls
  • Recurse infinitely with promises — you can starve timers/I/O (microtask starvation)
Gotcha: because microtasks (promises) fully drain before the loop moves on, a runaway chain of .then()s can starve timers and I/O — your setTimeout never fires, your server stops accepting connections, and nothing looks crashed. Conversely, one long synchronous function blocks the entire loop and freezes all concurrent requests. "Event loop lag" (topic 28) is the metric that catches both before users do.
03Async: callbacks → promises → async/await★ core idea

Scenario: you inherit code nested five callbacks deep ("callback hell"), impossible to read or error-handle. Promises and async/await flatten it into code that reads synchronous but runs async. Knowing all three is the difference between fighting Node and flowing with it.

What

Three generations of async: callbacks (pass a function to call later), promises (an object for a future value), async/await (write async code that reads top-to-bottom).

Why

Almost everything in Node is async. async/await makes it readable and lets you use normal try/catch for errors instead of nested callbacks.

How

await pauses the function (not the thread) until a promise settles, then resumes — sugar over .then(), scheduled as a microtask (topic 2).

Three eras of the same operation
  CALLBACK:  read(f, (err, data) => { if (err)... else use(data) })
                nest 3 of these → the "pyramid of doom"

  PROMISE:   read(f).then(use).catch(handle)     // chainable, flat

  ASYNC/AWAIT:
    try { const data = await read(f); use(data) }   // reads like sync
    catch (err) { handle(err) }                     // normal try/catch
🧠
Analogy 1 — a coat-check ticket: a promise is the ticket you get when you drop off your coat — a stand-in for a coat you'll get later. You don't wait at the counter; you hold the ticket and redeem it when ready (await). Callbacks are instead handing the clerk your phone number to call you back.
🍽️
Analogy 2 — ordering at a restaurant: callbacks are giving the waiter a list of "then do this, then that" instructions to follow blindly. async/await is you simply saying "bring the soup, then I'll order the main" — a natural, readable sequence, even though the kitchen works asynchronously.
Flatten the pyramid
// callback hell → flat async/await
async function loadUser(id) {
  try {
    const user = await db.getUser(id);
    const orders = await db.getOrders(user.id);   // sequential
    const [prefs, cart] = await Promise.all([      // parallel!
      db.getPrefs(user.id), db.getCart(user.id),
    ]);
    return { user, orders, prefs, cart };
  } catch (err) {
    log.error({ err, id }, "loadUser failed");     // one place to catch
    throw err;
  }
}
🧒
In plain wordsBecause Node does slow things in the background, you need a way to say "when it's done, do this." The old way (callbacks) nests deeper and deeper until it's unreadable. The modern way, async/await, lets you write it like normal step-by-step code — "get the user, then get their orders" — even though it's still running in the background. And you catch errors the normal way with try/catch.

✅ Do

  • Prefer async/await for readability and normal try/catch
  • Use Promise.all for independent work — don't await in series needlessly
  • Use Promise.allSettled when you need every result, success or fail

❌ Don't

  • Forget to await (or return) a promise — errors vanish, order breaks
  • await inside a loop when the iterations are independent (slow & serial)
Gotcha: a floating promise — one you never await or .catch() — swallows its error into an "unhandled rejection" that can crash the process (and in older Node, silently vanish). The classic version is await-ing in a loop when you meant to parallelize: it's not wrong, just needlessly serial and slow. Lint for floating promises and reach for Promise.all when iterations don't depend on each other (topics 8, 26).
04Modules — CommonJS vs ES Modules

Scenario: you copy an import statement into a file and Node throws Cannot use import statement outside a module. Node has two module systems — the old CommonJS (require) and the standard ES Modules (import) — and mixing them is a common source of pain.

What

CommonJS (CJS): require() / module.exports, synchronous, Node's original. ES Modules (ESM): import/export, the JS standard, static and async-friendly.

Why

ESM is the future (tree-shaking, standard, browser-aligned), but a huge CJS ecosystem remains. Knowing which a file is — and how they interop — prevents baffling errors.

How

A file is ESM if the package's "type": "module" or the file ends .mjs; else CJS. ESM can import CJS; CJS can only import() ESM dynamically.

Two module worlds
  COMMONJS (.cjs / default)        ES MODULES (.mjs / "type":"module")
  ─────────────────────────        ────────────────────────────────
  const x = require("./a")         import x from "./a.js"
  module.exports = { foo }         export { foo }
  synchronous, dynamic             static, hoisted, async-friendly
  __dirname available              import.meta.url instead
  interop: ESM ──can import──► CJS   |   CJS ──import() only──► ESM
🧠
Analogy 1 — two languages in one office: CJS and ESM are like colleagues speaking different languages. ESM speakers can understand CJS fine, but a CJS speaker needs a special interpreter (dynamic import()) to talk to ESM. Mixing them without knowing who speaks what causes confusion.
🔌
Analogy 2 — two plug standards: CJS is the old socket, ESM the new one. New devices (ESM) ship with an adapter to use old sockets, but old devices (CJS) can't just plug into the new socket without extra effort. The "type" field is the label on the wall telling you which socket a room has.
Same module, both systems
// package.json: { "type": "module" }  → files are ESM by default

// ESM (modern, preferred for new projects)
import { readFile } from "node:fs/promises";
export function parse(x) { /* ... */ }

// CommonJS (still everywhere in the ecosystem)
const { readFile } = require("node:fs/promises");
module.exports = { parse };
// tip: to load ESM from CJS, use  await import("./esm-only.js")
🧒
In plain wordsNode has two ways to split code into files. The old way uses require(); the new standard way uses import. A little setting ("type": "module") tells Node which one a file speaks. If you mix them up, you get confusing errors. New projects should use the modern import style; just know the old style is still everywhere.

✅ Do

  • Choose ESM for new projects; set "type": "module" and use .js import extensions
  • Know how to tell which system a file uses before debugging import errors
  • Use dynamic import() to pull ESM-only packages into CJS code

❌ Don't

  • Mix require and import in the same file — pick one per file
  • Assume __dirname exists in ESM — use import.meta.url
Gotcha: the ESM/CJS boundary is asymmetric and bites hardest with third-party packages — many are CJS-only or ESM-only, and a "dual" package can even load twice (once as CJS, once as ESM), giving you two separate copies of its state (the "dual package hazard"). When an import mysteriously fails or a singleton isn't singular, suspect the module system. Standardize on ESM and check a dependency's exports map before adopting it (topic 5).
05npm & package.json — deps, scripts, lockfiles

Scenario: "works on my machine" but breaks in CI — different dependency versions. package.json declares what you want; the lockfile pins exactly what you got. Understanding versions, dep types, and the lockfile is what makes builds reproducible.

What

package.json lists dependencies, scripts, and metadata. Semver ranges (^1.2.3) say "compatible-with"; the lockfile records the exact resolved versions installed.

Why

Without a committed lockfile, two installs can pull different versions → non-reproducible builds and "works on my machine" bugs.

How

npm install resolves ranges and writes package-lock.json. npm ci installs exactly the lockfile — deterministic, for CI.

Ranges vs the lockfile
  package.json (INTENT)          package-lock.json (REALITY)
  "express": "^4.18.0"    ──►    "express": "4.18.2"  (exact + hash)
     ^ = 4.x.x, >=4.18.0          plus its whole dependency tree, pinned

  npm install → may update within ranges, rewrites lock
  npm ci      → installs EXACTLY the lock (fails if out of sync) ← CI
  ~1.2.3 = patch only  ·  ^1.2.3 = minor+patch  ·  1.2.3 = exact
🧠
Analogy 1 — a recipe vs the exact grocery receipt: package.json is the recipe ("some sharp cheddar"); the lockfile is the receipt showing the exact brand and batch you bought. Share only the recipe and everyone's dish tastes slightly different. Share the receipt and everyone reproduces it exactly.
📋
Analogy 2 — a blueprint vs the as-built drawings: the blueprint (package.json) says "a door here, roughly this size"; the as-built (lockfile) records the precise door that was installed. Contractors on another site need the as-built to build the identical house, not just the loose blueprint.
Deps, scripts, and reproducible installs
{
  "type": "module",
  "scripts": {
    "dev": "tsx watch src/index.ts",
    "build": "tsc -p tsconfig.json",
    "test": "vitest run"
  },
  "dependencies":    { "express": "^4.18.0" },
  "devDependencies": { "typescript": "^5.4.0", "vitest": "^1.5.0" }
}
🧒
In plain wordspackage.json is your project's shopping list — what libraries you use and handy shortcut commands (npm run dev). But "^1.2.3" means "anything close to 1.2.3," which could differ next week. The lockfile writes down the exact versions you actually installed, so teammates and servers get the identical setup. Always commit the lockfile, and use npm ci on servers for an exact, repeatable install.

✅ Do

  • Commit the lockfile and use npm ci in CI for deterministic installs
  • Put build/test-only tools in devDependencies, runtime libs in dependencies
  • Understand ^ vs ~ vs exact before pinning

❌ Don't

  • Gitignore the lockfile — you throw away reproducibility
  • Run npm install in CI (it can drift the lock) — use npm ci
Gotcha: the "works on my machine" ghost almost always traces to a lockfile problem — an uncommitted lock, or npm install in CI silently bumping a transitive dependency to a new patch that broke something. And every dependency you add runs its own (and its dependencies') code with your privileges — npm install is npm install-and-execute-arbitrary-postinstall-scripts. Pin with a committed lock, audit regularly, and treat adding a dep as a security decision (topic 29).
06Streams & buffers — data that doesn't fit in memory★ core idea

Scenario: you readFile a 4GB log and your process OOMs — you loaded the whole thing into RAM. Streams process data in chunks as it flows, so you can handle files or network data far bigger than memory, with backpressure built in.

What

A stream moves data in chunks over time (readable, writable, transform, duplex). A Buffer is a fixed chunk of raw bytes. pipe connects streams.

Why

Streams give constant memory use regardless of data size, and built-in backpressure so a fast source can't overwhelm a slow sink (topic 26).

How

Read a chunk, process it, write it, repeat — never holding it all. pipeline() wires source → transforms → sink with error handling and backpressure.

Chunks flowing, not one giant blob
  readFile (BAD for big data):  [■■■■■■■■ 4GB all in RAM] → OOM

  stream (GOOD): read → transform → write, one chunk at a time
    source ──▮──► gzip ──▮──► file        memory stays ~constant
       backpressure: if the file (sink) is slow, the source PAUSES
       so buffers don't grow unbounded
🧠
Analogy 1 — a bucket brigade vs one giant tank: to move a lake, you don't build one lake-sized tank (readFile). You pass buckets down a line (chunks) — steady, bounded, and if the last person slows, the line waits (backpressure). Streams are the bucket brigade for data.
🎬
Analogy 2 — streaming a movie vs downloading it: you don't wait to download a 4GB film before watching — it streams in chunks and plays as it arrives, using little storage. And if your connection slows, it buffers (backpressure) rather than exploding. Node streams work the same way for any data.
pipeline: constant memory, backpressure, errors
import { pipeline } from "node:stream/promises";
import { createReadStream, createWriteStream } from "node:fs";
import { createGzip } from "node:zlib";

await pipeline(
  createReadStream("huge.log"),   // source — one chunk at a time
  createGzip(),                   // transform — compress each chunk
  createWriteStream("huge.log.gz")// sink — backpressure applied automatically
);
// 4GB file, ~constant memory — never loaded whole into RAM
🧒
In plain wordsIf you try to load a giant file all at once, you run out of memory. A stream instead moves the data in small chunks, like a bucket brigade — read a bit, handle it, pass it on, repeat. This uses hardly any memory no matter how big the data is. And if the receiving end is slow, the stream automatically pauses the source so nothing piles up. Great for big files, uploads, and network data.

✅ Do

  • Stream large files/uploads/responses instead of buffering them whole
  • Use pipeline() — it wires backpressure and error handling correctly
  • Handle error events — an unhandled stream error can crash the process

❌ Don't

  • readFile/buffer huge data into memory — that's how you OOM
  • Use raw .pipe() without error handling — errors don't propagate (use pipeline)
Gotcha: the classic streams bug is ignoring backpressure — if you write to a slow sink faster than it drains (e.g. res.write() in a loop without checking the return value), Node buffers the excess in memory and you OOM anyway, defeating the point of streams. pipeline() handles this for you; hand-rolled .pipe() chains silently drop errors and can leak. Always use pipeline() and respect the write() return value (topic 26).
07EventEmitter — Node's pub/sub in-process

Scenario: you want one part of your app to react when something happens elsewhere, without tightly wiring them together. EventEmitter is Node's built-in observer pattern — emit named events, and any number of listeners react. Streams, HTTP servers, and much of Node are built on it.

What

A base class with .on(name, fn) to subscribe and .emit(name, ...args) to fire. Many listeners can react to one event — in-process pub/sub.

Why

It decouples "something happened" from "who cares," and it's the foundation of Node's core (streams, sockets, servers all emit events).

How

Extend EventEmitter, emit events synchronously to registered listeners in order. Handle the special error event or it throws.

One emit, many listeners
   emitter.emit("order", data)
         │
         ├─► listener A (charge)      registered via .on("order", …)
         ├─► listener B (email)
         └─► listener C (log)
   synchronous, in-order, same process
   special: an "error" event with NO listener → THROWS (crashes)
🧠
Analogy 1 — a doorbell: pressing the bell (emit) alerts everyone in the house who's listening (listeners) — the cook, the dog, the kid. The presser doesn't know or care who responds. Add or remove listeners freely; the doorbell stays the same.
📢
Analogy 2 — a town crier: the crier shouts "news!" (emit) and whoever's in the square reacts. It's the in-house version of pub/sub (topic 14 in the distributed playbook) — same idea, but everyone's in the same building (process), and it's instant and synchronous.
Emit and react, decoupled
import { EventEmitter } from "node:events";

class Orders extends EventEmitter {}
const orders = new Orders();

orders.on("placed", (o) => charge(o));     // any number of listeners
orders.on("placed", (o) => sendEmail(o));
orders.on("error",  (e) => log.error(e));  // ALWAYS handle 'error'

orders.emit("placed", { id: 42 });          // fires both listeners, in order
// beware: too many listeners on one emitter → MaxListenersExceededWarning
🧒
In plain wordsSometimes you want one part of your program to announce "hey, this happened!" and let other parts react — without them being glued together. That's an EventEmitter: it's like a doorbell. One place rings it (emit), and everyone listening (on) responds. Lots of Node's own machinery (file streams, web servers) works this way under the hood.

✅ Do

  • Use it to decouple in-process reactions from what triggers them
  • Always attach an error listener — an unhandled one crashes the process
  • Remove listeners you no longer need (off) to avoid leaks

❌ Don't

  • Forget the error event — it's special and throws if unhandled
  • Add listeners in a loop without removing — you'll leak memory & hit the max warning
Gotcha: two traps. First, the error event is special — emit it with no listener attached and Node throws and crashes the process, so every emitter that can error needs an error handler. Second, forgotten listeners are a top cause of Node memory leaks: adding listeners in request handlers without removing them piles up references, and the MaxListenersExceededWarning is Node telling you you've probably got a leak, not that you should just raise the limit (topics 8, 28).
08Error handling — sync, async & uncaught★ core idea

Scenario: a rejected promise nobody caught takes down your whole server. In Node, errors come from three places — thrown sync, rejected promises, and emitted error events — and each needs a different net. Miss one and a single bad request crashes the process.

What

Node error sources: synchronous throws (try/catch), promise rejections (.catch/try-await), EventEmitter error events, plus process-level uncaughtException/unhandledRejection.

Why

An uncaught async error crashes the process. Since one process serves many requests, one unhandled rejection takes down everyone.

How

Catch where you can act; centralize error handling (middleware); treat process-level handlers as last-resort logging + graceful shutdown, not "keep running."

Three nets for three error sources
  throw new Error()      → try { } catch { }
  await rejectedPromise  → try { await … } catch { }  (or .catch)
  emitter.emit("error")  → emitter.on("error", …)
                               │ if none of these catch it ↓
  process.on("unhandledRejection" / "uncaughtException", …)
     ← LAST RESORT: log, then shut down gracefully (don't limp on)
🧠
Analogy 1 — safety nets under a trapeze: each act (sync, async, events) needs its own net right below it. The floor-level net (uncaughtException) exists only to prevent a fatal splat — but if you're relying on the floor net, the show is already broken. Catch errors close to where they happen.
🚢
Analogy 2 — bulkheads on a ship: one flooded compartment shouldn't sink the ship. Catching an error per request is a bulkhead — that request fails, the server sails on. An uncaught rejection is a breached hull with no bulkheads: the whole process goes down.
Centralized + last-resort handling
// per-request: catch and turn into a clean response (Express)
app.get("/user/:id", async (req, res, next) => {
  try { res.json(await getUser(req.params.id)); }
  catch (err) { next(err); }              // → central error middleware
});
app.use((err, req, res, next) => {         // ONE place to log & respond
  log.error({ err }); res.status(500).json({ error: "internal" });
});
// last resort: log & exit — don't pretend the process is healthy
process.on("unhandledRejection", (err) => { log.fatal(err); shutdown(); });
🧒
In plain wordsErrors in Node can come from three doors: normal code, background (promise) code, and event-based code — and each door needs its own catcher. If an error slips through all of them, it can crash the whole server, kicking off everyone using it, not just the one person who hit the bug. So you catch errors close to where they happen, funnel them to one handler, and keep a last-resort net that logs and restarts cleanly rather than limping along broken.

✅ Do

  • Catch async errors with try/await or .catch; funnel to one handler
  • Attach error listeners to emitters and streams
  • On uncaughtException/unhandledRejection: log, then shut down gracefully

❌ Don't

  • Swallow errors silently (catch {}) — you'll debug blind
  • Keep running after an uncaughtException — process state is now unknown
Gotcha: after an uncaughtException, the process is in an undefined state — a handler that logs and then continues serving can corrupt data or leak resources, because you don't know what invariants the crash broke. The correct pattern is log, drain in-flight work, and exit, letting your supervisor (PM2, Kubernetes) restart a fresh process. Treating these handlers as "keep the server alive" is how a small bug becomes silent data corruption (topic 27).
09process, env & configuration

Scenario: you hardcode a database URL, then need different values for dev, staging, and prod — and you accidentally commit a secret. Configuration via environment variables (the 12-factor way) keeps config out of code and secrets out of git.

What

The process object exposes the environment (process.env), arguments, signals, and lifecycle. Config comes from the environment, not hardcoded constants.

Why

Same build, different environments — no rebuild to change a URL. Secrets stay out of source control. It's the 12-factor app principle.

How

Read process.env.X, validate it at startup (fail fast if missing), and handle signals (SIGTERM) for graceful shutdown.

Config flows in from the environment
   .env (dev, gitignored)     platform secrets (prod)
        │                            │
        └──────────► process.env ◄───┘
                          │ validate at startup (fail fast!)
                          ▼
                    typed config object → used everywhere
   same code + different env = dev / staging / prod (no rebuild)
   signals: SIGTERM → stop taking traffic, finish in-flight, exit
🧠
Analogy 1 — a universal appliance with a voltage switch: the same appliance works in any country by reading the wall voltage, not by rewiring it. Env vars are that switch — one build adapts to any environment by reading its surroundings, instead of baking values into the code.
🔑
Analogy 2 — a hotel key card vs a key cut into the door: you don't carve the room key into the door (hardcoding secrets in code). You hand a card that's programmed per stay (env vars). Change guests without changing the door — and the "key" never travels in your luggage (git).
Validate config at startup, fail fast
import { z } from "zod";

const Env = z.object({                 // validate the environment
  DATABASE_URL: z.string().url(),
  PORT: z.coerce.number().default(3000),
  NODE_ENV: z.enum(["development", "test", "production"]),
});
export const config = Env.parse(process.env);  // throws NOW if misconfigured

// graceful shutdown on SIGTERM (Kubernetes sends this)
process.on("SIGTERM", async () => { await server.close(); process.exit(0); });
🧒
In plain wordsDon't write your database password or server address directly into your code — you'd need different values for your laptop vs the real server, and you might accidentally publish a secret. Instead, read those values from the environment (settings that live outside the code). The same program then works everywhere just by being handed different settings, and secrets never end up in your code history. Check the settings when the app starts, and quit loudly if any are missing.

✅ Do

  • Read config from process.env; validate it at startup and fail fast
  • Keep secrets out of git — use .env (gitignored) locally, secret stores in prod
  • Handle SIGTERM for graceful shutdown (finish in-flight requests)

❌ Don't

  • Hardcode URLs, ports, or secrets — you'll rebuild per environment and leak keys
  • Read process.env scattered everywhere untyped — centralize & validate once
Gotcha: process.env values are always stringsprocess.env.PORT is "3000", not 3000, and process.env.DEBUG is the string "false" which is truthy. Untyped, scattered env access leads to bugs like if (process.env.FEATURE_OFF) being true when it says "false". Parse and coerce once at startup into a typed config object, and fail loudly if anything's missing rather than crashing mysteriously at 3am when a code path first reads a missing var (topics 21, 27).
10CPU-bound work — worker threads & child processes

Scenario: you add an endpoint that resizes images or hashes passwords in a loop, and suddenly every request is slow — because that CPU work blocks the single event loop. For heavy computation, you offload to worker threads so the main loop stays free.

What

Worker threads run JS on separate threads (share memory via SharedArrayBuffer). Child processes run separate programs. Both move CPU work off the main event loop.

Why

Node's one JS thread is for coordinating I/O, not crunching numbers. A CPU-heavy task blocks all concurrent requests until it finishes.

How

Send the heavy task to a worker; it computes on its own thread and posts the result back. A worker pool reuses threads to avoid spawn overhead.

Offload the crunch, keep the loop free
  BAD: hash on main thread
    request → [ hash 500ms on JS thread ] → ALL other requests frozen ❌

  GOOD: worker thread pool
    request → main thread ──task──► [ worker 1 ] crunches
                          ◄─result─┘ (main loop keeps serving others) ✓
    pool of N workers ≈ N CPU cores for parallel heavy work
🧠
Analogy 1 — a manager who shouldn't do the heavy lifting: if the manager (main thread) personally hauls every crate, they can't coordinate anyone else — the whole team stalls. Delegating heavy crates to warehouse staff (workers) keeps the manager free to direct traffic. CPU work is the heavy crate.
🍳
Analogy 2 — a line cook vs a prep station: the line cook (event loop) plates fast orders. When a dish needs 20 minutes of chopping (CPU work), it goes to a prep cook (worker) so the line keeps moving. Making the line cook chop for 20 minutes freezes every other order.
Offload CPU work to a worker
// main.js — don't block the loop with heavy work
import { Worker } from "node:worker_threads";

function hashInWorker(password) {
  return new Promise((resolve, reject) => {
    const w = new Worker("./hash-worker.js", { workerData: password });
    w.on("message", resolve);          // result comes back off-thread
    w.on("error", reject);
  });
}
// hash-worker.js runs bcrypt on ITS thread → main loop stays responsive
// for real apps, use a POOL (e.g. piscina) to reuse workers
🧒
In plain wordsNode has one main worker for your code. If you give it a big number-crunching job (resizing images, hashing passwords in a loop), it's stuck on that and everyone else waits. The fix: hand heavy jobs to extra workers running on other CPU cores, so the main worker stays free to keep answering requests. Node is great at waiting for slow things (databases), but bad at doing heavy math on its main thread.

✅ Do

  • Move CPU-heavy tasks (crypto, image/video, big parsing) to worker threads
  • Use a worker pool (piscina) to reuse threads and cap concurrency
  • Consider offloading to a separate service/queue for very heavy jobs

❌ Don't

  • Run heavy synchronous computation on the main thread — it freezes all requests
  • Spawn a new worker per request without a pool — spawn cost adds up
Gotcha: reaching for worker threads is a smell you should question first — most Node performance problems are accidental blocking (a sync crypto call, JSON.parse on a huge payload, a regex catastrophe), not genuine parallel-compute needs. Fix the accidental blocking before adding thread complexity. And workers don't share normal memory — you pay a serialization cost passing data in/out, so tiny tasks can be slower in a worker than inline (topics 2, 28).
11HTTP servers — http, Express, Fastify

Scenario: you can spin up a server with Node's built-in http module in five lines, but real apps need routing, middleware, body parsing, and error handling — so you reach for a framework. Knowing the raw layer and the framework makes you effective.

What

Node's http module is the raw server (request/response streams). Express and Fastify add routing, middleware, parsing, and ergonomics on top.

Why

The raw module is fine for tiny things; frameworks give you the request pipeline (middleware), structured routing, and error handling you'd otherwise rebuild.

How

A request flows through a chain of middleware (auth, logging, parsing) to a route handler, which writes a response. Fastify adds schema-based validation & speed.

The request pipeline (middleware chain)
  request ─►[ logger ]─►[ body parse ]─►[ auth ]─►[ route handler ]─► response
                each middleware can: read/modify req, respond, or next()
  Express: flexible, huge ecosystem, callback-style middleware
  Fastify: faster, schema validation built in, async-first
  raw http: no routing/middleware — you build it yourself
🧠
Analogy 1 — airport security lanes: a request is a passenger passing through stations — ID check (auth), bag scan (body parse), logging — before reaching the gate (route handler). Each station can wave them on (next()) or stop them (respond early). Middleware is that series of checkpoints.
🏭
Analogy 2 — an assembly line: the request moves down a belt, each worker (middleware) adding or inspecting something — parse the body, attach the user, log it — until the final worker (handler) ships the response. You can insert or remove stations without rebuilding the whole line.
Express with middleware + a route
import express from "express";
const app = express();

app.use(express.json());                       // body-parsing middleware
app.use((req, _res, next) => {                  // logging middleware
  log.info({ method: req.method, url: req.url }); next();
});
app.get("/users/:id", async (req, res, next) => {
  try { res.json(await getUser(req.params.id)); }
  catch (err) { next(err); }                    // → error middleware (topic 8)
});
app.listen(config.PORT, () => log.info(`up on ${config.PORT}`));
🧒
In plain wordsNode can be a web server on its own, but it's very bare-bones. Frameworks like Express and Fastify add the convenient parts: matching URLs to code (routing) and a chain of steps every request passes through — like airport security lanes — for things like logging, reading the request body, and checking login. Each step can handle the request or pass it along. You could build all this yourself, but frameworks save you the trouble.

✅ Do

  • Use a framework (Express/Fastify) for real apps — routing, middleware, parsing
  • Order middleware deliberately (logging → parse → auth → routes → errors)
  • Consider Fastify when you want speed + built-in schema validation

❌ Don't

  • Rebuild routing/middleware on raw http for anything non-trivial
  • Forget the error-handling middleware — unhandled route errors crash you (topic 8)
Gotcha: in Express (the still-dominant version), async errors don't reach the error middleware on their own — a promise rejection in a route handler becomes an unhandled rejection unless you try/catch and call next(err) (or wrap handlers). This surprises everyone and crashes servers in production. Fastify and Express 5 handle async throws natively; on Express 4 you must wrap every async handler. Middleware order also matters enormously — put body-parsing before routes, error handling last (topics 8, 24).
Part II · TypeScript
12Why TypeScript — types as a safety net★ core idea

Scenario: a teammate renames a field from userName to username, and three months later a undefined is not a function blows up in production. TypeScript would have caught it the instant you typed it — that's the whole pitch: bugs found at author-time, not 2am.

What

TypeScript is JavaScript plus a static type system. You annotate shapes; the compiler checks them and then erases the types — the output is plain JS.

Why

It catches a huge class of bugs (typos, wrong shapes, null access) before running, and gives you autocomplete, safe refactors, and living documentation.

How

The tsc compiler type-checks your code, then emits JS with types stripped. Types exist only at compile time — zero runtime cost, zero runtime checks.

Types check, then vanish
  your .ts ──► tsc type-checks ──► emits plain .js (types ERASED)
                  │ error here                 runs in Node/browser
                  ▼ (before it ever runs)
   caught at AUTHOR TIME:  user.usernam  ✗  "did you mean username?"
   without TS: ships fine, crashes at runtime when that line executes
   ⚠ types are gone at runtime → they don't validate real input (topic 21)
🧠
Analogy 1 — spell-check for code shapes: a red squiggle catches your typo as you write, not after you've printed a thousand copies. TypeScript squiggles when you misuse a shape — call a missing method, pass the wrong argument — right in your editor, before anyone runs it.
📐
Analogy 2 — a jigsaw where pieces only fit right: types make each function's inputs and outputs a specific puzzle-piece shape. If a piece doesn't fit (wrong type), you can't force the puzzle together — the compiler stops you. JavaScript lets you jam mismatched pieces and discover the mess later.
The bug TypeScript catches instantly
interface User { username: string; age: number }

function greet(u: User) {
  return `Hi ${u.usernam}`;   // ✗ TS: Property 'usernam' does not exist
}                              //   caught while typing — never ships

greet({ username: "ada" });   // ✗ TS: 'age' is missing
// plain JS: both bugs run fine, then explode at runtime
🧒
In plain wordsPlain JavaScript lets you make typos and shape mistakes that only blow up later, when a user hits them. TypeScript is like spell-check for your code: it knows the exact shape of your data and warns you the moment you misuse it — call a function wrong, misspell a field, forget a value. You fix it in your editor instead of getting a 2am crash. After checking, the types disappear and it runs as normal JavaScript.

✅ Do

  • Adopt TS for anything beyond a script — the refactor safety alone pays off
  • Let inference do the work; annotate boundaries (function signatures, exports)
  • Remember types are compile-time only — validate real input separately (topic 21)

❌ Don't

  • Assume types protect you at runtime — a bad API response ignores them
  • Over-annotate obvious locals — inference is usually cleaner (topic 13)
Gotcha: the number-one TypeScript misconception is that types exist at runtime — they don't. After compilation every type annotation is erased, so a value arriving from the network, a JSON parse, or any-typed library can be a completely different shape than its type claims, and TS won't catch it. Your User type is a promise you make to the compiler, not a runtime guarantee. Untrusted input still needs real validation at the boundary (topic 21).
13Structural typing & inference

Scenario: coming from Java/C#, you expect to name a type before you can use it. But in TS, if an object has the right shape, it fits — no explicit relationship needed. And you rarely annotate anything, because TS infers types for you. Both surprise newcomers.

What

Structural typing: compatibility is by shape, not name ("duck typing" checked at compile time). Inference: TS figures out types from values so you write fewer annotations.

Why

Structural typing makes TS flexible and JS-like; inference keeps code clean — you get safety without the annotation noise of nominal systems.

How

If a value has all required members of a type, it's assignable — regardless of declared name. TS infers variable, return, and generic types from usage.

Shape fits, name irrelevant — and inference
  STRUCTURAL: "has an x and y number? → it's a Point"
    type Point = { x: number; y: number }
    const p = { x: 1, y: 2, label: "a" }   // extra prop ok
    takesPoint(p)  ✓  (shape matches, no 'implements Point' needed)

  INFERENCE:
    const n = 42          // TS infers: number  (no annotation)
    const arr = [1, 2]    // number[]
    function id(x) → return type inferred from x
🧠
Analogy 1 — hiring by skills, not by diploma: structural typing hires anyone who can do the job (has the right methods), regardless of what school (class name) they came from. If it can quack, it's a duck. Nominal systems demand the right diploma even if the skills are identical.
🔍
Analogy 2 — a smart assistant that fills in the blanks: inference is an assistant who sees const price = 9.99 and silently notes "that's a number" without you saying so. You only spell things out at the important doorways (function inputs/outputs); everywhere else, it just knows.
Shape compatibility + let inference work
type Named = { name: string };
function hello(x: Named) { return `hi ${x.name}`; }

const dog = { name: "Rex", legs: 4 };  // not declared as Named...
hello(dog);                             // ✓ structurally compatible

// inference: annotate the boundary, not every local
function total(items: { price: number }[]) {
  return items.reduce((s, i) => s + i.price, 0);  // return inferred: number
}
🧒
In plain wordsTypeScript decides if two things match by looking at their shape, not their name — "if it has a name and an age, it counts as a Person," no formal label needed. And it's smart enough to figure out most types on its own from the values you write, so you don't have to spell out every type. You mainly write types at the "doorways" — what goes into and comes out of your functions — and let TS handle the rest.

✅ Do

  • Let inference handle locals; annotate function params, returns, and exports
  • Design by shape — accept the minimal shape a function needs
  • Trust structural compatibility; you rarely need explicit implements

❌ Don't

  • Annotate every variable — noisy and fights inference
  • Assume TS is nominal — unrelated types with matching shapes are compatible
Gotcha: structural typing has a sharp edge called excess property checks — passing an object literal directly with extra fields is rejected (hello({ name: "x", oops: 1 }) errors), but passing the same object via a variable is allowed. This inconsistency confuses everyone. It's a deliberate typo-catcher for literals. Also, inference can widen a const object's fields to general types (string not "GET"), breaking discriminated unions until you add as const (topics 15, 18).
14interface vs type — and when to use each

Scenario: you need to describe an object's shape and see both interface User {} and type User = {} in the codebase. They overlap 90%, but the 10% difference (declaration merging, unions, computed types) determines which to reach for.

What

Both name a type. interface describes object shapes and can be extended and merged. type aliases any type — unions, primitives, tuples, mapped/computed types.

Why

Interfaces are ideal for object contracts and public APIs (extendable, mergeable). Type aliases are needed for unions and type-level computation.

How

Use interface for object shapes you may extend; type for unions, intersections, tuples, and anything the interface can't express.

Overlap 90%, differ at the edges
  INTERFACE                       TYPE
  ─────────                       ────
  object shapes                   ANY type: unions, tuples, primitives
  extends (inheritance)           & and | (intersection / union)
  declaration MERGING (reopen)    computed / mapped / conditional types
  great for public API contracts  great for "A | B", utility types
  → both work for plain objects; pick by what ELSE you need
🧠
Analogy 1 — a form template vs a formula: an interface is a fill-in form describing an object's fields, and you can staple extra pages to it (extend/merge). A type is a formula that can express "this OR that," combine shapes, or compute new shapes — more like algebra than a form.
🧱
Analogy 2 — Lego bricks vs a recipe: interfaces are bricks you snap together and extend (extends). A type is a recipe that can say "either soup or salad" (union) or "soup and a side" (intersection) — expressing combinations bricks can't.
Each where it shines
// interface: object contract you might extend
interface User { id: string; name: string }
interface Admin extends User { role: "admin" }

// type: unions and things interface can't do
type Status = "active" | "banned" | "pending";   // union — needs type
type Point = [number, number];                    // tuple
type WithId<T> = T & { id: string };              // intersection/generic
// rule of thumb: interface for objects/APIs, type for everything else
🧒
In plain wordsBoth interface and type let you name a shape, and for plain objects they're basically the same. The difference: interface is best for describing objects you might build on top of (extend), while type can also describe "this or that" choices (unions) and fancier combinations. Simple rule: use interface for object shapes, type when you need a union or something clever.

✅ Do

  • Use interface for object shapes and extendable public API contracts
  • Use type for unions, tuples, and computed/mapped types
  • Pick one convention per team for the overlapping cases and be consistent

❌ Don't

  • Agonize over it for plain objects — they're interchangeable there
  • Try to express a union with interface — it can't; use type
Gotcha: interfaces support declaration merging — two interface User declarations in scope silently combine into one. This is a feature for augmenting library types (e.g. adding a field to Express's Request), but a footgun when two unrelated interfaces accidentally share a name and merge into a Frankenstein type with no error. Type aliases can't merge (duplicate type = error), which is often safer for your own app code (topics 22, 13).
15Unions, narrowing & discriminated unions★ core idea

Scenario: a value can be a success or an error, a string or a number. Union types model "one of several," and narrowing lets TS know which one you're holding in each branch — turning runtime if checks into compile-time safety.

What

A union (A | B) is "one of these types." Narrowing is how TS deduces the specific type inside a branch. A discriminated union tags each variant with a literal field for clean, exhaustive handling.

Why

They model real-world "either/or" data precisely and make impossible states unrepresentable — the compiler forces you to handle every case.

How

Check with typeof, in, or a literal tag; inside the branch TS narrows to that variant. A common tag field (kind) enables exhaustive switch.

Tag each variant, switch exhaustively
  type Result =
    | { kind: "ok";  value: number }      ← discriminant: "kind"
    | { kind: "err"; message: string }

  switch (r.kind) {
    case "ok":  r.value    // TS knows: value exists here
    case "err": r.message  // TS knows: message exists here
    default: assertNever(r) // ✓ compile error if a case is unhandled
  }
🧠
Analogy 1 — mail sorted by a stamp: the discriminant field is a colored stamp on each envelope. Once you read the stamp ("ok" or "err"), you know exactly what's inside and how to handle it. Without the stamp you'd have to guess the contents; the tag makes each branch certain.
🚦
Analogy 2 — a traffic light with defined states: a light is red, yellow, or green — never "sort of red." Discriminated unions model exactly those distinct states, and an exhaustive switch is like a rule that must specify behavior for every color, so you can't forget one.
Narrowing + exhaustive handling
type Shape =
  | { kind: "circle"; r: number }
  | { kind: "rect"; w: number; h: number };

function area(s: Shape): number {
  switch (s.kind) {
    case "circle": return Math.PI * s.r ** 2;   // s narrowed to circle
    case "rect":   return s.w * s.h;            // s narrowed to rect
    default: {
      const _exhaustive: never = s;   // ✗ compile error if a case is missing
      return _exhaustive;
    }
  }
}
🧒
In plain wordsSometimes a value can be one of a few things — like "success or failure." A union type says exactly that. Then, when you check which one it is (with an if or switch), TypeScript is smart enough to know "oh, in this branch it's the success kind" and lets you use the right fields safely. Give each option a little label (like kind: "ok") and TS can even force you to handle every option, so you never forget a case.

✅ Do

  • Model "either/or" data as discriminated unions with a literal tag
  • Use an exhaustive switch + a never default to catch missed cases at compile time
  • Narrow with typeof, in, or the tag — then use the specific fields safely

❌ Don't

  • Use loose optional fields (value? + error?) where a tagged union is clearer
  • Cast to access a variant's fields — narrow properly so TS proves it
Gotcha: the exhaustiveness check (the never default) is the underrated superpower here — add a new variant to the union and every non-exhaustive switch lights up red across your codebase, telling you exactly what to update. Skip it, and adding a variant silently falls through to a wrong default at runtime. Also, narrowing can be "un-narrowed" by an await or a function call in between — TS conservatively forgets what it knew if the value could have changed (topics 13, 18).
16Generics — reusable, type-safe code★ core idea

Scenario: you write a first(arr) helper. Typed as any[], it throws away type info — callers get any back. Generics let the function remember the type it was given, so first(numbers) returns a number, not any.

What

Generics are type parameters — placeholders (<T>) that let a function, class, or type work over many types while preserving the specific one.

Why

They give you reuse and safety — write once, keep exact types. Without them you'd duplicate code or fall back to unsafe any.

How

Declare a type parameter <T>; TS infers it from arguments. Constraints (T extends …) limit what T can be while keeping it specific.

A placeholder the compiler fills in
  function first<T>(arr: T[]): T | undefined { return arr[0]; }

  first([1, 2, 3])        T = number  → returns number
  first(["a", "b"])       T = string  → returns string
  first(users)            T = User    → returns User
        ▲ the exact type flows THROUGH the function (no 'any')

  constraint: <T extends { id: string }>  → T must have an id
🧠
Analogy 1 — a labeled shipping box: a generic is a box that works for anything, but it remembers what you put in it. Put in books, you get books out — not "generic contents." any is a box that forgets its label, so you never know what you'll pull out. Generics keep the label.
🏭
Analogy 2 — a machine with swappable molds: one injection-molding machine (the function) makes parts of whatever mold (T) you load — same machine, exact output shape. You don't build a separate machine per part, and you don't get shapeless blobs (any). The mold is the type parameter.
Preserve the type through the function
function first<T>(arr: T[]): T | undefined { return arr[0]; }
const n = first([1, 2, 3]);       // n: number  (not any!)
const s = first(["a", "b"]);      // s: string

// constrained generic: T must have an id
function byId<T extends { id: string }>(items: T[], id: string) {
  return items.find((x) => x.id === id);   // returns T | undefined
}
byId(users, "u1");                // keeps the full User type
🧒
In plain wordsSay you write a helper that grabs the first item of a list. You want it to work for a list of anything — numbers, users, whatever — but still remember what kind it got, so if you give it numbers, it gives back a number. Generics are a placeholder (written <T>) that says "whatever type comes in, that same type comes out." One reusable function, full type safety — instead of writing a separate version for each type or giving up and using the unsafe any.

✅ Do

  • Use generics to keep exact types flowing through reusable helpers
  • Add constraints (T extends …) so T has the members you need
  • Let TS infer type args from usage — you rarely pass them explicitly

❌ Don't

  • Reach for any when a generic keeps the type safely (topic 18)
  • Over-genericize simple code — generics that add no reuse just add noise
Gotcha: generics are tempting to overuse — a function with three type parameters and nested constraints that only ever gets called with one concrete type is abstraction for its own sake, and it makes error messages cryptic and code unreadable. Add a generic only when you genuinely need one type to flow through to the output or relate two arguments. If T appears only once in a signature, it probably shouldn't be generic at all (topics 12, 17).
17Utility types — Partial, Pick, Omit, Record

Scenario: you have a User type and now need "a User with all fields optional" (for a PATCH), "just the id and name" (for a list), or "User without the password" (for an API response). Rewriting each by hand is error-prone. Utility types derive them automatically.

What

Built-in generic types that transform existing types: Partial<T> (all optional), Pick<T,K>, Omit<T,K>, Record<K,V>, Required, Readonly, ReturnType, and more.

Why

They keep derived types in sync with the source — change User once and every derived type updates. No hand-maintained duplicates drifting apart.

How

They're generic mapped types over your type. Compose them (Omit<User, "password">) to shape exactly the type you need.

Derive types instead of duplicating
  type User = { id: string; name: string; password: string }

  Partial<User>           → all fields optional        (PATCH body)
  Pick<User, "id"|"name"> → { id, name }               (list view)
  Omit<User, "password">  → { id, name }               (safe API output)
  Record<string, User>    → { [k: string]: User }      (a lookup map)
  change User → every derived type updates automatically ✓
🧠
Analogy 1 — a master document and auto-generated views: User is the master doc. Utility types are saved "views" — a summary (Pick), a redacted copy (Omit), a draft with everything optional (Partial). Edit the master and every view refreshes. Hand-copying would leave stale, mismatched versions.
🎛️
Analogy 2 — photo filters: your original photo is the type; filters transform it without editing the original — blur out a field (Omit), crop to two fields (Pick), make everything faded/optional (Partial). Stack filters (compose) to get exactly the look you want, always derived from the one source.
Derive, don't duplicate
interface User { id: string; name: string; password: string }

type UserPatch  = Partial<User>;              // all optional (PATCH)
type UserSummary = Pick<User, "id" | "name">;  // list view
type SafeUser   = Omit<User, "password">;      // API response (no secret)
type UsersById  = Record<string, User>;        // { [id]: User } lookup

function toResponse(u: User): SafeUser {
  const { password, ...safe } = u; return safe;   // never leak the secret
}
🧒
In plain wordsOnce you have a type like User, you often need slight variations: all fields optional (for updates), only a couple fields (for a list), or the User without the password (for what you send back). Instead of rewriting those by hand — and risking them drifting out of sync — TypeScript has ready-made helpers (Partial, Pick, Omit, Record) that build the variation for you. Change User once and all the variations update themselves.

✅ Do

  • Derive related types with utility types so they stay in sync with the source
  • Use Omit to strip secrets (password) from API response types
  • Compose them: Partial<Pick<User, "name" | "age">>

❌ Don't

  • Hand-maintain parallel types that must match — they will drift
  • Rely on a SafeUser type to actually remove the password at runtime — strip it in code too
Gotcha: Omit<User, "password"> removes the field from the type, not from the runtime object — the actual user.password is still there and will happily serialize into your JSON response unless you also delete it in code. Teams ship password/PII leaks precisely because they trusted the type. Utility types shape compile-time contracts; you must still strip sensitive fields at runtime (topics 12, 29).
18any vs unknown vs never

Scenario: you don't know a value's type, so you slap any on it — and silently disable type-checking for everything it touches. unknown is the safe alternative, and never is the "this can't happen" type. Confusing these three quietly guts your type safety.

What

any: opt out of checking (anything goes). unknown: "some type, but you must narrow before use." never: no value is possible (impossible states, exhaustiveness).

Why

any is a hole in your type safety that spreads; unknown keeps safety by forcing a check; never proves code is unreachable.

How

Prefer unknown for untyped input and narrow it. Reserve any for rare escape hatches. Use never for exhaustiveness (topic 15) and impossible branches.

The safety difference
  any:      const x: any = json;   x.foo.bar()   // no error, MAY crash ✗
  unknown:  const x: unknown = json;
              x.foo               // ✗ TS: must narrow first
              if (isUser(x)) x.name   // ✓ safe after a check
  never:    a value that can't exist — return type of a throw,
            the exhaustiveness default (topic 15)
  any = "trust me" (unsafe)   unknown = "prove it" (safe)
🧠
Analogy 1 — a mystery box: any is grabbing blindly into a mystery box and using whatever you pull as if you know it — sometimes it bites. unknown is the same box, but you're required to look and identify what you grabbed before using it. never is an empty box that, by definition, holds nothing.
🛂
Analogy 2 — border control: any waves everyone through without checking passports (unsafe). unknown demands ID before entry — you must verify who they are first (narrow). never is a checkpoint no one can pass, marking a place that should be impossible to reach.
Prefer unknown; narrow before use
// BAD: any disables checking and spreads
function parseA(json: string): any { return JSON.parse(json); }
parseA("{}").anything.goes();     // no error → runtime crash

// GOOD: unknown forces a check
function parseU(json: string): unknown { return JSON.parse(json); }
const data = parseU("{}");
if (typeof data === "object" && data && "name" in data) {
  // narrowed — safe to use data.name here
}
// never: exhaustiveness (topic 15) & functions that always throw
function fail(msg: string): never { throw new Error(msg); }
🧒
In plain wordsWhen you don't know what type something is, you have two choices. any means "don't check anything" — easy, but it turns off safety and lets bugs through. unknown means "I don't know yet, so make me check before I use it" — a little more work, but safe. And never is TypeScript's way of saying "this situation is impossible." Reach for unknown, avoid any, and let never guard the impossible cases.

✅ Do

  • Use unknown for untyped/external data and narrow before using it
  • Use never for exhaustiveness checks and always-throw functions
  • Enable noImplicitAny so accidental any is flagged (topic 20)

❌ Don't

  • Reach for any to silence an error — you disable checking for everything downstream
  • Let any spread through your codebase from one untyped library
Gotcha: any is contagious — it doesn't just disable checking on one value, it spreads: anything derived from an any becomes any, silently hollowing out type safety across whole call chains while your editor still shows green. One any-typed API client can un-type a feature. That's why a single untyped dependency or a lazy as any cast is more damaging than it looks. Use unknown at boundaries and quarantine unavoidable any behind a typed wrapper (topics 21, 22).
19Type guards & assertions

Scenario: you have an unknown from a parsed JSON and need TS to treat it as a User. You can assert "trust me, it's a User" (as User — unchecked and dangerous) or write a type guard that actually verifies it and teaches TS the type safely.

What

A type guard is a function returning x is T that checks a value and narrows it. A type assertion (as T) just tells TS a type with no runtime check.

Why

Guards give you safety and narrowing for real data. Assertions bypass the compiler — convenient but a lie if you're wrong, causing silent runtime bugs.

How

Write function isUser(x): x is User that returns a boolean after checking fields; TS narrows in the true branch. Use as only when you truly know more than TS.

Verify (guard) vs merely claim (assert)
  ASSERTION (unchecked):  const u = data as User;   // "trust me"
     if data isn't a User → no error now, CRASH later ✗

  TYPE GUARD (checked):
     function isUser(x: unknown): x is User {
       return typeof x === "object" && x !== null && "id" in x;
     }
     if (isUser(data)) { data.id }   // ✓ TS narrows, AND it's real
🧠
Analogy 1 — checking ID vs claiming to know someone: a type guard is a bouncer who actually checks the ID before letting someone in (verified). An assertion is you telling the bouncer "he's with me" without proof — fine if true, a problem if you're wrong. Guards verify; assertions vouch.
🧪
Analogy 2 — a lab test vs a label: a guard runs the test and confirms what the substance is. An assertion just slaps a label on the bottle. Trust the label and it's actually something else, and the reaction (runtime crash) happens later, far from the mislabeling.
A real guard beats a cast
// assertion — no check, dangerous with external data
const risky = JSON.parse(body) as User;    // lies if body isn't a User

// type guard — verifies, then narrows safely
function isUser(x: unknown): x is User {
  return typeof x === "object" && x !== null
    && "id" in x && typeof (x as any).id === "string";
}
const data: unknown = JSON.parse(body);
if (isUser(data)) { save(data); }          // ✓ safe & typed
else { throw new Error("bad payload"); }
// best for whole-object validation: a schema library (zod) — topic 21
🧒
In plain wordsWhen you have a value and want TypeScript to treat it as a specific type, you have two options. You can assert — basically say "trust me, it's a User" — but if you're wrong, it crashes later with no warning. Or you can write a type guard — a little function that actually checks the value and only then tells TS "yes, it's a User." Guards are safe because they verify; assertions are just promises you might break.

✅ Do

  • Write type guards (x is T) to safely narrow real/external data
  • Reserve as for when you genuinely know more than the compiler can
  • For whole objects, prefer a schema validator (zod) over hand-written guards (topic 21)

❌ Don't

  • Use as T on untrusted input — you're disabling the safety net
  • Chain as unknown as T to force a cast — that's a code smell hiding a bug
Gotcha: a type assertion (as) generates zero runtime code — it's a compile-time claim only, so data as User on a malformed API response produces a value that TS thinks is a User but isn't, and the crash surfaces far away where you finally touch a missing field. The double-cast x as unknown as T is an even louder alarm: you're explicitly telling the compiler to stop protecting you. For anything crossing a trust boundary, validate at runtime — don't assert (topics 18, 21).
20tsconfig & strict mode

Scenario: your TS project "compiles fine," yet undefined bugs still slip through — because strict is off and TS isn't checking for null. tsconfig.json is where TypeScript's rigor is dialed in, and strict is the single most important setting.

What

tsconfig.json configures the compiler: target JS version, module system, output, and type-checking strictness. "strict": true turns on a bundle of the important checks.

Why

Without strict mode (especially strictNullChecks), TS lets null/undefined flow anywhere — you lose most of TS's bug-catching value.

How

Set strict: true from day one. It enables strictNullChecks, noImplicitAny, and more. Add target, module, and outDir to control output.

strict flips on the checks that matter
  "strict": true  ≈ turns on all of:
    strictNullChecks    → null/undefined must be handled explicitly
    noImplicitAny       → no silent 'any' on untyped params
    strictFunctionTypes, strictBindCallApply, ...

  without strictNullChecks:  const u: User = find(); u.name  // may be undefined!
  with it:                   find() returns User | undefined → TS forces a check
🧠
Analogy 1 — a smoke detector's sensitivity: tsconfig is the panel; strict is turning the detector to full sensitivity. On low sensitivity it ignores real smoke (null bugs) until there's a fire. Full sensitivity catches the wisp early. Running TS without strict is like a detector with the battery half out.
🧗
Analogy 2 — climbing with vs without a harness: non-strict TS lets you climb (write code) but skips the safety gear for the scariest fall — null/undefined. strict clips you in: you can't proceed past a possibly-empty hold without securing it first. Slower to clip, but it's the falls that kill.
A sane starting tsconfig
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "outDir": "dist",
    "sourceMap": true,
    "esModuleInterop": true
  },
  "include": ["src"]
}
🧒
In plain wordstsconfig.json is the settings file for TypeScript — it decides how picky the checker is and what kind of JavaScript to produce. The most important setting is "strict": true. With it off, TypeScript ignores a huge category of bugs (especially "this might be empty/undefined"), so you lose most of the protection you came for. Turn strict on from the very start — it's much harder to add later.

✅ Do

  • Set "strict": true from day one of a project
  • Add noUncheckedIndexedAccess to catch unsafe array/object indexing
  • Match target/module to your Node version and module system (topic 4)

❌ Don't

  • Leave strict off "to move faster" — you forfeit TS's main benefit
  • Retrofit strict onto a huge codebase all at once — it's a painful flag day
Gotcha: turning strict on after a codebase has grown is brutal — strictNullChecks alone can surface thousands of "possibly undefined" errors that were always latent bugs, and teams often bail and leave it off, permanently forfeiting TS's biggest win. Start strict; you can't easily add it later. And note strict is a moving target — new TS versions fold new checks into it, so an upgrade can introduce fresh errors (that's a feature, catching real issues) (topics 12, 18).
21Typing the boundary — runtime validation (zod)★ core idea

Scenario: your API handler is typed (body: CreateUser), so you trust body.email. But the request is just JSON from the internet — TS types are erased at runtime (topic 12), so a malicious or buggy client can send anything. You need runtime validation at every trust boundary.

What

Validating untrusted input (HTTP bodies, env, DB rows, third-party APIs) at runtime with a schema library like zod, which also infers the TS type from the schema.

Why

TS types vanish at runtime, so a type annotation is not a guarantee about real data. One schema gives you both a runtime check and a static type — kept in sync.

How

Define a schema, parse() incoming data (throws on invalid), and derive the type with z.infer. Validate at the edge; trust freely inside.

Validate at the edge; trust within
  internet ──JSON──►[ zod schema.parse() ]──► typed & verified ──► app
     untrusted           throws on bad input        safe to use
  one schema gives you BOTH:
     • runtime check (real guarantee)
     • static type via z.infer<typeof Schema>  (kept in sync)
  skip this → your CreateUser type is a hope, not a fact
🧠
Analogy 1 — customs at the border: inside your country (your code) you trust documents. But at the border (the API edge), customs inspects everything entering (validation). A TS type without runtime validation is trusting a stranger's word that their bag is safe — customs actually opens it.
💧
Analogy 2 — a water filter at the intake: you filter water where it enters the house, then trust every tap inside. Validate input once at the boundary, and the rest of your typed code drinks safely. No filter at the intake means every tap is a gamble, no matter how clean the pipes look.
One schema → runtime check + static type
import { z } from "zod";

const CreateUser = z.object({
  email: z.string().email(),
  age: z.number().int().min(0),
});
type CreateUser = z.infer<typeof CreateUser>;   // static type, in sync

app.post("/users", (req, res) => {
  const result = CreateUser.safeParse(req.body);   // runtime check
  if (!result.success) return res.status(400).json(result.error);
  const user = result.data;      // ✓ verified AND typed as CreateUser
  save(user);
});
🧒
In plain wordsRemember TypeScript's types disappear when the code runs. So even though your function says "the body is a CreateUser," the actual data came from the internet and could be anything. You must actually check it when it arrives, using a tool like zod. Bonus: zod gives you the check and the TypeScript type from one definition, so they never drift apart. Check untrusted data at the door; trust it everywhere inside.

✅ Do

  • Validate all untrusted input at the boundary (bodies, params, env, external APIs)
  • Derive the TS type from the schema (z.infer) so type and check stay in sync
  • Validate env vars and even DB rows if they can drift from your types

❌ Don't

  • Trust a TS type on data from outside your program — types don't run
  • Cast request bodies with as — that's a lie about untrusted data (topic 19)
Gotcha: this is where the "types are erased" fact (topic 12) turns into real security bugs — teams annotate a handler (body: CreateUser), feel safe, and ship an endpoint that will cheerfully process { age: "DROP TABLE" } because nothing checks at runtime. The type is a comment the compiler happens to read; it does nothing to the actual request. Every trust boundary — HTTP, queue messages, env, third-party responses — needs runtime validation, not just a type (topics 12, 29).
22Declaration files & typing libraries

Scenario: you install a JavaScript-only package and TS complains it has no types, or you need to add a property to Express's Request. Declaration files (.d.ts) are how types are described for JS code — including the whole @types ecosystem.

What

Declaration files (.d.ts) contain types only, no implementation. They describe the shape of JS code so TS can check calls into it — used for libraries and to augment existing types.

Why

Most of the JS ecosystem is untyped or ships types separately (@types/*). Declaration files let TS understand and check those libraries and let you extend types you don't own.

How

Libraries ship .d.ts (or you install @types/pkg). You can write your own to type a JS module or augment types via declaration merging (topic 14).

Types without implementation
  library.js  (runtime code, no types)
  library.d.ts (types only):  export function foo(x: number): string
        │ TS reads this to check YOUR calls into the JS lib
  sources of types:
    • bundled with the package (best)
    • @types/pkg from DefinitelyTyped  (npm i -D @types/express)
    • your own .d.ts for untyped modules or to AUGMENT existing types
🧠
Analogy 1 — a menu without the kitchen: a .d.ts is the menu — it lists what's available and what each dish takes and returns, without the recipes (implementation). TS reads the menu to make sure you order correctly, even though the cooking happens in plain JavaScript elsewhere.
🗺️
Analogy 2 — a map of a building you didn't design: when you use a library you didn't write, the .d.ts is a floor plan showing every room (function) and its doors (parameters). With the map, TS can stop you from walking into a wall (wrong call). No map, and you're feeling around in the dark (any).
Get types, and augment ones you don't own
// 1) most libs: install types if not bundled
//    npm i -D @types/express     (DefinitelyTyped)

// 2) type an untyped JS module yourself (my-lib.d.ts)
declare module "legacy-lib" {
  export function doThing(n: number): string;
}

// 3) AUGMENT an existing type (add req.user to Express) — express.d.ts
declare global {
  namespace Express { interface Request { user?: { id: string } } }
}
export {};   // make this file a module
🧒
In plain wordsA lot of JavaScript libraries don't come with TypeScript types built in. A declaration file (.d.ts) is a "types-only" description — it says what functions exist and what they take and return, without the actual code. That lets TypeScript check that you're using the library correctly. Often you just install a matching @types/... package; sometimes you write a small .d.ts yourself, or use one to add a field to a type you don't own (like attaching the logged-in user to a request).

✅ Do

  • Install @types/pkg when a JS library doesn't bundle its own types
  • Write a small .d.ts to type an untyped module you depend on
  • Use module augmentation to safely add fields to library types (e.g. Request.user)

❌ Don't

  • Fall back to any for a whole untyped library — write a minimal .d.ts instead
  • Put runtime code in a .d.ts — they're types only, erased entirely
Gotcha: @types packages version independently from the library they describe, so a mismatch — @types/node ahead of your Node version, or @types/express@5 against express@4 — produces type errors that describe an API you don't actually have. When the types and reality disagree, check that versions line up before trusting the error. And a hand-written .d.ts is an unchecked promise: if it lies about the JS, TS believes the lie (topics 14, 5).
Part III · Building & Shipping
23Project setup & tooling — tsx, tsc, esbuild

Scenario: you write TypeScript, but Node runs JavaScript. So how do you run your .ts files in dev, and ship them to prod? The answer is a small toolchain: a fast runner for dev, and a compiler/bundler for the build.

What

The tools that turn TS into runnable JS: tsc (official type-check + emit), tsx/ts-node (run TS directly in dev), esbuild/swc (ultra-fast transpile/bundle, no type-check).

Why

Fast tools (esbuild/swc) transpile in milliseconds but skip type-checking; tsc is slower but checks types. Real setups use both: fast run + separate type-check.

How

Dev: tsx watch for instant reloads. Build: transpile with esbuild/swc/tsc and type-check separately with tsc --noEmit in CI.

Transpiling ≠ type-checking
  DEV:   tsx/ts-node  → runs .ts instantly (strips types, no check)
  BUILD: esbuild/swc  → .ts → .js in ms   (strips types, NO check)
         tsc --noEmit → type-checks only  (the safety gate)
  CI:    run BOTH — fast transpile for output + tsc for correctness
  ⚠ fast tools don't type-check → a type error can still "build" fine
🧠
Analogy 1 — a fast translator vs a proofreader: esbuild is a lightning translator that converts TS→JS without judging correctness. tsc --noEmit is the proofreader who never publishes but catches every mistake. You want the translator's speed and the proofreader's rigor — run both.
🏎️
Analogy 2 — a race car with no brakes vs a safety inspector: esbuild is the race car — blazing, but it won't stop you driving off a cliff (type errors). The type-check is the inspector who signs off the car is safe. Racing without the inspection ships broken code fast.
Dev runner + separate type-check
$ npm i -D typescript tsx
$ npx tsx watch src/index.ts     # dev: run .ts instantly, auto-reload

# package.json scripts:
#   "dev":        "tsx watch src/index.ts"
#   "typecheck":  "tsc --noEmit"          <- the real safety gate (CI)
#   "build":      "tsc -p tsconfig.json"  (or esbuild for speed)
$ npm run typecheck && npm run build   # check THEN build
🧒
In plain wordsNode can't run TypeScript directly — it needs plain JavaScript. So while coding, you use a tool like tsx that runs your .ts files instantly. To ship, you convert them to .js. Important catch: the fast conversion tools don't check your types — they just strip them out. So you also run the real type-checker (tsc --noEmit) separately, usually in CI, to make sure there are no type mistakes before shipping.

✅ Do

  • Use a fast runner (tsx) in dev and a separate tsc --noEmit for checking
  • Run the type-check in CI — fast transpilers won't catch type errors
  • Keep build and typecheck as distinct scripts

❌ Don't

  • Assume "it built with esbuild" means "types are correct" — it doesn't
  • Ship without a type-check step somewhere in your pipeline
Gotcha: the modern fast toolchain (esbuild, swc, Bun, tsx) transpiles each file in isolation and does no type-checking at all — which means a genuine type error compiles and ships perfectly, and only blows up at runtime. Teams that replaced tsc with esbuild for speed and forgot to keep a tsc --noEmit gate effectively turned TypeScript back into JavaScript without realizing it. Speed for building, tsc for checking — you need both (topics 12, 20).
24Typed Express/Fastify — end-to-end types

Scenario: your route handler reads req.body.email and req.params.id, but they're typed any — so a typo compiles fine and crashes at runtime. Wiring TypeScript through your web layer (typed params, validated bodies, typed responses) closes that gap.

What

Making the HTTP layer type-safe: typed route params/query/body, validated inputs (topic 21), and typed responses — so the compiler checks your whole request/response flow.

Why

The web edge is where untyped data enters. Typing + validating it there means the rest of your app works with trusted, typed values — bugs caught at compile time.

How

Validate the body with a schema and infer its type; type params/query; in Fastify, attach JSON schemas so routes are validated and typed automatically.

Types + validation across the edge
  request ->[ validate body/params (zod) ]-> typed handler -> typed response
              ^ runtime check (topic 21)      ^ req.body: CreateUser (not any)
  Express: wrap with a validation middleware; infer types from schema
  Fastify: attach schema -> auto-validates AND infers the handler types
  result: a typo in req.body.emial is a COMPILE error, not a 500
🧠
Analogy 1 — a form with validated fields: instead of a blank box where anyone scribbles anything (untyped any body), you use a form with labeled, validated fields. The form rejects bad entries at submission (validation) and hands your code a clean, known structure (types). The edge does the vetting.
🛃
Analogy 2 — a receptionist who checks and tags visitors: the receptionist (edge layer) verifies each visitor's paperwork and clips on a badge with their verified details. Inside the building (your app), everyone trusts the badge. Without it, every room re-checks IDs or lets impostors through.
Validated body → typed handler
import { z } from "zod";
const Body = z.object({ email: z.string().email(), name: z.string() });

app.post("/users", (req, res) => {
  const parsed = Body.safeParse(req.body);        // validate at the edge
  if (!parsed.success) return res.status(400).json(parsed.error);
  const body = parsed.data;                        // typed: {email, name}
  res.status(201).json(createUser(body));          // typed response
});
// Fastify: pass { schema: { body: ... } } -> auto-validate + infer types
🧒
In plain wordsWeb requests bring in data from outside, and by default your framework treats it as "anything" (any) — so a typo like req.body.emial won't be caught. The fix: check and type the data right where it arrives — validate the body, type the URL params — so from then on your code works with clean, known values and the compiler catches mistakes. Frameworks like Fastify can do this automatically from a schema.

✅ Do

  • Validate and type request bodies/params/query at the route (topic 21)
  • Prefer Fastify's schema approach for automatic validation + inferred types
  • Type your responses too, so handlers can't return the wrong shape

❌ Don't

  • Read req.body as any and hope — that's where runtime 500s hide
  • Cast the body with as instead of validating it (topic 19)
Gotcha: Express types req.body, req.query, and req.params as any (or loose strings) by default, so your beautifully-typed app has an any-shaped hole right at its most dangerous entry point — and any is contagious (topic 18). The type annotations you write on handlers are unchecked hopes unless a validator actually runs. Fastify's schema-first approach ties validation and types together so they can't drift; with Express you must add the validation yourself and never trust the raw request (topics 21, 11).
25Testing — Vitest/Jest & mocking★ core idea

Scenario: you refactor a service and hope you didn't break anything. Types catch shape errors, but not logic — a wrong calculation, a missed edge case. Tests are how you verify behavior and refactor without fear.

What

Automated tests with a runner (Vitest or Jest): unit (one function, deps mocked), integration (real components together), plus mocking to isolate and control dependencies.

Why

Types verify shapes; tests verify behavior. Together they let you change code confidently — the test suite is your refactor safety net.

How

Arrange–act–assert per test. Mock slow/external deps (DB, network) for unit tests; use real ones for a few integration tests. Vitest is fast & TS-native.

The testing pyramid
            / E2E \            few, slow, real everything
          / integration \      some, real components together
        /--- unit tests ---\   many, fast, deps mocked
  types check SHAPES; tests check BEHAVIOR — you need both
  mock the slow/external (DB, HTTP); test YOUR logic in isolation
🧠
Analogy 1 — checking each part before assembling the machine: unit tests inspect each gear alone (mocked surroundings); integration tests check gears meshing; E2E runs the whole machine. You want lots of cheap part-checks and a few whole-machine runs — not only end-to-end tests that are slow and vague when they fail.
🎭
Analogy 2 — a stunt double: a mock is a stand-in for a real dependency that's slow or dangerous to use for real (the database, a payment API). The stunt double does exactly what you tell it, so you can rehearse the scene (test your logic) safely and repeatably, without calling the real thing.
Unit test with a mock (Vitest)
import { describe, it, expect, vi } from "vitest";
import { createUser } from "./users.js";

it("rejects a duplicate email", async () => {
  const db = { findByEmail: vi.fn().mockResolvedValue({ id: "1" }) };  // mock
  await expect(createUser({ email: "a@b.com" }, db))    // act
    .rejects.toThrow("email taken");                     // assert
  expect(db.findByEmail).toHaveBeenCalledWith("a@b.com");
});
// fast, deterministic, no real database — tests YOUR logic
🧒
In plain wordsTypeScript checks that your data has the right shape, but it can't tell if your logic is correct — like adding when you meant to subtract. Tests do that: little programs that run your code and check it gives the right answers. Write lots of small, fast tests for individual pieces (using mocks — fake stand-ins for slow things like databases), and a few bigger ones that check pieces working together. Then you can change code and instantly know if you broke something.

✅ Do

  • Write many fast unit tests and a few integration/E2E tests (the pyramid)
  • Mock slow/external dependencies to keep unit tests fast and deterministic
  • Test behavior and edge cases — types already cover shapes

❌ Don't

  • Rely on types alone — they don't catch logic bugs
  • Over-mock until tests only verify your mocks, not real behavior
Gotcha: the two failure modes are opposite. Over-mocking produces tests that pass while the real system is broken — you've tested that your mocks return what you told them to, not that the code works with the real database's actual behavior. Under-mocking makes unit tests slow and flaky (hitting real networks). The skill is mocking the boundaries (DB, HTTP) while testing real logic through them, and keeping a few integration tests that use the real thing to catch what mocks hide (topics 21, 29).
26Concurrency at scale — limits & backpressure

Scenario: you need to process 10,000 URLs. You write await Promise.all(urls.map(fetch)) — and fire 10,000 simultaneous requests, exhausting sockets, memory, and getting rate-limited into oblivion. Controlling how many run at once is essential at scale.

What

Bounding parallelism: run at most N async operations concurrently (a concurrency limit / pool) instead of all at once, and apply backpressure so work doesn't pile up.

Why

Promise.all over a huge array is unbounded concurrency — it overwhelms memory, file descriptors, and downstream services. Bounded concurrency is fast and stable.

How

Use a limiter (p-limit) or a worker-pool pattern to cap in-flight tasks. Stream/chunk large inputs rather than materializing everything at once (topic 6).

Unbounded vs bounded parallelism
  Promise.all(10k tasks): all at once -> OOM / socket death

  concurrency limit = 10:
    [##########] 10 in flight; as one finishes, the next starts
    steady throughput, bounded memory, no rate-limit meltdown  ok
🧠
Analogy 1 — a kitchen with a fixed number of burners: you have 10,000 dishes but only 10 burners (concurrency limit). You cook 10 at a time, sliding the next dish on as one finishes — steady and controlled. Trying to cook all 10,000 at once (Promise.all) means no burner, chaos, and burnt everything.
🅿️
Analogy 2 — a parking garage with N spots: cars (tasks) enter only when a spot frees up. The garage never overflows onto the street (memory), and traffic flows steadily. Promise.all is opening the gates and letting all 10,000 cars in at once — instant gridlock.
Bound concurrency with a limiter
import pLimit from "p-limit";
const limit = pLimit(10);                 // at most 10 in flight

const results = await Promise.all(
  urls.map((url) => limit(() => fetch(url)))   // queued behind the limit
);
// 10k urls, only 10 concurrent fetches — fast, bounded, kind to servers
// even better for huge/streaming inputs: process in chunks (topic 6)
🧒
In plain wordsIf you have 10,000 things to do and you start them all at the same time with Promise.all, you overwhelm your computer and the servers you're calling — like turning on 10,000 taps at once. Instead, do them N at a time (say 10): as one finishes, the next starts. You still get through all of them quickly, but without the flood. A small tool like p-limit does this for you.

✅ Do

  • Cap concurrency (p-limit / a pool) when fanning out many async tasks
  • Stream or chunk huge inputs instead of building one giant array (topic 6)
  • Tune the limit to your downstream's capacity and rate limits

❌ Don't

  • Promise.all over thousands of tasks — that's unbounded concurrency
  • Ignore rate limits — bound concurrency and add backoff
Gotcha: Promise.all has two hidden hazards at scale beyond memory — it starts every promise the instant you build the array (the map runs synchronously, so all 10k fetches fire before all even awaits), and it rejects on the first failure while the rest keep running invisibly in the background. For bounded, fault-tolerant fan-out you want a limiter plus Promise.allSettled (or per-task try/catch), not a naïve Promise.all over a giant array (topics 3, 8).
27Logging & production error handling

Scenario: production breaks and your only clue is console.log("here") scattered around — no request id, no structure, no way to search. Structured logging and a real error strategy turn "it's broken" into "here's exactly what happened to request abc123."

What

Structured logging (JSON logs via pino/winston) with levels and context, correlation ids per request, and a clear error taxonomy (operational vs programmer errors).

Why

console.log doesn't scale — you can't search, filter, or correlate it. Structured logs + a request id let you reconstruct any incident and alert on real signals.

How

Use a logger with levels; attach a request id (and trace id) to every log; log errors with stack + context; distinguish expected failures (400s) from bugs (500s).

Searchable, correlated logs
  console.log("error!")  -> unsearchable, no context, lost in noise

  structured (pino):
   {"level":"error","reqId":"abc123","userId":"u1","msg":"charge failed"}
        | filter by reqId -> the WHOLE story of one request
        | alert on level:error rate, not on log volume
  levels: trace < debug < info < warn < error < fatal
🧠
Analogy 1 — a labeled logbook vs sticky notes everywhere: console.log is sticky notes stuck randomly — impossible to search when it matters. Structured logs are a proper logbook with dated, categorized entries you can flip to instantly. When something goes wrong, you want the logbook, not a wall of notes.
🧾
Analogy 2 — a receipt with an order number: the request id is the order number printed on every related log line. When a customer complains about "order abc123," you pull every line tagged abc123 and see the whole journey — instead of guessing which of thousands of log lines belong to them.
Structured logs with a request id
import pino from "pino";
const log = pino();

app.use((req, _res, next) => {
  req.log = log.child({ reqId: crypto.randomUUID() });  // id per request
  next();
});
app.get("/pay", async (req, res) => {
  try { res.json(await charge(req)); }
  catch (err) {
    req.log.error({ err }, "charge failed");   // searchable, with context
    res.status(500).json({ error: "internal" });
  }
});
// JSON logs -> ship to a log platform; filter by reqId; alert on error rate
🧒
In plain wordsWhen something breaks in production, console.log("here") tells you almost nothing — you can't search it or tell which user it belongs to. Instead, use a real logger that writes tidy, labeled entries (in JSON) with a unique id for each request. Then, when a specific request fails, you search that one id and see its entire story. You also sort logs by importance (info, warning, error) so you can alert on real problems, not noise.

✅ Do

  • Use a structured logger (pino/winston) with levels and JSON output
  • Attach a request id (and trace id) to every log line for correlation
  • Distinguish operational errors (expected, handle) from bugs (fix)

❌ Don't

  • Ship console.log debugging — it's unsearchable and unstructured
  • Log secrets, tokens, or full PII — redact sensitive fields (topic 29)
Gotcha: logs are a double-edged tool — the same context that makes them useful (request bodies, headers, user data) is exactly what leaks secrets and PII into your log platform, where it's now a compliance problem and a breach waiting to happen. Redact tokens, passwords, and personal data at log time. And distinguish operational errors (a bad request, a down dependency — expected, handle gracefully) from programmer errors (a bug — let it crash and fix it); treating a real bug as "handle and continue" hides it forever (topic 8).
28Performance & profiling — event loop lag

Scenario: your API is mysteriously slow under load, but CPU looks fine. The real culprit is often event loop lag — something is blocking the single thread (topic 2), so every request queues behind it. Profiling, not guessing, finds it.

What

Measuring where time and memory go: event loop lag (delay before the loop can run your callbacks), CPU profiles (flame graphs), and heap snapshots for leaks.

Why

In Node, blocking the one JS thread is the top performance killer, and it's invisible to CPU% — you must measure loop lag and profile to find the blocking or the leak.

How

Monitor loop lag (perf_hooks); take CPU profiles (--prof, clinic, 0x) to find hot/blocking functions; heap snapshots to find memory leaks.

Event loop lag = the hidden killer
  healthy:  loop lag < 10ms  -> requests flow
  blocked:  one sync JSON.parse(20MB) or regex takes 400ms
            -> loop lag spikes -> ALL requests queue behind it
  CPU may look "fine" while p99 explodes -> measure LAG, not just CPU
  tools: perf_hooks.monitorEventLoopDelay(), clinic doctor, 0x flame graphs
🧠
Analogy 1 — a single cashier who stops to count inventory: the line (requests) is fine until the one cashier pauses to count a huge shipment (a blocking sync operation). Now everyone waits, even though the cashier is "busy working." Event loop lag measures how long customers wait for the cashier to be free.
🩺
Analogy 2 — a doctor ordering tests instead of guessing: profiling is running diagnostics rather than prescribing blindly. A flame graph is the X-ray showing exactly which function is the tumor eating your CPU. Guessing at performance ("maybe add a cache?") without profiling is prescribing without a diagnosis.
Watch the loop lag
import { monitorEventLoopDelay } from "node:perf_hooks";

const h = monitorEventLoopDelay({ resolution: 20 });
h.enable();
setInterval(() => {
  const p99ms = h.percentile(99) / 1e6;      // ns -> ms
  if (p99ms > 50) log.warn({ p99ms }, "event loop lag high");
  h.reset();
}, 5000);
// high lag = something is BLOCKING the thread -> profile with clinic/0x
🧒
In plain wordsNode has one main worker. If something makes that worker stop and grind on a big job (parsing a huge file, a slow bit of text-matching), every request piles up behind it — and confusingly, the CPU might look normal. The key measurement is "event loop lag": how long the worker is too busy to get to the next task. When it spikes, you don't guess — you run a profiler that shows you exactly which function is hogging the thread, and fix that.

✅ Do

  • Monitor event loop lag in production — it catches blocking early
  • Profile (clinic, 0x, --prof) to find the real hot/blocking function
  • Take heap snapshots to hunt memory leaks (growing heap over time)

❌ Don't

  • Optimize by guessing — measure first, then fix the proven bottleneck
  • Assume "CPU looks fine" means "no performance problem" — check loop lag
Gotcha: the most common Node performance bug is accidental synchronous blocking hiding in innocent-looking code — JSON.parse/stringify on a large payload, a fs.readFileSync, bcrypt's sync API, a crypto call, or a catastrophic-backtracking regex on user input (a ReDoS). None of these look like heavy compute, but each freezes the whole server for the duration, spiking loop lag and p99 while CPU graphs look calm. Measure loop lag, and be suspicious of anything ...Sync or any regex touching untrusted input (topics 2, 10, 29).
29Security — deps, input, secrets★ safety

Scenario: your app is one npm install of a compromised package, one unvalidated input, or one logged token away from a breach. Node's biggest attack surfaces are dependencies, untrusted input, and secrets — and each has a well-known defense.

What

The core Node/TS security practices: vet & audit dependencies, validate all input (topic 21), protect secrets, and use safe defaults (parameterized queries, helmet, least privilege).

Why

Most Node breaches come from these three: a malicious/vulnerable dependency, injection via unvalidated input, or a leaked secret. They're preventable with discipline.

How

npm audit + lockfile + minimal deps; validate/escape input; keep secrets in env/secret stores and out of logs; use parameterized queries and security middleware.

Three attack surfaces, three defenses
  DEPENDENCIES -> audit, pin (lockfile), minimize, review postinstall
     (npm install runs arbitrary code with YOUR privileges)
  INPUT        -> validate everything (zod), parameterize SQL, escape output
     (injection, ReDoS, prototype pollution)
  SECRETS      -> env/secret store, never in git, never in logs, rotate
     one leaked token = full compromise
🧠
Analogy 1 — locking doors, windows, and the safe: security isn't one lock. Dependencies are the front door (who are you letting in?), input validation is the window screens (what's crawling in?), secrets are the safe (are the keys hidden?). A mansion with a great front door but an open safe and no window screens still gets robbed.
📦
Analogy 2 — accepting packages from strangers: every npm install is accepting a box that runs code in your house. You wouldn't let an unvetted stranger's box execute freely — you'd check the sender (audit), limit what it can touch (least privilege), and be wary of "just install this" (supply-chain attacks).
Safe defaults
$ npm audit                      # known-vulnerable deps
$ npm ci                         # exact, pinned install (topic 5)

# in code:
#  - validate ALL input with zod at the boundary (topic 21)
#  - PARAMETERIZED queries, never string-concatenated SQL:
#      db.query("select * from users where id = $1", [id])   # safe
#      db.query(`... id = ${id}`)                            # INJECTION
#  - secrets from env/secret store, redact them in logs (topic 27)
#  - app.use(helmet())            # safe HTTP headers
🧒
In plain wordsThree big ways a Node app gets hacked, and how to shut each down: (1) Dependencies — every library you install runs code on your computer, so use trusted ones, keep them pinned, and scan them (npm audit). (2) Input — never trust data from users; check it and use safe database queries so attackers can't sneak in commands. (3) Secrets — keep passwords and keys out of your code and logs, in a safe place. Lock all three doors, not just one.

✅ Do

  • Audit and pin dependencies; keep the tree minimal; review what you add
  • Validate all input (topic 21) and use parameterized queries — never string-concat SQL
  • Keep secrets in env/secret stores, redact them in logs, and rotate them

❌ Don't

  • Trust user input or third-party responses without validation
  • Commit secrets or log tokens/PII — one leak can compromise everything
Gotcha: the scariest Node attack surface is the one you didn't write — the dependency supply chain. npm install executes install scripts and pulls a deep tree of transitive packages, any of which could be hijacked (it has happened to hugely popular packages). A typo-squatted or compromised dependency runs with your full privileges — reading env vars, exfiltrating secrets, mining crypto. Minimize dependencies, pin with a committed lockfile, audit regularly, and treat every npm install as running a stranger's code, because it is (topics 5, 21).
30Capstone — a typed REST API, end to end★ capstone

Scenario: assemble everything into one real service — a typed REST API that validates input, handles errors, logs with correlation ids, tests its logic, and ships safely. This is the whole playbook working together.

What

A production Node + TS service wiring together the event-loop-friendly runtime, ESM + strict TS, validated typed routes, structured logging, tests, and safe shipping — the concepts from all 29 topics.

Why

Any one piece is easy alone. Production is making them coexist: type-safe end to end, validated at the edge, observable, tested, and secure.

How

Strict TS + ESM → validate input (zod) at typed routes → keep the loop free (offload CPU) → structured logs + graceful shutdown → tests + tsc gate → audited deps.

The whole service, assembled
  request -> [ pino reqId (27) ] -> [ zod validate (21) ] -> typed handler
                                                              |
     strict TS (20) + ESM (4)   async/await, no blocking (2,3)
     CPU work -> worker (10)     |          |
                                 v          v
                          service logic   DB (parameterized, 29)
                                 |
     errors -> central handler (8) -> typed response
  ship: tsc --noEmit gate (23) + vitest (25) + npm audit (29) + SIGTERM (9)
🧠
Analogy 1 — a well-run restaurant: the runtime is the kitchen that never blocks (chefs delegate slow tasks), types are the recipes that must line up, validation is the receiving dock inspecting deliveries, logging is the order tickets, tests are the health inspection, and shipping is opening safely each night. One great dish is easy; running the whole restaurant reliably is the skill.
🏗️
Analogy 2 — a finished building vs a pile of materials: you learned the runtime (bricks), the type system (the parts that must fit), and the tooling (the tools) separately. The capstone is the standing, inspected, occupied building — every system wired and up to code. That integration is the engineering.
Everything, wired together
// strict TS + ESM; validate at the edge; log with reqId; handle errors
const CreateUser = z.object({ email: z.string().email(), name: z.string() });

app.use((req, _res, next) => { req.log = log.child({ reqId: uuid() }); next(); });

app.post("/users", async (req, res, next) => {
  const parsed = CreateUser.safeParse(req.body);        // validate (21)
  if (!parsed.success) return res.status(400).json(parsed.error);
  try {
    const user = await createUser(parsed.data);         // typed logic (13,16)
    res.status(201).json(toSafeUser(user));             // strip secrets (17,29)
  } catch (err) { next(err); }                          // central errors (8)
});
app.use((err, req, res, _next) => {                      // one error handler
  req.log.error({ err }); res.status(500).json({ error: "internal" });
});
process.on("SIGTERM", () => server.close(() => process.exit(0)));  // graceful (9)
// ship only if: tsc --noEmit passes (23) + tests pass (25) + npm audit clean (29)
🧒
In plain wordsThis is everything put together: a web service that checks incoming data at the door, keeps its types honest, never freezes on heavy jobs, records what happens with searchable logs, is covered by tests, and only ships after the type-checker and tests pass and its libraries are scanned. Each part was simple on its own — the real skill is making them all work together into something that stays correct, fast, and safe in the real world.

✅ Do

  • Start strict (TS strict, ESM) and validate at every boundary from day one
  • Keep the loop free, log with correlation ids, and gate shipping on tsc + tests
  • Add pieces as real needs appear — not every service needs workers or CQRS

❌ Don't

  • Bolt on validation, logging, and tests "later" — later is after the incident
  • Trust types at runtime, block the event loop, or ship without a type-check gate
Gotcha: the hardest part of a production Node + TS service isn't any single piece — it's remembering that types stop at the runtime boundary. Your app can be beautifully typed internally and still be one unvalidated request, one blocking sync call, or one leaked secret away from an incident, because the compiler's guarantees evaporate exactly where the real world enters. Validate at every edge, keep the one thread free, and gate every ship on tsc + tests + audit. That discipline — not clever types — is what "production-grade" means. 🚀
Test yourself · Round-based quiz

Answer 5 per round. Your progress and score are saved in this browser — come back anytime and continue where you left off.

Score: 0 / 0 answered · 0 total