Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Debugging TypeScript: Visual Studio vs. VS Code & Chrome DevTools

For .NET engineers who know: Visual Studio’s integrated debugger — breakpoints, watch windows, call stacks, exception settings, Immediate Window, conditional breakpoints, and attaching to processes You’ll learn: How to set up equivalent debugging for TypeScript in VS Code and Chrome DevTools, what source maps are and why they matter, and the practical debugging workflows for Node.js/NestJS, Next.js, and Vitest Time: 15-20 min read

Visual Studio’s debugger is one of .NET’s genuine advantages. You attach a breakpoint, press F5, and within seconds you’re inspecting live variable values, stepping through code, and evaluating expressions. The integrated experience — breakpoints in the same editor you write code, watch windows, exception settings, Immediate Window — is mature and reliable.

Debugging TypeScript requires more setup. The reason is architectural: TypeScript is compiled to JavaScript before running. What executes at runtime is JavaScript, not TypeScript. A stack trace points to a JavaScript file and line number that you never wrote. Source maps bridge this gap, but you need to understand them to configure debugging correctly. Once configured, VS Code’s debugger is fully capable — breakpoints, watches, call stacks, conditional expressions. The path there just requires deliberate setup rather than the F5-and-go experience you’re used to.


The .NET Way (What You Already Know)

In .NET, the CLR executes your C# directly (via JIT compilation to native code). The PDB (Program Database) file maps IL instructions back to source code lines. Visual Studio reads PDB files automatically — you never configure this. Press F5, and the debugger attaches to the process. Breakpoints you set in .cs files work because Visual Studio knows the mapping between source lines and executable addresses.

// Visual Studio debugger experience:
// 1. Set breakpoint on this line
// 2. Press F5
// 3. Hover over 'user' to inspect its properties
// 4. Add 'user.Email' to the Watch window
// 5. Step into GetUser() with F11

[HttpGet("{id}")]
public async Task<ActionResult<UserDto>> GetUser(int id)
{
    var user = await _userService.GetByIdAsync(id);  // Breakpoint here
    if (user is null) return NotFound();
    return _mapper.Map<UserDto>(user);
}

The Immediate Window lets you execute arbitrary C# expressions in the context of the current stack frame. Exception Settings let you break on first-chance exceptions. Attach to Process lets you debug a running production-like environment. All of this is configured through GUI dialogs and Just Works.

None of that transfers automatically to TypeScript. But all of it is achievable.


The TypeScript Way

Source Maps — The PDB Equivalent

A source map is a JSON file that maps positions in the generated JavaScript back to positions in the original TypeScript source. It is TypeScript’s equivalent of a PDB file.

Without source maps, a stack trace looks like this:

TypeError: Cannot read properties of undefined (reading 'email')
    at Object.<anonymous> (/app/dist/users/users.service.js:47:23)
    at step (/app/dist/users/users.service.js:33:23)
    at Object.next (/app/dist/users/users.service.js:14:53)

With source maps enabled and a debugger that understands them, the same error points to:

TypeError: Cannot read properties of undefined (reading 'email')
    at UsersService.getById (/app/src/users/users.service.ts:31:15)

Enable source maps in tsconfig.json:

{
  "compilerOptions": {
    "sourceMap": true,        // Generate .js.map files alongside .js files
    "inlineSources": true,    // Embed the TS source in the map (optional — easier for Sentry)
    "outDir": "dist"
  }
}

When TypeScript compiles users.service.ts to dist/users/users.service.js, it also generates dist/users/users.service.js.map. The .map file is a JSON document containing the mapping from each character position in the .js file to the corresponding position in the .ts file. The debugger reads this mapping to show you TypeScript source while executing JavaScript.

VS Code launch.json — The Debug Configuration

The launch.json file in .vscode/ tells VS Code how to start and attach to processes for debugging. This is equivalent to configuring Visual Studio’s debug targets in project properties.

NestJS / Node.js — Debug the API Server

// .vscode/launch.json — NestJS/Node.js configurations
{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "NestJS: Debug (ts-node)",
      "type": "node",
      "request": "launch",
      "runtimeArgs": ["-r", "ts-node/register", "-r", "tsconfig-paths/register"],
      "args": ["src/main.ts"],
      "cwd": "${workspaceFolder}",
      "env": {
        "NODE_ENV": "development",
        "TS_NODE_PROJECT": "tsconfig.json"
      },
      "sourceMaps": true,
      "outFiles": ["${workspaceFolder}/dist/**/*.js"],
      "console": "integratedTerminal",
      "internalConsoleOptions": "neverOpen"
    },
    {
      "name": "NestJS: Attach to Running Process",
      "type": "node",
      "request": "attach",
      "port": 9229,
      "sourceMaps": true,
      "outFiles": ["${workspaceFolder}/dist/**/*.js"],
      "restart": true
    },
    {
      "name": "NestJS: Debug Compiled (dist/)",
      "type": "node",
      "request": "launch",
      "program": "${workspaceFolder}/dist/main.js",
      "sourceMaps": true,
      "outFiles": ["${workspaceFolder}/dist/**/*.js"],
      "preLaunchTask": "npm: build"
    }
  ]
}

The "NestJS: Attach to Running Process" configuration is the most useful for daily development. Start your NestJS server with the --inspect flag and then attach:

# Start NestJS with the Node.js inspector enabled
node --inspect -r ts-node/register -r tsconfig-paths/register src/main.ts

# Or add to package.json scripts:
# "debug": "node --inspect -r ts-node/register -r tsconfig-paths/register src/main.ts"

With --inspect, Node.js listens on ws://127.0.0.1:9229 for a debugger connection. Press F5 in VS Code with the “Attach” configuration selected, and VS Code connects to the running process. Set a breakpoint in your TypeScript source — it will be hit when that code executes.

Next.js — Debug the Full-Stack App

Next.js needs two debugger sessions: one for the Node.js server process (Server Components, API routes), and the browser DevTools for client components. VS Code can run both:

// .vscode/launch.json — Next.js configurations
{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Next.js: Server-Side Debug",
      "type": "node",
      "request": "launch",
      "program": "${workspaceFolder}/node_modules/.bin/next",
      "args": ["dev"],
      "cwd": "${workspaceFolder}",
      "env": {
        "NODE_OPTIONS": "--inspect"
      },
      "sourceMaps": true,
      "outFiles": ["${workspaceFolder}/.next/**/*.js"],
      "console": "integratedTerminal"
    },
    {
      "name": "Next.js: Attach to Chrome",
      "type": "chrome",
      "request": "attach",
      "port": 9222,
      "urlFilter": "http://localhost:3000/*",
      "sourceMaps": true,
      "webRoot": "${workspaceFolder}"
    }
  ],
  "compounds": [
    {
      "name": "Next.js: Full Stack Debug",
      "configurations": ["Next.js: Server-Side Debug", "Next.js: Attach to Chrome"]
    }
  ]
}

The compounds entry lets you launch both configurations simultaneously with a single F5. You can then set breakpoints in Server Component code (hit by the Node.js debugger) and Client Component code (hit by the Chrome debugger).

Start Chrome with debugging enabled for the “Attach to Chrome” configuration:

# macOS
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --remote-debugging-port=9222

# Or use VS Code's built-in browser launch (simpler):
# Change "request": "attach" to "request": "launch" and add "url": "http://localhost:3000"

Debugging in Vitest

Vitest, our test runner, supports breakpoint debugging through VS Code:

// .vscode/launch.json — add to configurations array
{
  "name": "Vitest: Debug Current File",
  "type": "node",
  "request": "launch",
  "autoAttachChildProcesses": true,
  "skipFiles": ["<node_internals>/**", "**/node_modules/**"],
  "program": "${workspaceFolder}/node_modules/.bin/vitest",
  "args": ["run", "${relativeFile}"],
  "smartStep": true,
  "console": "integratedTerminal",
  "sourceMaps": true
},
{
  "name": "Vitest: Debug All Tests",
  "type": "node",
  "request": "launch",
  "autoAttachChildProcesses": true,
  "skipFiles": ["<node_internals>/**", "**/node_modules/**"],
  "program": "${workspaceFolder}/node_modules/.bin/vitest",
  "args": ["run"],
  "smartStep": true,
  "console": "integratedTerminal",
  "sourceMaps": true
}

With these configurations, open a test file, set a breakpoint inside a test, and press F5 with “Vitest: Debug Current File” selected. The debugger will hit your breakpoint when that test executes — exactly equivalent to debugging a unit test in Visual Studio’s Test Explorer.

You can also use vitest --inspect-brk for attaching from a terminal:

# Start Vitest in debug mode — pauses before first test
pnpm exec vitest --inspect-brk run src/users/users.service.test.ts
# Then attach with VS Code's "Attach to Running Process" configuration

Chrome DevTools — Frontend Debugging

For client-side React and Vue code, Chrome DevTools is the primary debugger. The Sources panel in DevTools shows your TypeScript source (via source maps), allows setting breakpoints, and provides a watch window and call stack identical in capability to Visual Studio.

Open DevTools with F12. Navigate to Sources > localhost:3000 > your TypeScript files. Set a breakpoint by clicking the line number. Reload the page if the code you want to debug runs on load.

The most useful DevTools panels for TypeScript debugging:

  • Sources — Breakpoints, step-through, watch expressions, call stack. Your primary debugging panel.
  • Network — Inspect API requests and responses. Invaluable for debugging data fetching.
  • Console — Evaluate expressions in the current page context. Equivalent to Visual Studio’s Immediate Window for running code.
  • Application — Inspect localStorage, sessionStorage, cookies, IndexedDB.
  • Performance — Profile rendering and JavaScript execution (rarely needed unless investigating performance regressions).

The debugger Statement

The debugger statement is a hardcoded breakpoint. When DevTools (or VS Code) is open and a debugger statement is reached, execution pauses exactly as if you’d set a breakpoint in the UI.

async function processOrder(orderId: OrderId): Promise<void> {
  const order = await orderRepository.findById(orderId);

  debugger; // Execution pauses here when DevTools is open
  // Inspect 'order' in the Sources panel or Console

  const result = await paymentService.charge(order.total);
  // ...
}

This is the equivalent of programmatic breakpoints in C# (System.Diagnostics.Debugger.Break()). Use it for situations where you can’t set a breakpoint through the UI — code generated at runtime, event handlers attached by a library, or code that runs before the debugger finishes attaching.

Remove debugger statements before committing. The no-debugger ESLint rule catches any that slip through.

Structured Console Logging That Does Not Suck

Raw console.log calls scattered throughout a codebase are the TypeScript equivalent of littering your C# code with Debug.WriteLine. They don’t belong in production, they’re not queryable, and they make log output unreadable. But console.log is genuinely useful during development when configured well.

// Avoid: unstructured, un-searchable, removed before commit
console.log('user', user);
console.log('processing order');

// Better: structured, labeled, still removed before commit but more useful while present
console.log('[UsersService.getById]', { id, user });
console.log('[OrderService.process] Starting', { orderId, total: order.total });

// Best: use a real logger in service code (stays in production)
import { Logger } from '@nestjs/common';

@Injectable()
export class UsersService {
  private readonly logger = new Logger(UsersService.name);

  async getById(id: UserId): Promise<User | null> {
    this.logger.debug('Fetching user', { id });
    const user = await this.db.user.findUnique({ where: { id } });
    this.logger.debug('Fetched user', { id, found: user !== null });
    return user;
  }
}

For temporary debugging during development, use console.table for arrays and console.dir for deep object inspection:

// Inspect an array of objects as a table — much more readable than JSON.stringify
console.table(users.map(u => ({ id: u.id, name: u.name, role: u.role })));

// Deep inspect with full prototype chain (Node.js)
console.dir(complexObject, { depth: null });

// Time a section of code
console.time('db-query');
const results = await db.user.findMany({ where: { active: true } });
console.timeEnd('db-query'); // Outputs: "db-query: 143ms"

For production code in NestJS, use the built-in Logger or a structured logger like Pino (see Article 4.9). Structured JSON logs are queryable in log aggregation systems; console.log is not.

Node.js --inspect Flag

The --inspect flag is the Node.js equivalent of attaching a debugger to a .NET process. It opens a WebSocket server that accepts debugger connections.

# Basic inspect — listens on port 9229
node --inspect src/main.js

# With ts-node (TypeScript source, no compile step)
node --inspect -r ts-node/register src/main.ts

# Break immediately on start — useful for debugging startup code
node --inspect-brk src/main.js

# Change the port (useful when 9229 is already in use)
node --inspect=0.0.0.0:9230 src/main.js

When --inspect is active, Chrome itself can attach directly to Node.js. Open chrome://inspect in Chrome and click “inspect” next to your Node.js process. This opens a DevTools window connected to your server-side Node.js process — the same interface you use for frontend debugging, but running your backend code.

This is particularly useful for debugging Prisma queries, examining request data, and stepping through NestJS service code without a separate VS Code configuration.

React DevTools and Vue DevTools

These are browser extensions that add a Components panel to Chrome DevTools, giving you a tree view of the component hierarchy with live prop and state inspection.

React DevTools (Chrome/Firefox extension):

  • Components panel: Select any component in the tree, inspect its current props, state, and hooks (including useState values, useRef, context values)
  • Profiler panel: Record renders and identify which components re-render and why

Vue DevTools (Chrome/Firefox extension):

  • Component inspector: Equivalent to React DevTools’ Components panel
  • Pinia inspector: Inspect store state and track mutations
  • Timeline: Record events and mutations with timestamps

These tools are essential for diagnosing the most common frontend bug class: “the component isn’t rendering the data I expect.” Rather than adding console.log inside the component, inspect props and state directly in the extension.


Key Differences

.NET / Visual StudioTypeScript / VS Code + DevTools
PDB files for source mappingSource maps (.js.map files)
F5 to launch with debuggerF5 in VS Code with launch.json configured
Attach to Process dialognode --inspect + VS Code “Attach” config
Immediate WindowChrome DevTools Console, VS Code Debug Console
Watch WindowDevTools Watch expressions, VS Code Watch panel
Exception Settings dialogpause on caught/uncaught exceptions in DevTools
Edit and ContinueNot supported — restart process after changes
Step Into (F11)F11 in VS Code / DevTools
Step Over (F10)F10 in VS Code / DevTools
Step Out (Shift+F11)Shift+F11 in VS Code / DevTools
Debug.WriteLine()debugger statement or console.log
Roslyn exception windowDevTools “pause on exceptions” checkbox
Test Explorer debuggingVitest launch configuration in VS Code
[Conditional("DEBUG")]if (process.env.NODE_ENV === 'development')
Application Insights Live MetricsSentry Performance (production tracing)

Gotchas for .NET Engineers

1. Source Maps Must Match the Running Code

The most common reason breakpoints “don’t hit” or show in the wrong location: the source map is stale. If you compiled TypeScript to dist/ an hour ago, then made changes to the TypeScript source without recompiling, the source map no longer matches the running code. The debugger will show breakpoints in the wrong location, or they will simply not trigger.

Solutions:

  • Use ts-node for development (executes TypeScript directly, no compile step, always current)
  • Use a --watch compiler mode so dist/ rebuilds automatically on changes
  • When using compiled output for debugging, always run pnpm build before attaching the debugger
# In one terminal — watch mode recompiles on every save
pnpm exec tsc --watch

# In another terminal — start Node.js, watching for changes
node --inspect dist/main.js
# Then use nodemon or equivalent to auto-restart on dist/ changes

With NestJS’s dev server (pnpm dev / nest start --watch), this is handled automatically — the dev server recompiles and restarts on changes. The attach-based debugging workflow works cleanly here.

2. console.log Output Is Your Primary Stack Trace in Some Environments

In C#, an uncaught exception gives you a full stack trace with file names, line numbers, and the exact position of the throw. In TypeScript, this only works cleanly in environments that have loaded source maps.

In Node.js production environments (minified, bundled code without source maps loaded), a stack trace will point to bundle.js:1:14823 — useless. This is why source maps matter in production for error tracking (Sentry reads source maps to transform these stack traces back to TypeScript), and why structured logging with context is more valuable than stack traces for production diagnosis.

During local development:

  • ts-node always gives you correct TypeScript stack traces (no compile step, no source map mismatch)
  • The Node.js --inspect debugger with VS Code shows correct TypeScript locations
  • console.error(new Error('message')) prints a full stack trace to the console including TypeScript source locations (if source maps are configured)

In production:

  • Configure Sentry to upload source maps during deployment (Article 7.1)
  • Never rely on production stack traces without source map support

3. TypeScript Errors in One Place, Runtime Errors in Another

TypeScript’s type system catches one class of errors at compile time. But TypeScript types are erased at runtime. This means:

  • A variable typed as string might be undefined at runtime if data came from an unvalidated external source
  • A function that TypeScript says returns User might return null if Prisma’s query returns null and you’ve mistyped the return
  • An as cast bypasses type checking — const user = data as User tells TypeScript to trust you, but the runtime value may not match

When you encounter a runtime error that seems impossible given the TypeScript types, the first questions to ask:

  1. Did this data come through a Zod-validated boundary, or did it come in unvalidated?
  2. Is there an as cast somewhere in the chain that overrode type safety?
  3. Did an await get dropped, causing the Promise object itself to be assigned to a variable typed as the resolved value?
// This TypeScript error misleads you — the runtime error is different
async function getUser(id: string): Promise<User> {
  return db.user.findFirst({ where: { id } }); // Returns User | null
  // TypeScript error: Type 'User | null' is not assignable to type 'User'
}

// Fix the TypeScript error with a cast — now you have a runtime problem
async function getUser(id: string): Promise<User> {
  return db.user.findFirst({ where: { id } }) as Promise<User>; // Suppresses error
  // At runtime: user is null, next code throws "Cannot read property 'email' of null"
}

// Correct fix — handle the null case
async function getUser(id: string): Promise<User> {
  const user = await db.user.findFirst({ where: { id } });
  if (!user) throw new NotFoundException(`User ${id} not found`);
  return user;
}

When debugging runtime errors that TypeScript didn’t predict, add a debugger statement at the point where the suspicious value is used and inspect its actual runtime type in the debugger — don’t trust what TypeScript says it is.

4. Hot Reload Is Not Edit and Continue

Visual Studio’s Edit and Continue lets you modify code while paused at a breakpoint and resume execution with the changed code. TypeScript tooling does not support this. When you modify a file during a debugging session:

  • In VS Code with the “Launch” configuration: You must restart the debug session
  • In the “Attach” configuration with a watch-mode server: The server restarts (killing the session), and you re-attach
  • In Chrome DevTools: You can edit sources in DevTools, but changes are in-memory only and do not persist

The practical workaround: use the “Attach” configuration paired with a watch-mode dev server. When you modify code, the server restarts automatically, and you re-attach (which VS Code can be configured to do automatically with "restart": true):

// launch.json — auto-restart attachment after server reload
{
  "name": "NestJS: Attach (Auto-Restart)",
  "type": "node",
  "request": "attach",
  "port": 9229,
  "restart": true,  // Re-attach when the process restarts
  "sourceMaps": true,
  "outFiles": ["${workspaceFolder}/dist/**/*.js"]
}

This gives you a reasonable approximation of Edit and Continue: modify code, save, server restarts, debugger re-attaches, you set your breakpoints again. It’s not as seamless as Visual Studio, but it works.


Common Debugging Scenarios

API Not Returning Expected Data

  1. Set a breakpoint in the NestJS controller method handling the request
  2. Attach VS Code with the NestJS Attach configuration
  3. Make the API request (from browser, Postman, or curl)
  4. Inspect the incoming dto or @Param values to verify request data is correct
  5. Step into the service call to see what the database returns
  6. Check whether data transformations are producing the expected output

Component Not Re-Rendering in React/Vue

  1. Open React/Vue DevTools in Chrome
  2. Select the component that should be re-rendering
  3. Inspect its current props and state
  4. Trigger the action that should cause re-render
  5. Watch for prop/state changes in DevTools — if props change but rendering doesn’t update, check that the component is correctly using the prop (not copying it into local state that doesn’t update)

Type Error at Runtime That TypeScript Didn’t Catch

  1. Add debugger at the point where the runtime error occurs
  2. Inspect the actual runtime type of the suspicious variable (use typeof variable in the Console)
  3. Trace backwards to where the value entered the system
  4. Look for as casts or unvalidated external data

Test Failing With Unclear Output

  1. Add the Vitest debug configuration to launch.json
  2. Open the failing test file
  3. Set a breakpoint inside the failing test
  4. Press F5 with “Vitest: Debug Current File”
  5. Inspect the actual and expected values at the point of failure

Hands-On Exercise

This exercise sets up a complete debugging configuration for a NestJS API and verifies it works.

Step 1: Create a minimal NestJS project

pnpm dlx @nestjs/cli new debug-exercise
cd debug-exercise

Step 2: Enable source maps

Verify tsconfig.json has:

{
  "compilerOptions": {
    "sourceMap": true
  }
}

Step 3: Create the launch.json

Create .vscode/launch.json with the NestJS attach configuration from this article. Then add this script to package.json:

{
  "scripts": {
    "debug": "node --inspect -r ts-node/register -r tsconfig-paths/register src/main.ts"
  }
}

Step 4: Add a deliberate bug

In src/app.controller.ts, add a method:

@Get('user/:id')
getUser(@Param('id') id: string) {
  const users = [
    { id: '1', name: 'Alice', role: 'admin' },
    { id: '2', name: 'Bob', role: 'viewer' },
  ];
  // Bug: this returns undefined for unknown IDs, but the type says it returns an object
  return users.find(u => u.id === id);
}

Step 5: Debug the endpoint

  1. Run pnpm debug in one terminal
  2. Press F5 in VS Code with “NestJS: Attach to Running Process”
  3. Set a breakpoint inside getUser
  4. Navigate to http://localhost:3000/user/1 in a browser
  5. Verify the breakpoint hits, inspect id and users
  6. Use the Debug Console to evaluate users.find(u => u.id === '99') and see undefined

Step 6: Add a Vitest debug configuration and debug a test

Create src/app.controller.spec.ts:

import { describe, it, expect } from 'vitest';

describe('AppController', () => {
  it('returns undefined for unknown user IDs', () => {
    const users = [{ id: '1', name: 'Alice' }];
    const result = users.find(u => u.id === '99');
    expect(result).toBeUndefined(); // Set breakpoint here
  });
});

Add the Vitest configuration to launch.json, set a breakpoint inside the test, and press F5. Verify the debugger pauses at the breakpoint.


Quick Reference

launch.json Configurations Summary

ConfigurationUse ForKey Settings
NestJS Launch (ts-node)Start and debug in one stepruntimeArgs: ["-r", "ts-node/register"]
NestJS AttachAttach to running --inspect server"request": "attach", "port": 9229
Next.js Server DebugDebug Server Components and API routesNODE_OPTIONS: "--inspect"
Next.js Chrome AttachDebug Client Components"type": "chrome"
Vitest DebugDebug failing tests"program": "node_modules/.bin/vitest"

Keyboard Shortcuts (VS Code)

ActionShortcutVisual Studio Equivalent
Start debuggingF5F5
Stop debuggingShift+F5Shift+F5
Toggle breakpointF9F9
Step OverF10F10
Step IntoF11F11
Step OutShift+F11Shift+F11
ContinueF5 (while paused)F5
Open Debug ConsoleCtrl+Shift+YImmediate Window: Ctrl+Alt+I

Source Map Troubleshooting

SymptomLikely CauseFix
Breakpoints don’t hitSource maps missing or staleAdd "sourceMap": true to tsconfig, rebuild
Stack traces show .js filesSource maps not loaded by runtimeVerify map files exist alongside JS files
Breakpoint in wrong locationCompiled output is outdatedRecompile or use ts-node to skip compile
debugger statement ignoredNo debugger attachedOpen DevTools before running, or attach VS Code

Useful --inspect Commands

# Start with inspector (attach-based debugging)
node --inspect src/main.js

# Break before first line (for debugging startup code)
node --inspect-brk src/main.js

# With ts-node (no compile step needed)
node --inspect -r ts-node/register src/main.ts

# NestJS with nest CLI
nest start --debug

# Vitest with inspector
pnpm exec vitest --inspect-brk run

Further Reading