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.
┌─────────── 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
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✅ 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
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.
[ 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 → ...
setTimeout(…, 0).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).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
setImmediatevssetTimeout(…,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)
.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).
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
await). Callbacks are instead handing the clerk your phone number to call you back.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.// 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;
}
}try/catch.✅ Do
- Prefer
async/awaitfor readability and normaltry/catch - Use
Promise.allfor independent work — don'tawaitin series needlessly - Use
Promise.allSettledwhen you need every result, success or fail
❌ Don't
- Forget to
await(or return) a promise — errors vanish, order breaks awaitinside a loop when the iterations are independent (slow & serial)
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.
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
import()) to talk to ESM. Mixing them without knowing who speaks what causes confusion."type" field is the label on the wall telling you which socket a room has.// 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")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.jsimport 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
requireandimportin the same file — pick one per file - Assume
__dirnameexists in ESM — useimport.meta.url
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.
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
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.{
"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" }
}package.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 ciin CI for deterministic installs - Put build/test-only tools in
devDependencies, runtime libs independencies - Understand
^vs~vs exact before pinning
❌ Don't
- Gitignore the lockfile — you throw away reproducibility
- Run
npm installin CI (it can drift the lock) — usenpm ci
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.
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
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✅ Do
- Stream large files/uploads/responses instead of buffering them whole
- Use
pipeline()— it wires backpressure and error handling correctly - Handle
errorevents — 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 (usepipeline)
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.
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)
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 → MaxListenersExceededWarningemit), 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
errorlistener — an unhandled one crashes the process - Remove listeners you no longer need (
off) to avoid leaks
❌ Don't
- Forget the
errorevent — it's special and throws if unhandled - Add listeners in a loop without removing — you'll leak memory & hit the max warning
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."
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)
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.// 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(); });✅ Do
- Catch async errors with
try/awaitor.catch; funnel to one handler - Attach
errorlisteners 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
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.
.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
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); });✅ 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
SIGTERMfor graceful shutdown (finish in-flight requests)
❌ Don't
- Hardcode URLs, ports, or secrets — you'll rebuild per environment and leak keys
- Read
process.envscattered everywhere untyped — centralize & validate once
process.env values are always strings — process.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.
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
// 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✅ 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
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.
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
next()) or stop them (respond early). Middleware is that series of checkpoints.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}`));✅ 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
httpfor anything non-trivial - Forget the error-handling middleware — unhandled route errors crash you (topic 8)
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).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.
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)
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✅ 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)
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.
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
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.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
}✅ 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
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.
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
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.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.// 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 elseinterface 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
interfacefor object shapes and extendable public API contracts - Use
typefor 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; usetype
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.
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
}
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;
}
}
}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+ aneverdefault 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
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.
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
any is a box that forgets its label, so you never know what you'll pull out. Generics keep the label.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.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<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 …) soThas the members you need - Let TS infer type args from usage — you rarely pass them explicitly
❌ Don't
- Reach for
anywhen a generic keeps the type safely (topic 18) - Over-genericize simple code — generics that add no reuse just add noise
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.
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 ✓
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.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
}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
Omitto 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
SafeUsertype to actually remove the password at runtime — strip it in code too
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.
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)
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.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.// 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); }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
unknownfor untyped/external data and narrow before using it - Use
neverfor exhaustiveness checks and always-throw functions - Enable
noImplicitAnyso accidentalanyis flagged (topic 20)
❌ Don't
- Reach for
anyto silence an error — you disable checking for everything downstream - Let
anyspread through your codebase from one untyped library
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.
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
// 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✅ Do
- Write type guards (
x is T) to safely narrow real/external data - Reserve
asfor 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 Ton untrusted input — you're disabling the safety net - Chain
as unknown as Tto force a cast — that's a code smell hiding a bug
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": 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
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.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.{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noUncheckedIndexedAccess": true,
"outDir": "dist",
"sourceMap": true,
"esModuleInterop": true
},
"include": ["src"]
}tsconfig.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": truefrom day one of a project - Add
noUncheckedIndexedAccessto catch unsafe array/object indexing - Match
target/moduleto 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
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.
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
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);
});✅ 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)
(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).
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
.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..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).// 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.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/pkgwhen a JS library doesn't bundle its own types - Write a small
.d.tsto 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
anyfor a whole untyped library — write a minimal.d.tsinstead - Put runtime code in a
.d.ts— they're types only, erased entirely
@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).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.
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
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.$ 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
.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 --noEmitfor checking - Run the type-check in CI — fast transpilers won't catch type errors
- Keep
buildandtypecheckas 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
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.
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
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.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 typesany) — 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.bodyasanyand hope — that's where runtime 500s hide - Cast the body with
asinstead of validating it (topic 19)
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.
/ 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
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✅ 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
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).
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
Promise.all is opening the gates and letting all 10,000 cars in at once — instant gridlock.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)
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.allover thousands of tasks — that's unbounded concurrency- Ignore rate limits — bound concurrency and add backoff
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).
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
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.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 rateconsole.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.logdebugging — it's unsearchable and unstructured - Log secrets, tokens, or full PII — redact sensitive fields (topic 29)
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.
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
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✅ 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
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.
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
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).$ 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 headersnpm 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
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.
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)
// 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)✅ 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
tsc + tests + audit. That discipline — not clever types — is what "production-grade" means. 🚀Answer 5 per round. Your progress and score are saved in this browser — come back anytime and continue where you left off.