TypeScript

Debugging TypeScript Applications: Lessons from the Trenches

TypeScript's type system catches a lot. But "a lot" isn't "everything." The bugs that survive the compiler are the ones that hurt the most — subtle, runti...

19 Dec 2024

Debugging TypeScript Applications: Lessons from the Trenches

TypeScript's type system catches a lot. But "a lot" isn't "everything." The bugs that survive the compiler are the ones that hurt the most — subtle, runtime, hard to reproduce.

Here's what I've learned debugging TypeScript across large codebases.

Complex type mismatches

As your types grow, intersection types can create surprises:

Typescript
type User = {
  id: number;
  name: string;
};

type Admin = User & {
  permissions: string[];
};

const getUser = (user: User) => {
  console.log(user.name);
};

const admin: Admin = { id: 1, name: "Ehsan", permissions: ["read"] };
getUser(admin); // This actually works — but watch for deeper incompatibilities

When types get complex enough that the compiler gives you cryptic errors, type guards bring clarity:

Typescript
const isUser = (input: any): input is User => {
  return "id" in input && "name" in input;
};

if (isUser(admin)) {
  getUser(admin);
}

The null problem

Enable strictNullChecks. Seriously. If you haven't, you're flying blind.

Typescript
const getUserName = (user: User | null): string => {
  return user.name; // Boom. Runtime error if user is null.
};

Fix it with optional chaining and nullish coalescing:

Typescript
const getUserName = (user: User | null): string => {
  return user?.name ?? "Guest";
};

Tools that actually help

VS Code debugger

Set up source maps in tsconfig.json:

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

Add a launch config in .vscode/launch.json:

Json
{
  "version": "0.2.0",
  "configurations": [
    {
      "type": "node",
      "request": "launch",
      "name": "Launch Program",
      "skipFiles": ["<node_internals>/**"],
      "program": "${workspaceFolder}/dist/index.js"
    }
  ]
}

Now you can set breakpoints directly in .ts files. VS Code maps them to the compiled JavaScript at runtime.

Chrome DevTools

For frontend TypeScript, compile with source maps enabled. Open Chrome DevTools, go to the Sources tab, and you'll see your original .ts files under webpack:// or similar. You can set breakpoints and inspect variables in TypeScript directly.

console.log — but smarter

I'm not above console.log. But I use it with intent.

console.table for arrays and objects:

Typescript
const users: User[] = [
  { id: 1, name: "Ehsan" },
  { id: 2, name: "John" },
];

console.table(users);

String interpolation for context:

Typescript
console.log(`User ID: ${user.id}, Name: ${user.name}`);

ESLint with TypeScript

Static analysis catches bugs before you even run the code:

Bash
npm install eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin --save-dev
Json
{
  "parser": "@typescript-eslint/parser",
  "plugins": ["@typescript-eslint"],
  "rules": {
    "@typescript-eslint/no-unused-vars": "error",
    "@typescript-eslint/explicit-function-return-type": "warn"
  }
}
Bash
npx eslint src/**/*.ts

Stack traces

TypeScript stack traces can be noisy. Tools like ts-stacktrace map them back to your .ts files so you're not reading compiled JavaScript line numbers.

What I do in practice

  • Remove console.log before merging. Use a real logger like pino or winston in production.
  • Write tests for the edge cases the compiler can't catch. Async race conditions, API response shapes, third-party library quirks.
  • Isolate the problem. Reproduce it in the smallest possible unit before debugging the whole system.
  • Read the error message. TypeScript's error messages are verbose but precise. The answer is usually in there if you read the whole thing.

Debugging is a skill you build by doing it. The compiler handles the easy bugs. The hard ones teach you the most.

Keep reading