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
useStatevalues,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 Studio | TypeScript / VS Code + DevTools |
|---|---|
| PDB files for source mapping | Source maps (.js.map files) |
| F5 to launch with debugger | F5 in VS Code with launch.json configured |
| Attach to Process dialog | node --inspect + VS Code “Attach” config |
| Immediate Window | Chrome DevTools Console, VS Code Debug Console |
| Watch Window | DevTools Watch expressions, VS Code Watch panel |
| Exception Settings dialog | pause on caught/uncaught exceptions in DevTools |
| Edit and Continue | Not 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 window | DevTools “pause on exceptions” checkbox |
| Test Explorer debugging | Vitest launch configuration in VS Code |
[Conditional("DEBUG")] | if (process.env.NODE_ENV === 'development') |
| Application Insights Live Metrics | Sentry 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-nodefor development (executes TypeScript directly, no compile step, always current) - Use a
--watchcompiler mode sodist/rebuilds automatically on changes - When using compiled output for debugging, always run
pnpm buildbefore 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-nodealways gives you correct TypeScript stack traces (no compile step, no source map mismatch)- The Node.js
--inspectdebugger 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
stringmight beundefinedat runtime if data came from an unvalidated external source - A function that TypeScript says returns
Usermight returnnullif Prisma’s query returns null and you’ve mistyped the return - An
ascast bypasses type checking —const user = data as Usertells 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:
- Did this data come through a Zod-validated boundary, or did it come in unvalidated?
- Is there an
ascast somewhere in the chain that overrode type safety? - Did an
awaitget 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
- Set a breakpoint in the NestJS controller method handling the request
- Attach VS Code with the NestJS Attach configuration
- Make the API request (from browser, Postman, or
curl) - Inspect the incoming
dtoor@Paramvalues to verify request data is correct - Step into the service call to see what the database returns
- Check whether data transformations are producing the expected output
Component Not Re-Rendering in React/Vue
- Open React/Vue DevTools in Chrome
- Select the component that should be re-rendering
- Inspect its current props and state
- Trigger the action that should cause re-render
- 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
- Add
debuggerat the point where the runtime error occurs - Inspect the actual runtime type of the suspicious variable (use
typeof variablein the Console) - Trace backwards to where the value entered the system
- Look for
ascasts or unvalidated external data
Test Failing With Unclear Output
- Add the Vitest debug configuration to
launch.json - Open the failing test file
- Set a breakpoint inside the failing test
- Press F5 with “Vitest: Debug Current File”
- 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
- Run
pnpm debugin one terminal - Press F5 in VS Code with “NestJS: Attach to Running Process”
- Set a breakpoint inside
getUser - Navigate to
http://localhost:3000/user/1in a browser - Verify the breakpoint hits, inspect
idandusers - Use the Debug Console to evaluate
users.find(u => u.id === '99')and seeundefined
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
| Configuration | Use For | Key Settings |
|---|---|---|
| NestJS Launch (ts-node) | Start and debug in one step | runtimeArgs: ["-r", "ts-node/register"] |
| NestJS Attach | Attach to running --inspect server | "request": "attach", "port": 9229 |
| Next.js Server Debug | Debug Server Components and API routes | NODE_OPTIONS: "--inspect" |
| Next.js Chrome Attach | Debug Client Components | "type": "chrome" |
| Vitest Debug | Debug failing tests | "program": "node_modules/.bin/vitest" |
Keyboard Shortcuts (VS Code)
| Action | Shortcut | Visual Studio Equivalent |
|---|---|---|
| Start debugging | F5 | F5 |
| Stop debugging | Shift+F5 | Shift+F5 |
| Toggle breakpoint | F9 | F9 |
| Step Over | F10 | F10 |
| Step Into | F11 | F11 |
| Step Out | Shift+F11 | Shift+F11 |
| Continue | F5 (while paused) | F5 |
| Open Debug Console | Ctrl+Shift+Y | Immediate Window: Ctrl+Alt+I |
Source Map Troubleshooting
| Symptom | Likely Cause | Fix |
|---|---|---|
| Breakpoints don’t hit | Source maps missing or stale | Add "sourceMap": true to tsconfig, rebuild |
Stack traces show .js files | Source maps not loaded by runtime | Verify map files exist alongside JS files |
| Breakpoint in wrong location | Compiled output is outdated | Recompile or use ts-node to skip compile |
debugger statement ignored | No debugger attached | Open 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
- VS Code — Node.js Debugging Guide — The official guide for all Node.js debugging configurations, including attach, launch, and compound configurations
- Chrome DevTools — JavaScript Debugging — Breakpoints, call stacks, watch expressions, and source maps in Chrome DevTools
- NestJS — Recipes: Hot Reload — Configuring webpack HMR for faster NestJS development restart cycles when the full restart latency is too slow
- TypeScript — Source Maps — tsconfig reference for all source map options (
sourceMap,inlineSourceMap,inlineSources,sourceRoot)