TypeScript Debugging

Debugging TypeScript is really two separate skills stacked on top of each other. First, there’s compile-time debugging: using the type checker (tsc) to catch mistakes before your code ever runs. Second, there’s runtime debugging: stepping through the actual JavaScript your TypeScript compiles into, using breakpoints, the call stack, and console output — but doing it in a way that still shows you your original .ts source instead of ugly compiled JS. This lesson covers both, plus the tooling (source maps, debugger, VS Code launch configs) that ties them together.

Overview: How TypeScript Debugging Works

TypeScript itself never runs. The compiler (tsc) reads your .ts files, checks every expression against your declared types, and then erases all type information and emits plain JavaScript. That JavaScript is what Node.js or the browser actually executes. This has a critical consequence for debugging: by the time your program is running, there are no interfaces, no generics, no : number annotations left anywhere — they only ever existed as compiler metadata. A runtime debugger attached to Node or Chrome is debugging JavaScript, full stop.

So how do you set a breakpoint on line 12 of your .ts file and have it actually pause there, instead of on some unrecognizable line of compiled output? The answer is source maps: a side file (or inline comment) that tells tools like VS Code, Chrome DevTools, and Node’s inspector how to translate positions in the compiled .js file back to positions in the original .ts file. With source maps enabled, your debugger shows you TypeScript source, lets you set breakpoints on TypeScript lines, and prints stack traces with .ts filenames and line numbers — even though, under the hood, it’s still stepping through JavaScript.

The other half of debugging TypeScript happens before you ever hit “run”: the type checker itself is a debugging tool. Type errors, exhaustiveness checks, and strict null checks catch entire categories of bugs (wrong argument order, forgotten null handling, typos in property names) without you needing to execute a single line.

Syntax: Enabling Debuggable Output

To make a TypeScript project debuggable at runtime, you configure a handful of tsconfig.json compiler options, then attach a debugger (VS Code, Chrome DevTools, or Node’s built-in inspector) to the running process.

tsconfig Option Purpose
sourceMap Emits a separate .js.map file that maps compiled JS positions back to the original .ts lines.
inlineSourceMap Embeds the source map directly inside the emitted .js file instead of writing a separate .map file.
inlineSources Embeds the original TypeScript source text inside the map itself, so debugging works even if the .ts files aren’t distributed alongside the build.
declarationMap Generates a .d.ts.map file so “Go to Definition” on a compiled library jumps into its original .ts source rather than the generated .d.ts.
noEmitOnError Blocks emitting any JS at all when there are type errors, so you can never accidentally debug a build that the compiler already flagged as broken.

With sourceMap set to true, running node --enable-source-maps app.js (Node 12.12+) automatically resolves stack traces back to .ts lines without any extra libraries. In an editor like VS Code, a launch.json pointing at your compiled outDir (or using ts-node directly) lets you set breakpoints straight in your .ts files and step through them line by line.

For pure compile-time debugging, the relevant “syntax” is a command, not TypeScript code:

tsc --noEmit --pretty type-checks your entire project without producing any output files — ideal for a fast “did I break anything” pass or a CI check. Add --extendedDiagnostics to see how long type-checking took and where the time went, which is useful when debugging a slow build rather than a slow program.

Examples

Example 1: A Bug the Type Checker Won’t Catch

Type errors are not the same thing as logic errors. This function type-checks perfectly under --strict, but it has the wrong formula — a classic case where you need runtime debugging (breakpoints or console.log), not the compiler, to find the problem.

function calculateDiscount(price: number, percentage: number): number {
  return price - percentage;
}

const finalPrice = calculateDiscount(100, 0.2);
console.log(`Final price: $${finalPrice}`);

Output:

Final price: $99.8

The intent was clearly to subtract a percentage of the price (price - price * percentage), not to subtract the raw decimal 0.2. TypeScript has no way to know that — both operands are correctly typed as number. This is exactly the kind of bug you’d find by setting a breakpoint on the return line and watching the actual values of price and percentage in the debugger’s variable panel.

Example 2: Using the debugger Statement

The JavaScript debugger; statement works identically in TypeScript. When code containing it runs under an attached debugger (Chrome DevTools, VS Code, or node --inspect-brk), execution pauses at that exact line, letting you inspect local variables. Outside of a debugger, the statement is simply ignored at full speed.

interface User {
  id: number;
  name: string;
  age: number;
}

function findOldestUser(users: User[]): User | undefined {
  let oldest: User | undefined;
  for (const user of users) {
    debugger;
    if (!oldest || user.age > oldest.age) {
      oldest = user;
    }
  }
  return oldest;
}

const users: User[] = [
  { id: 1, name: "Ada", age: 32 },
  { id: 2, name: "Grace", age: 45 },
  { id: 3, name: "Alan", age: 28 },
];

console.log(findOldestUser(users));

Output:

{ id: 2, name: 'Grace', age: 45 }

Run without a debugger attached, this prints the result immediately and the debugger; line does nothing. Run with node --inspect-brk and a debugger client connected, execution stops on every loop iteration at debugger;, letting you watch oldest update as each User is compared — far more informative than sprinkling console.log calls throughout the loop.

Example 3: Debugging with Discriminated Unions

Well-designed types make runtime debugging easier because the type checker forces you to handle every case explicitly, rather than discovering missing branches when the program crashes.

type ApiResponse =
  | { status: "success"; data: string[] }
  | { status: "error"; message: string };

function handleResponse(response: ApiResponse): void {
  if (response.status === "success") {
    console.log("Data received:", response.data.join(", "));
  } else {
    console.error("Request failed:", response.message);
  }
}

const failed: ApiResponse = { status: "error", message: "Timeout after 5000ms" };
handleResponse(failed);

Output:

Request failed: Timeout after 5000ms

Because ApiResponse is a discriminated union, TypeScript narrows response inside each branch automatically: in the else branch it knows response can only be the error shape, so response.message is valid without a cast. If a third variant were added to the union later and a branch forgotten, tsc would flag it — catching a whole class of “forgot to handle this case” bugs before you ever attach a debugger.

Under the Hood: What Happens Between Source and Breakpoint

Compilation and Source Maps

When sourceMap is enabled, tsc (or your bundler’s TS transform) does three things for every file: it emits the compiled .js, it emits a .js.map describing a position-by-position mapping back to the .ts source, and it appends a comment like //# sourceMappingURL=app.js.map to the bottom of the emitted JS. Any tool that understands source maps — V8’s inspector protocol, Chrome DevTools, VS Code’s debugger — reads that comment, loads the map, and uses it to translate both breakpoint locations (TS line → JS line) and stack traces (JS line → TS line) in both directions.

Type Erasure at Runtime

None of your type annotations survive compilation. interface declarations, generic type parameters, and type-only imports produce zero output JavaScript. This means typeof someValue inside running code reflects the JavaScript runtime type ("string", "number", "object", etc.), never the TypeScript static type — an interface name like User has no runtime representation at all. If you need to distinguish shapes at runtime, you need real runtime checks (typeof, instanceof, discriminant properties like the status field in Example 3), not anything derived from the type system itself.

Common Mistakes

Mistake 1: Assuming a catch Variable Is Typed Error

Since TypeScript 4.4, strict projects type caught exceptions as unknown by default, because JavaScript allows anything to be thrown — not just Error instances. Reaching straight for .message fails to compile:

try {
  JSON.parse("{ invalid json");
} catch (err) {
  console.log(err.message);
}

This produces a compiler error: Object is of type 'unknown' on err.message, because unknown requires narrowing before you can access any property. The fix is to check the type first, usually with instanceof Error:

function riskyOperation(): void {
  throw new Error("Connection refused");
}

try {
  riskyOperation();
} catch (err) {
  if (err instanceof Error) {
    console.log("Caught error:", err.message);
  } else {
    console.log("Unknown error:", err);
  }
}

Output:

Caught error: Connection refused

This isn’t just satisfying the compiler — it makes your debugging code more robust, since a rejected promise or thrown value that isn’t an Error (a string, a plain object, undefined) won’t crash your error-handling logic itself.

Mistake 2: Debugging the Compiled Output Instead of the Source

If sourceMap is left off (or a bundler strips source maps in production mode), attaching a debugger still works — but breakpoints land in the generated .js, variable names may be minified beyond recognition, and stack traces reference line numbers that don’t correspond to anything in your editor. This is a frequent source of confusion: the debugger “looks broken” when really it’s just showing you JavaScript, not TypeScript. Always confirm sourceMap (or inlineSourceMap) is true in tsconfig.json for any environment you intend to debug in, and check that your bundler is configured to emit and serve those maps (most dev servers do this by default, but production builds often disable it deliberately to save size).

Mistake 3: Using ! to Silence Errors Instead of Fixing Them

The non-null assertion operator (!) tells the compiler “trust me, this isn’t null or undefined” — it performs no runtime check at all. Writing products.find(p => p.id === id)!.price compiles cleanly, but if find actually returns undefined at runtime, the program crashes with a generic Cannot read properties of undefined error at that line, with none of the context TypeScript could have preserved. Reaching for ! to make a red squiggle disappear often just delays the bug from compile time (where it’s cheap and precise) to runtime (where it’s a crash in production). Prefer an explicit check or a fallback value, and reserve ! for cases where you can genuinely prove the value can’t be missing.

Best Practices

  • Enable sourceMap (or inlineSourceMap + inlineSources) in every environment where you might need to debug, including staging.
  • Run tsc --noEmit as a fast pre-commit or CI check — it catches whole categories of bugs before a debugger session is ever needed.
  • Set noEmitOnError: true so a build never silently succeeds with known type errors baked in.
  • Prefer narrowing (instanceof, discriminant properties, custom type guards) over non-null assertions (!) when handling values that might legitimately be missing.
  • Use debugger; statements sparingly and remove them before committing — or rely on your editor’s breakpoints, which don’t require touching source code.
  • When a stack trace looks unfamiliar (minified names, unexpected line numbers), suspect a missing or stale source map before assuming the debugger is broken.
  • Use discriminated unions for state that has multiple shapes (loading/success/error) so the compiler forces you to handle every case, catching missing branches before they become runtime bugs.

Practice Exercises

  • Take the calculateDiscount function from Example 1, fix the logic bug so it correctly subtracts a percentage of the price, and verify with console.log that calculateDiscount(100, 0.2) now returns 80.
  • Write a function parseConfig(json: string): Record<string, unknown> that calls JSON.parse inside a try/catch. In the catch block, correctly narrow the caught value before logging a message, following the pattern from Mistake 1.
  • Create a discriminated union type Shape = { kind: "circle"; radius: number } | { kind: "square"; side: number } and a function that computes the area. Add a third variant later and use the compiler’s errors to find every place that now needs updating — this simulates how type-driven debugging catches incomplete changes.

Summary

  • TypeScript debugging spans two layers: compile-time (the type checker catching mistakes via tsc) and runtime (stepping through the actual emitted JavaScript).
  • All type annotations are erased at compile time — the running program is plain JavaScript with no trace of interfaces, generics, or type aliases.
  • Source maps (sourceMap, inlineSourceMap, inlineSources) let debuggers show your original .ts lines, variable names, and stack traces instead of compiled output.
  • tsc --noEmit is a fast way to type-check an entire project without producing build artifacts.
  • Caught exceptions are typed unknown by default in strict mode — always narrow with instanceof or similar before accessing properties.
  • The debugger; statement pauses execution only when an inspector is attached; it’s a no-op otherwise.
  • Discriminated unions turn missing-case bugs into compiler errors, reducing how often you need a runtime debugger at all.