Run and Debug TypeScript

Bugs do not care about your type annotations: invalid states can still reach the runtime through untyped boundaries, and logic can be wrong in perfectly well-typed code. This lesson covers the running options, the source-map machinery that lets you debug .ts instead of emitted JavaScript, and a triage playbook that exploits the type system during debugging.

Choosing the execution path

Three ways to run, each with a distinct debugging consequence — pick deliberately or the debugger will surprise you.

Matching runner to workflow

Purpose of the example: map the three runners from the setup lesson onto debugging scenarios.

# 1. Fast dev loop with full syntax support (enums, decorators, paths):
npx tsx --watch src/server.ts

# 2. Node's native stripping for zero-dependency scripts (Node ≥ 22.18):
node src/strip-demo.ts

# 3. Debug with the inspector attached — the topic of this lesson:
node --inspect-brk -r tsx/cjs src/server.ts     # or: npx tsx --inspect-brk src/server.ts

What you should see: for scenario 3, Node prints an inspector URL (chrome://inspect). Open it in Chrome or use VS Code's JavaScript Debug Terminal, and the debugger attaches before the first line runs (--inspect-brk breaks on start).

VS Code launch configuration

Purpose of the example: a committed launch.json makes debugging reproducible for the whole team — like a test, but for breakpoints.

// .vscode/launch.json
{
  "version": "0.2.0",
  "configurations": [
    {
      "type": "node",
      "request": "launch",
      "name": "Debug current file (tsx)",
      "runtimeExecutable": "${workspaceFolder}/node_modules/.bin/tsx",
      "args": ["${file}"],
      "console": "integratedTerminal",
      "skipFiles": ["<node_internals>/**"]
    }
  ]
}

What you should see: F5 launches the open file under the debugger, with breakpoints in .ts files binding correctly — no emitted-code stepping. skipFiles keeps the stack focused on your code rather than Node internals.

Source maps: debugging source, not output

Whenever JavaScript is emitted (by tsc or a bundler), the executing file is not the file you wrote. Source maps bridge the two; without them, every stack trace points at line numbers that do not exist in your source.

Enabling source maps in tsconfig

Purpose of the example: one flag makes emitted JavaScript reference its TypeScript origin.

{
  "compilerOptions": {
    "sourceMap": true,       // write .js.map next to each .js
    "inlineSources": true    // embed original source in the map —
                             // enables debugging without serving .ts files
  }
}

What you should see: in browser DevTools or a Node stack trace, errors report src/orders.ts:42:11 instead of dist/orders.js:19:3. Two consequences to internalize: stack traces, breakpoints, and profiler entries all show source positions; and error-monitoring services (Sentry and similar) reconstruct the original stack from the same maps — upload them at build time, never ship them to users.

What we learn: source maps are part of the emit contract, not an optional nicety. A project without them produces un-debuggable output; a project with them keeps the entire debugging experience in TypeScript.

Debugging with types as a map

The checker is a debugging instrument, not just a gatekeeper. During a live session, the types of the values in front of you encode what the program believes.

The triage playbook

Purpose of the example: a repeatable procedure for a value that is "wrong at runtime but valid per the types."

// Symptom: order.total is NaN somewhere. Pause in the debugger and ask
// three questions, in order:

// 1. What does the CHECKER believe `total` is at this breakpoint?
//    (hover in the editor / debugger tooltip)
//    → number  — so the type system is satisfied; not a type bug.

// 2. What does the RUNTIME say?
//    typeof order.total          → "number"  (NaN is a number!)
//    Number.isNaN(order.total)   → true
//    → a value typed `number` holds NaN: a boundary smuggled bad data in.

// 3. Where did the value enter? Walk the call stack to the last place the
//    type was *proved* rather than asserted — usually a parse boundary
//    (JSON.parse, form input, URL param). There, add the runtime check
//    the types could not provide:
const total = Number.isFinite(raw) ? raw : fallback();

What you should see: the bug located not by stepping blindly but by asking "where does the runtime violate what the checker assumed?" Nine times in ten, the answer is an untyped boundary — the exact theme of the errors lesson.

Node and browser debugging recipes

Purpose of the example: the two most common day-to-day debug setups, in copyable form.

# Node: drop into a REPL at the breakpoint with full source positions:
npx tsx --inspect-brk src/script.ts

# Browser (Vite project): dev server + sourcemaps mean DevTools breakpoints
# set in src/*.ts bind natively — no extra config:
npm run dev        # then open DevTools → Sources → webpack:// or src/

# Print the checker's view of a value while debugging in the editor:
# hover any identifier — the tooltip shows the *narrowed* type at that line,
# which answers "why didn't my guard work?" instantly.

What you should see: breakpoints binding to .ts lines in both environments; editor tooltips showing narrowed types that change across your if guards.

Practice: debug with the map on

Triage a NaN, in anger

Create a module that computes a total from a parsed JSON string with a deliberately inconsistent field type in the JSON. Run it with the debugger attached and execute the three-question playbook above, writing down where the checker's belief and the runtime diverged. What we learn: the divergence point is always a boundary — and boundaries are where the next lesson's validation patterns live.

Debug the types themselves

Take any complex type from the advanced-types lesson and add an intentionally wrong annotation. The editor's tooltip vs. the runtime value now disagree — trace which line lied (usually an as cast or an any leak) using the editor's "go to type definition". What we learn: when debugging, treat as casts as suspects before treating data as the culprit.