Skip to content
QSWEQB

Node.js and TypeScript fundamentals

Where the event loop actually spends its time, what blocks it and what does not, how the two module systems collide, what TypeScript's type system does and does not check at runtime, and the packaging details that only break in production. Fifty-seven items, twelve worked through with code or a diagram.

57 questions

Go deeper on Node.js & TypeScript

The event loop and runtime model

Show me the phases of the event loop and where things run.

Node's loop is not one queue. It cycles through phases, and knowing which phase a callback belongs to explains most ordering surprises.

flowchart TD
    T[timers<br/>setTimeout and setInterval callbacks] --> P[pending callbacks<br/>some deferred system errors]
    P --> Po[poll<br/>waits for I/O, runs I/O callbacks]
    Po --> C[check<br/>setImmediate callbacks]
    C --> Cl[close<br/>socket and handle close events]
    Cl --> T

Between every phase transition — and after each individual callback — the microtask queues drain completely: process.nextTick first, then promise callbacks. That is why an await resumes before any timer, no matter how short the timer.

The poll phase is where a mostly-idle server spends its life, blocked in the kernel waiting for a socket to become readable. This is the crucial point about Node's model: waiting for I/O costs nothing, because the thread is parked in the operating system rather than spinning.

setImmediate runs in the check phase, so it fires after the current poll phase completes; setTimeout(fn, 0) waits for the next timers phase. Despite the names, setImmediate is generally the sooner of the two, which is exactly backwards from what the names suggest.

process.nextTick is not a phase at all. It runs before promise callbacks and before the loop advances, so recursive nextTick calls starve the loop completely — no I/O, no timers, nothing. It exists for library internals that must run before anything else, and application code almost always wants a promise.

What actually blocks the event loop?

CPU work and synchronous system calls, because there is one thread running your JavaScript. A tight loop over a large array, JSON.parse on a twenty-megabyte payload, crypto.pbkdf2Sync, fs.readFileSync, a catastrophically backtracking regular expression — each stops every other request in the process, including the health check, which is why the orchestrator then kills a server that was merely busy. Waiting on a database, by contrast, blocks nothing at all.

Show me the exact ordering of the scheduling primitives.

This is the question that distinguishes people who have debugged an ordering bug from people who have read that Node is asynchronous.

setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
Promise.resolve().then(() => console.log('promise'));
process.nextTick(() => console.log('nextTick'));
console.log('sync');

// sync, nextTick, promise, timeout, immediate

Synchronous code first, always — nothing asynchronous interleaves with a running stack.

Then the microtask queues, in their fixed order: the nextTick queue drains entirely, then the promise queue. That ordering is why a nextTick scheduled inside a promise callback still runs before the next promise callback.

Then the loop advances. Here the output is mildly unstable on the first tick — the timeout and immediate order can swap depending on how long the process took to start, because the timer may or may not have expired by the time the timers phase is reached. Inside an I/O callback it is deterministic: setImmediate always wins, because check follows poll in the same iteration while timers require a full lap.

The practical rule this leaves you with: use setImmediate to yield to I/O between chunks of work, and avoid process.nextTick in application code entirely, because starving the loop is a failure mode with no error message.

What is `AsyncLocalStorage` for?

Carrying context — a request id, a tenant, a user — through an asynchronous call chain without threading it through every function signature. In a synchronous language a thread-local would do it; in Node the call stack is discarded at every await, so there is no ambient place to put it. AsyncLocalStorage hooks the async resource lifecycle to keep a store associated with the logical operation across those boundaries. It is what lets a logger attach the trace id automatically, and the cost is a measurable per-operation overhead plus the risk of leaking context into work that outlives the request.

How does Node do anything in parallel if it is single-threaded?

Only your JavaScript is single-threaded. File system operations, DNS lookups and some crypto run on a thread pool — four threads by default, which is a real and frequently-hit limit — and network I/O is handled by the operating system's event notification rather than by threads at all. So a service doing thousands of concurrent HTTP calls scales fine, while four concurrent pbkdf2 calls saturate the pool and a fifth waits.

When should you reach for a worker thread?

When the work is CPU-bound and must happen in-process: image manipulation, large parsing or serialisation jobs, compression, or a synchronous cryptographic operation on the request path. A worker has its own event loop and its own heap, so it cannot block the main one, and data crosses the boundary by copying unless you use a shared buffer. For work that does not need to be in-process, a separate queue and worker service is usually simpler to operate and to scale.

Why is clustering not the same as multithreading?

Because a cluster runs several separate processes, each with its own heap and event loop, sharing a listening socket. That gives you the cores without any shared-memory concurrency problems — and means nothing is shared, so in-memory caches, counters and sessions diverge per worker immediately. It is the same statelessness requirement as horizontal scaling, arriving on a single machine, and it catches people who assumed one process's memory was the whole application.

Streams, buffers and I/O

Show me why streaming matters and what buffering costs.

The two versions read identically and behave completely differently under load.

// Buffered: the whole file enters memory before anything is sent
const data = await fs.promises.readFile('export.csv');
res.end(data);

// Streamed: constant memory, first byte leaves almost immediately
fs.createReadStream('export.csv').pipe(res);

For a 500MB file the first version allocates 500MB per concurrent request. Ten simultaneous downloads is five gigabytes, so the process dies — and it dies from a usage pattern that worked perfectly in testing with a small fixture.

The second holds one chunk at a time, typically 64KB, regardless of file size or request count. Time to first byte also collapses, because the client starts receiving while the disk is still being read.

pipe does one more thing that matters: it wires up backpressure. When the socket cannot accept more data — a slow client on a phone — the write returns false, the readable side pauses, and resumption happens automatically. Writing manually in a loop without checking the return value buffers the entire unsent remainder in memory, which reproduces the original problem through a different route.

The one thing pipe does not do well is error propagation: an error on either side leaves the other undestroyed, leaking file descriptors. pipeline is the correct call, because it destroys every stream in the chain on failure.

Show me a stream pipeline and where backpressure applies.

Four stages, one chunk in flight at each, and the throughput set by the slowest.

flowchart LR
    R[Readable<br/>file on disk] --> G[Transform<br/>gunzip]
    G --> P[Transform<br/>parse and map]
    P --> W[Writable<br/>HTTP response]
    W -.backpressure travels upstream.-> R

The dotted line is what makes this work rather than merely compose. When the response socket stops accepting data, the writable side signals it, each transform pauses in turn, and the file read stops — so memory stays at roughly one buffered chunk per stage regardless of the file's size.

await pipeline(
  fs.createReadStream('export.csv.gz'),
  zlib.createGunzip(),
  new Transform({ objectMode: true, transform: mapRow }),
  res,
);

pipeline rather than chained pipe calls, for one specific reason: on any error it destroys every stream in the chain. Chained pipe leaves the others open, so a failure mid-transfer leaks a file descriptor and a gunzip context each time, and the process runs out of descriptors after a few thousand failures rather than crashing at the point of the bug.

The objectMode flag is the other detail worth knowing. Binary streams carry buffers and measure the high-water mark in bytes; object-mode streams carry arbitrary values and count items instead. Mixing them without saying so produces a stream that either buffers far more than intended or stringifies your objects.

What is a Buffer, and why does it exist?

A fixed-length view over raw bytes outside the JavaScript heap, because JavaScript strings are UTF-16 sequences and are the wrong model for binary data. Anything read from a socket, a file, or a crypto function is bytes, and treating bytes as a string silently corrupts them unless the encoding happens to be compatible. The related bug is decoding a multi-byte character split across two chunk boundaries, which is why a StringDecoder or an explicit encoding on the stream exists.

What does async iteration over a stream give you?

for await (const chunk of readable) reads a stream with ordinary loop control flow, so try/catch works, break destroys the stream properly, and backpressure is implicit — the loop body's duration is what paces the producer. That is substantially easier to reason about than event handlers, where errors arrive on a separate channel and pausing is manual. The trade is throughput: per-chunk overhead is higher than a piped stream, so a hot path moving large volumes still wants pipeline, while application logic processing records one at a time is clearer this way.

What are the four stream types for?

Readable produces data, Writable consumes it, Duplex does both independently — a socket — and Transform is a Duplex whose output is a function of its input, which is what compression, encryption and parsing are. Understanding that a Transform is a stream is what lets you compose a pipeline: read a file, decompress, parse, transform, write, each stage holding one chunk. The alternative, materialising each intermediate result, is where the memory goes.

Why is backpressure the whole point of streams?

Because without it, a fast producer and a slow consumer means the difference accumulates in memory until the process dies — and it dies quietly, with no error until the allocation fails. Backpressure propagates the consumer's rate back to the producer, so throughput settles at the slowest stage instead of buffering the gap. Any code that reads a source in a loop and writes without checking whether the destination accepted the write has discarded the mechanism.

Modules and packaging

Show me how CommonJS and ES modules differ where it bites.

The distinction is not syntax. It is when the module graph is resolved, and everything else follows.

// CommonJS: require is a function call, executed at runtime
const lib = require(`./plugins/${name}`);   // dynamic, resolved when it runs
module.exports = { handler };
// __dirname, __filename, require.cache all exist

// ESM: import is a static declaration, hoisted and resolved before execution
import { handler } from './lib.js';         // must be a literal path
export { handler };
// no __dirname; use import.meta.url. Top-level await is allowed.

Because ESM imports are static, the graph is known before any code runs — which is what makes tree shaking and cyclic-dependency detection possible, and what makes a computed path illegal. Dynamic import() covers that case and returns a promise, so it is asynchronous where require was not.

Three practical consequences. ESM cannot require and CommonJS cannot statically import, so a CommonJS file consuming an ESM-only package needs a dynamic import(), which makes the consuming function async — a change that propagates up the call stack and is the actual cost of the migration.

File extensions are mandatory in ESM specifiers. import './lib' fails where require('./lib') succeeded, because Node no longer guesses.

And the mode is decided per file by "type" in the nearest package.json, or by the .mjs and .cjs extensions. Mixing them within one package without setting exports correctly produces the dual package hazard: both copies load, and two instances of a class fail instanceof against each other.

What does the `exports` field in package.json do?

It declares the package's public entry points and, critically, closes off everything else — without it, consumers can import any internal file path and you have no private modules. It also allows conditional resolution, so an ESM consumer and a CommonJS consumer receive different builds from one package. Getting it wrong is a common cause of "cannot find module" against a package that is clearly installed, because the requested subpath is not exported.

What is a peer dependency for?

Declaring that the consumer must provide a package rather than the library bundling its own copy, used when two copies would actually break something. A component library with React as a regular dependency can end up with a second React in the tree, and hooks then fail with an error about invalid hook calls because the two copies hold separate internal state. The same reasoning applies to any plugin and its host framework. The cost is that version compatibility becomes the consumer's problem, which package managers now surface as an install warning rather than resolving silently.

Why do lockfiles matter more than version ranges?

Because a caret range means the resolved tree depends on when you installed, so CI, your machine and production can each build something different from the same commit. A lockfile pins every transitive version and its integrity hash, making installs reproducible and making a dependency change visible in a diff. It also matters for security: the hash is what makes a swapped-out published artefact fail rather than silently install.

What is the difference between dependencies and devDependencies in a deployed service?

dependencies are needed at runtime and must be installed in production; devDependencies are build and test tooling and should not be. Misplacing one produces either a production image carrying a test framework and its transitive tree — more bytes and more vulnerabilities to triage — or a service that crashes on first request because something it imports was pruned. For a bundled application the distinction blurs, which is why the honest check is what the built artefact actually requires.

Is bundling a server-side application worth it?

Sometimes, and for different reasons than on the client. It removes node_modules from the deployed artefact, which shrinks a container image substantially and cuts start-up time because there are far fewer files to resolve and read — a cost paid per cold start in serverless, where it matters most. The complications are real: native modules cannot be bundled and must be externalised, a dynamic require of a computed path breaks, and stack traces need source maps to stay readable. For a long-running container the gain is modest; for a function it can be the difference between a 600ms and a 200ms cold start.

Why is `npm ci` preferred over `npm install` in a pipeline?

Because ci installs exactly what the lockfile specifies and fails if the lockfile and manifest disagree, whereas install may resolve new versions and rewrite the lockfile. In a build that means install can silently produce a different dependency tree than the one reviewed, and the drift appears in the artefact rather than in a diff. It is also faster, because it skips resolution entirely and deletes node_modules first rather than reconciling it.

Errors and process lifecycle

Show me the async error-handling mistakes that lose errors silently.

Each of these compiles, runs, and discards a failure.

// 1. Callback error ignored — the classic, and the reason for promises
fs.readFile(path, (err, data) => {
  process(data);           // err never checked; data is undefined
});

// 2. Async function not awaited — rejection becomes unhandled
app.post('/x', (req, res) => {
  save(req.body);          // returns a promise nobody holds
  res.status(202).end();   // reports success regardless
});

// 3. try/catch around a non-awaited call — catches nothing
try {
  doAsyncThing();          // no await, so the rejection escapes the block
} catch (e) { log(e); }

// 4. Throwing inside a callback the framework never sees
setTimeout(() => { throw new Error('boom'); }, 0);
// no surrounding stack to catch it — this reaches uncaughtException

The common thread is that a promise's rejection is only handled by whoever holds the promise. Case two is the most damaging in practice: the endpoint returns 202, the write failed, and there is no error rate to alert on because from the framework's perspective nothing went wrong.

Case three is worth being able to explain precisely. try/catch catches synchronous throws in its block, and an un-awaited async call returns before throwing anything, so the block has already exited by the time the rejection occurs. Adding await makes the same code correct.

Case four explains why throw inside a bare timer callback is unrecoverable: there is no caller. This is what uncaughtException catches, and at that point the process state is unknown.

The systemic fix is to make it impossible: an async-aware wrapper on every route that forwards rejections to the error middleware, and a lint rule against floating promises.

What should you do on `uncaughtException`?

Log with as much context as you can, then exit and let the supervisor restart the process. An uncaught exception means a stack unwound to the top with no handler, so invariants may be half-applied and connections half-written — continuing to serve from that state risks corrupting data in ways much harder to diagnose than a restart. Treating the handler as a way to keep running is the mistake; treating it as a chance to record why you are dying is correct.

What is the difference between `unhandledRejection` and `uncaughtException`?

uncaughtException fires when a synchronous throw reaches the top of the stack with no handler. unhandledRejection fires when a promise rejects and no handler was attached by the time the microtask queue drains, so it is the asynchronous equivalent — and it became fatal by default in Node 15 precisely because treating it as a warning let broken code run for years. Both should log richly and exit rather than attempt to continue, because in both cases something unwound past its error handling and the process state is no longer known to be consistent.

What is the difference between an operational error and a programmer error?

An operational error is an expected runtime failure — a timeout, a rejected payment, a file that does not exist — and should be handled: retried, reported to the caller, or degraded around. A programmer error is a bug, such as reading a property of undefined, and handling it locally only hides it. The distinction decides the response: operational errors belong in your control flow, programmer errors belong in a crash and a stack trace.

Why should custom errors extend `Error`?

Because anything else loses the stack trace, and a failure without a stack is substantially harder to locate — you know what went wrong and not where. Extending Error also makes instanceof checks work, which is what lets a caller distinguish a NotFoundError from a ValidationError without matching on message strings that someone will eventually reword. Adding a stable code property is the further step that keeps the distinction usable across a process boundary, where instanceof cannot survive serialisation. The detail worth knowing is that subclassing Error requires calling super(message) and, in older transpilation targets, restoring the prototype explicitly, or instanceof quietly stops working against your own class.

What is `AbortSignal` used for in Node?

Cancelling work that has become irrelevant — a request whose client disconnected, a fetch superseded by a newer one, an operation past its deadline. It is the same primitive as in the browser, and modern Node APIs accept a signal directly, including fetch, timers and stream operations. It matters more on the server than people expect, because without it a disconnected client's work continues to hold a connection and run a database query to produce a response nobody will read, and under load that is precisely the wasted capacity that turns a slowdown into an outage.

How should a Node service shut down cleanly?

On SIGTERM, stop reporting ready so the load balancer drains you, wait long enough for it to notice, then stop accepting new connections and let in-flight requests finish before closing the database pool and exiting. The Node-specific trap is that server.close() waits for existing connections to end, and keep-alive connections do not end on their own — so without setting a timeout and destroying idle sockets, the process hangs until the orchestrator kills it.

The TypeScript type system

What is TypeScript actually doing?

Checking your code against declared types at compile time and then erasing every one of them. The output is JavaScript with no type information whatsoever, which means types cannot check anything at runtime — no validation of an incoming request body, no guarantee about a JSON response. That single fact explains the most common category of TypeScript bug: a value typed User that is not a user, because it came from outside the program and nobody checked.

Show me why `any` and `unknown` are not interchangeable.

Both accept anything. Only one of them makes you prove something before use.

function withAny(x: any) {
  x.foo.bar;          // compiles. Crashes at runtime.
  x();                // compiles. Crashes.
  const n: number = x;  // compiles. n may be a string.
}

function withUnknown(x: unknown) {
  x.foo;              // Error: 'x' is of type 'unknown'
  if (typeof x === 'object' && x !== null && 'foo' in x) {
    x.foo;            // narrowed, allowed
  }
}

any disables checking in both directions: nothing about the value is verified, and it assigns to anything else, so a single any propagates outward and silently removes checking from code that looked safe. That is why it is described as infectious.

unknown is the type-safe top type. It accepts any value and permits nothing until you narrow it, so it pushes the check to the boundary where the value arrives — which is where it belonged.

The place this matters most is catch. A caught value is unknown under modern settings, because JavaScript permits throwing anything, so err.message is not guaranteed to exist. Writing catch (e: any) to make the error go away reintroduces the crash it was preventing.

The setting behind all of this is strict. Without strictNullChecks in particular, null and undefined inhabit every type, so the compiler cannot catch the single most common runtime error in JavaScript.

When should you annotate a type rather than let it be inferred?

Annotate at boundaries and infer everywhere else. Function parameters and exported return types deserve annotations, because they are the contract and an inferred return type changes silently when the body does — turning an internal edit into a breaking change for consumers. Local variables almost never need one, since the inferred type is more precise than what you would write and stays correct as the initialiser changes. The useful exception is annotating to force a wider type deliberately, such as declaring an empty array's element type, which inference has no way to guess.

What is the difference between an interface and a type alias?

Interfaces describe object shapes and can be reopened — declaring the same interface twice merges the members, which is how ambient library types get extended. Type aliases name any type at all, including unions, tuples, primitives and mapped types, and cannot be reopened. So use an interface for a public object contract that consumers may need to augment, and a type alias for everything else, particularly unions, which interfaces cannot express.

Show me a discriminated union and what it buys.

The single most useful pattern in the language, because it makes the compiler enforce that every case is handled.

type Result =
  | { status: 'success'; data: User }
  | { status: 'error'; code: number; message: string }
  | { status: 'loading' };

function render(r: Result): string {
  switch (r.status) {
    case 'success': return r.data.name;      // data exists here, only here
    case 'error':   return r.message;        // code and message exist here
    case 'loading': return 'Loading...';
    default:
      const exhaustive: never = r;           // fails to compile if a case is added
      return exhaustive;
  }
}

The literal status field is the discriminant. Because the three members have distinct literal values, a check on it narrows the whole object, so r.data is accessible in exactly the branch where it exists and nowhere else.

Compare the alternative — one interface with everything optional — where data, code and message are all possibly undefined everywhere. Every access needs a guard, the illegal combination of status: 'success' with an error code is representable, and the compiler cannot help you.

The never assignment in the default branch is the part worth learning. Once every case is handled, r is narrowed to never, so the assignment is legal. Add a fourth variant to the union and that line stops compiling — so the type system tells you every place that needs updating, instead of you finding them at runtime.

This is what "make illegal states unrepresentable" means concretely, and it is a better answer to most modelling questions than any amount of validation.

What is the difference between an optional property and one that can be undefined?

foo?: string means the key may be absent; foo: string | undefined means the key must be present and may hold undefined. They differ for object literals, where the second must be written explicitly, and they differ for in checks and Object.keys. It matters most for update payloads, where absent means "leave unchanged" and present-but-undefined means "clear this field" — collapsing the two makes that intent inexpressible. exactOptionalPropertyTypes is the compiler flag that stops them being conflated silently.

What does `strict` actually turn on that matters?

strictNullChecks, which removes null and undefined from every type unless they are declared, and is by a wide margin the largest correctness win — without it the compiler cannot catch the most common runtime error in JavaScript. noImplicitAny, which stops an untyped parameter silently becoming any and taking its callers' checking with it. strictFunctionTypes, which checks parameter variance properly rather than bivariantly. And useUnknownInCatchVariables, which types a caught value honestly, since JavaScript permits throwing anything at all. Enabling strict on an existing codebase typically produces hundreds of errors, and the large majority of them are real bugs about values that can be absent.

Generics and type-level tools

What problem do generics actually solve?

Preserving the relationship between input and output types instead of discarding it. A function typed (x: any[]) => any tells the caller nothing, so every use site needs a cast; <T>(x: T[]) => T says the result is whatever the array contained, and the compiler tracks it. Generics are about relationships, which is why a type parameter used only once is usually a mistake — it should have been unknown or a concrete type.

Show me what a constraint on a type parameter buys.

Without one, a generic can accept anything and therefore can do almost nothing with it.

// Unconstrained: T could be a number, so .length is not guaranteed
function longest<T>(a: T, b: T): T {
  return a.length > b.length ? a : b;   // Error: no 'length' on type 'T'
}

// Constrained: T must have a length, and the specific type survives
function longest<T extends { length: number }>(a: T, b: T): T {
  return a.length > b.length ? a : b;
}

longest('ab', 'abc');           // string
longest([1], [1, 2]);           // number[]
longest(1, 2);                  // Error, correctly rejected

The constraint is what makes the body legal, and the type parameter is what makes the return type useful. Typing the parameters as { length: number } directly would also compile, and would return { length: number } — so the caller loses the fact that they passed a string.

keyof composes with this to produce the property accessor everyone eventually writes:

function pick<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}
const name = pick({ id: 1, name: 'a' }, 'name');   // string, not any
pick({ id: 1 }, 'nope');                            // Error at compile time

K extends keyof T restricts the key to one that exists, and T[K] is an indexed access type returning that property's specific type. Two type parameters and the call site is fully checked — the alternative returns any and moves the failure to runtime.

What is a mapped type for?

Transforming every property of a type by a rule, which is how Partial, Readonly and Record are implemented — { [K in keyof T]: ... } iterates the keys and produces a new type per property. The reason to write your own is to keep a derived type tied to its source: a validation-error shape mapped from a form type gains and loses fields automatically as the form changes. Key remapping with as extends it to renaming, which is how a type of getters is generated from a type of fields, and that is roughly where readability starts to suffer.

What are the utility types worth knowing?

Partial and Required toggle optionality, useful for update payloads. Pick and Omit derive a subset, which keeps a DTO tied to its source type so a rename propagates. Record describes a keyed map. Readonly prevents mutation at the type level. ReturnType and Parameters extract from a function type, which is how you avoid restating a signature. Together they let types be derived rather than duplicated, and duplication is where types drift from reality.

What is a conditional type for?

Expressing a type that depends on another type, which is what lets a generic function have a return type that varies by input rather than a union the caller must narrow. T extends string ? A : B is the form, and infer inside one extracts a component — how ReturnType is implemented. They are powerful and they are also where TypeScript becomes hard to read, so the practical guidance is to use them in library boundaries and prefer overloads or plain unions in application code.

Why can a list of subtypes not always be used where a list of supertypes is expected?

Because mutability breaks the substitution. A Dog[] looks like an Animal[], and if it were freely accepted, the receiver could legitimately push a Cat into it and corrupt the original array's element type. Sound type systems therefore reject this for mutable containers and permit it for read-only ones, which is why readonly Dog[] is assignable to readonly Animal[]. TypeScript is deliberately unsound here for arrays as a usability compromise, so the compiler will let you write that bug — and readonly is what buys the guarantee back.

Why is `as` a warning sign?

Because it asserts rather than checks: x as User tells the compiler to stop disagreeing with you and produces no runtime verification at all. If the value is not a user, the error surfaces later and somewhere else, with a stack trace pointing at the property access rather than the lie. The legitimate uses are narrow — narrowing a value the compiler cannot follow, or a const assertion — and everywhere else a type guard or a schema check is the honest tool.

Narrowing and runtime boundaries

Show me why every external input needs runtime validation.

This is the most consequential TypeScript misunderstanding, and it produces production bugs rather than compile errors.

interface User { id: number; email: string }

const res = await fetch('/api/user');
const user: User = await res.json();     // json() returns any. Nothing checked.

user.email.toLowerCase();
// TypeError: Cannot read properties of undefined
// The API returned { id: 1, emailAddress: "..." } and the type was a wish.

The annotation asserts a shape rather than confirming one. Types are erased, so there is no code in the output that could have checked anything — the compiler believed you and left.

The fix is a schema that exists at runtime and derives the static type from itself, so the two cannot drift:

import { z } from 'zod';

const UserSchema = z.object({ id: z.number(), email: z.string().email() });
type User = z.infer<typeof UserSchema>;          // derived, not declared

const user = UserSchema.parse(await res.json()); // throws here, with the path

The failure now happens at the boundary, naming the field that was wrong, instead of three layers deeper as an undefined property access.

Every boundary needs this: HTTP responses, request bodies, query parameters, environment variables, message payloads, files on disk, and anything from JSON.parse. Inside those boundaries you can trust the types completely, which is the actual payoff — validate once at the edge rather than defensively everywhere.

Validating environment variables at start-up is the highest-value instance of this, because a missing variable then fails immediately and loudly rather than producing undefined in a connection string an hour later.

Show me how to type configuration so a missing variable fails at start-up.

Environment variables are the boundary people forget, and the failure is maximally delayed: undefined becomes the string "undefined" in a connection URL and surfaces as an authentication error an hour later.

const EnvSchema = z.object({
  DATABASE_URL: z.string().url(),
  PORT: z.coerce.number().int().positive().default(3000),
  LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
  // NOT z.coerce.boolean(): that is Boolean(input), so "false" parses as true.
  FEATURE_X: z.enum(['true', 'false']).default('false')
              .transform((v) => v === 'true'),
});

export const env = EnvSchema.parse(process.env);   // throws at import time
export type Env = z.infer<typeof EnvSchema>;

Three things happen here that an annotation alone cannot do. The process fails at start-up with a message naming the missing or malformed variable, so a misconfigured deploy never becomes ready and never receives traffic — the readiness probe finishes the job.

The values are coerced and typed, so env.PORT is a number rather than the string that every environment variable actually is, and env.FEATURE_X is a boolean rather than the string "false" — which is truthy, and is one of the most reliably recurring configuration bugs there is.

Note what the boolean is not doing. z.coerce.boolean() is Boolean(input), so it maps the string "false" to true and reproduces exactly the bug it looks like it prevents. Coercion is only safe where the target type has a sensible parse from a string, which it does for a number and does not for a boolean, so the flag is parsed from an explicit pair of accepted spellings instead.

And process.env is touched in exactly one place, so the set of variables the service needs is a single readable list rather than something discovered by grepping. Everything else imports env and receives a fully typed object.

The caveat is import order: because this throws during module evaluation, it must be imported before anything that reads configuration, and the error must be allowed to propagate rather than caught by a wrapper that logs and continues.

What is a type predicate, and when do you need one?

A function whose return type is x is Foo, which tells the compiler that a truthy result means the argument has that type. You need it when the check is too complex for the compiler to follow — validating a shape field by field, for instance — and you want the narrowing to survive the function call. The danger is that the compiler trusts the annotation without verifying the body, so a wrong predicate is a silent as in disguise.

How does control-flow narrowing work?

The compiler tracks what must be true along each path: after if (typeof x === 'string'), x is a string inside the block and excluded from that possibility after it. in, instanceof, truthiness checks, equality against literals and discriminant fields all narrow. Narrowing is lost across a function boundary and after an await if the value could have been reassigned, which is why a check followed by an async call sometimes needs repeating.

Why is `Array.prototype.includes` sometimes rejected on a narrowed type?

Because a readonly ['a', 'b'] tuple's includes expects one of its own members, so passing an arbitrary string is a type error even though the runtime question is perfectly sensible. It is the compiler being stricter than the situation calls for, and the reasonable fixes are typing the array as readonly string[] for the check, or writing a small type predicate that takes a string and narrows to the union — which also documents the intent.

What does `satisfies` do that a type annotation does not?

It checks a value against a type without widening the value to it, so you get validation and precise inference at once. Annotating an object as Record<string, string> verifies the shape and then forgets which keys exist; writing satisfies Record<string, string> verifies the same thing and leaves the literal's exact keys and value types intact, so keyof typeof config remains useful afterwards. It matters for configuration objects, route tables and lookup maps — anywhere you want a constraint enforced while still deriving types from the actual contents.

What is the difference between structural and nominal typing?

TypeScript is structural: two types are compatible if their shapes are compatible, regardless of name or declaration site. That is convenient and it means a UserId aliased to string accepts any string, so nothing stops you passing an OrderId where a UserId was wanted. The workaround is branding — intersecting with a unique marker property — which recovers nominal behaviour for identifiers where mixing them up would be a real bug.

Tooling and interview traps

What is the difference between `tsc` and a bundler's TypeScript support?

tsc type-checks and emits. Most bundlers and modern runtimes only strip the types, transpiling each file in isolation for speed and performing no checking at all — which is why a project can build cleanly and be full of type errors. The correct arrangement is transpiling for speed in development and running tsc --noEmit as a separate pipeline step, because otherwise the type system is decoration.

What does the `declare` keyword do?

Declares that something exists without emitting any code for it, used to describe an untyped JavaScript library, a global injected by a bundler, or an augmentation of an existing module. It is a promise to the compiler with nothing verifying it, so a wrong declaration is as unsound as a cast and survives longer, because it looks like a definition rather than an assertion. That is why generated or maintained type definitions are preferable to hand-written ambient declarations, and why any ambient declaration deserves a comment saying where the real thing comes from.

Why does `enum` have a poor reputation?

Because a numeric enum emits a runtime object, participates in reverse mapping, and accepts any number where the enum type is expected in older configurations — so the type guarantee is weaker than it looks. const enum inlines and interacts badly with isolated transpilation. A union of string literals gives you the same exhaustiveness checking with no runtime footprint, and as const on an object covers the case where you genuinely need the values at runtime.

Show me why a floating promise is a real bug and not a style issue.

The lint rule against it exists because the failure is invisible.

// The write may fail. Nothing will ever know.
async function handler(req, res) {
  auditLog.write(req.user.id, 'viewed');   // no await, no catch
  res.json(await loadData());
}

Three separate consequences, and the third is the one that surprises people. The rejection is unhandled, so it either warns to stderr or terminates the process depending on Node's version and configuration. The response is sent regardless, so the caller believes the audit entry exists. And the write may still be in flight when the process receives SIGTERM during a deploy, so it is lost without any error at all.

The intent is usually fire-and-forget, and the correct expression of that is explicit rather than implicit:

void auditLog.write(req.user.id, 'viewed').catch((e) => logger.error(e));

Now the intent is documented, the rejection is handled, and the lint rule is satisfied for a reason rather than silenced.

Where the work genuinely must not be lost, it belongs on a durable queue rather than in an unawaited promise, because a promise is in-process state and a deploy discards it. That is the architectural version of the same bug.

Why is `JSON.parse` on request bodies a risk?

Because it blocks the event loop for the duration, and the duration is proportional to payload size — so a large body parses at the cost of every concurrent request in the process. A body-size limit is therefore a liveness control rather than merely a storage one. The related problem is prototype pollution through a __proto__ key in parsed JSON, which is why validating and constructing a known shape is safer than using the parsed object directly.

What does `Object.freeze` not do?

Deep freezing. It prevents adding, removing or changing properties on the object itself, and any nested object remains fully mutable, so a frozen configuration with a nested section provides no protection where it is most wanted. It is also silent in non-strict code — an assignment fails without an error — which makes the failure harder to notice than no protection at all. readonly in TypeScript has the analogous limitation of being compile-time only.

Why can two identical-looking values fail `instanceof` in Node?

Because instanceof compares prototype identity, and the same package loaded twice — once through CommonJS and once through ESM, or as two copies at different versions in the dependency tree — creates two distinct classes. An error thrown by one copy fails instanceof against the other, so a catch block that checks by class silently misses it. Checking a stable code property instead is why libraries expose one.

What does a source map actually do for a production stack trace?

Maps positions in the emitted JavaScript back to the original TypeScript, so a stack trace names your file and line rather than a bundled offset that means nothing. Without one, an error in production points at line 1 of a minified bundle and the trace is useless. The deployment question is where the map lives: serving it publicly alongside client code hands your source to anyone who looks, so the usual answer is to upload maps to the error-reporting service and not serve them. For a Node service the maps are already private, and enabling --enable-source-maps is close to free.

What Node question most reliably separates candidates?

"What happens to an in-flight request when this process gets SIGTERM?" It cannot be answered from documentation about the event loop; it requires having deployed something and noticed the errors that appear during every rollout. A strong answer covers readiness draining, server.close, keep-alive sockets not closing on their own, and what happens to work that was only ever held in a promise — which is the point where the theory and the operational reality meet.