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.