Debugging TypeScript
TypeScript does not just add types to JavaScript — it gives you a second set of eyes that never sleeps. The compiler catches entire categories of bugs before you run a single line of code. But that only works if you know how to read what it is telling you, and how to use the type system as an active debugging tool rather than a hurdle to work around.
Debugging in TypeScript happens at two levels. At compile time, the type checker flags inconsistencies you might not notice until a user hits them in production. At runtime, you still need the usual toolkit of logging and inspection, but TypeScript shapes how you approach those problems too. This lesson covers both layers: how to decode error messages, how to use types to find logic bugs, and how to apply runtime techniques without losing type safety.
Reading TypeScript Error Messages
TypeScript errors are famously verbose, but they follow a pattern. The outermost message tells you what went wrong. The indented lines underneath trace the path the compiler took to reach that conclusion. Reading them bottom-up is usually faster than top-down.
The fence below is marked typescript no-run because its whole point is to fail to compile: the runner strips the types and would happily run the buggy assignment (printing nothing useful), which would contradict the lesson. Read it as compiler input, not as a program:
type User = {
id: number;
name: string;
role: "admin" | "editor" | "viewer";
};
function promote(user: User): User {
return { ...user, role: "superadmin" };
}
Under tsc --strict this fails with:
error TS2322: Type '"superadmin"' is not assignable to type '"admin" | "editor" | "viewer"'.
The error tells you exactly which value is wrong and exactly what values are allowed. The fix is either to add "superadmin" to the union or to remove it from the return value. There is no ambiguity. And notice why this must be no-run: the tsx runner strips the annotation and would assign "superadmin" without complaint, so the buggy User would flow onward silently — the compiler is the only layer that catches it, which is exactly the debugging point.
When the error comes from a deeply nested generic, the message grows longer, but the structure stays the same. Find the innermost line — that is where the actual mismatch is.
Using the Type System to Find Logic Bugs
The most powerful debugging technique in TypeScript is not a tool — it is a mental model. When something behaves unexpectedly, add explicit types to the values involved. If the compiler agrees with your annotation, you have confirmed what the code actually does. If it disagrees, you have found the bug.
type CartItem = {
productId: string;
quantity: number;
unitPrice: number;
};
function calculateTotal(items: CartItem[]): number {
return items.reduce((sum, item) => {
// Bug: forgot to multiply by quantity
return sum + item.unitPrice;
}, 0);
}
const cart: CartItem[] = [
{ productId: "abc", quantity: 3, unitPrice: 9.99 },
{ productId: "xyz", quantity: 1, unitPrice: 4.99 },
];
// Type is correct (number), but the logic is wrong.
// TypeScript cannot catch arithmetic mistakes, but narrowing the
// intermediate type helps you reason about each step.
const itemTotal = (item: CartItem): number => item.unitPrice * item.quantity;
function calculateTotalFixed(items: CartItem[]): number {
return items.reduce((sum, item) => sum + itemTotal(item), 0);
}
console.log(calculateTotal(cart)); // 14.98 — wrong
console.log(calculateTotalFixed(cart)); // 34.96 — correct
TypeScript cannot catch logical errors like forgetting to multiply, but extracting the computation into a typed helper forces you to name the concept — and named concepts are easier to verify.
Exhaustiveness Checks with never
The never type is TypeScript's way of representing something that should never happen. You can exploit this to write code that breaks at compile time if you forget to handle a new variant of a union.
type Shape =
| { kind: "circle"; radius: number }
| { kind: "rectangle"; width: number; height: number }
| { kind: "triangle"; base: number; height: number };
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "rectangle":
return shape.width * shape.height;
case "triangle":
return (shape.base * shape.height) / 2;
default: {
// If a new Shape variant is added and this switch is not updated,
// TypeScript will error here: shape is no longer never.
const exhaustiveCheck: never = shape;
throw new Error(`Unhandled shape: ${JSON.stringify(exhaustiveCheck)}`);
}
}
}
const c: Shape = { kind: "circle", radius: 5 };
console.log(area(c).toFixed(2)); // 78.54
This pattern means you can safely add variants to a union knowing the compiler will point you to every switch statement that needs updating. It turns a runtime crash into a compile-time error.
Predict
A square member was added to Shape, but area still only handles circle — the default branch assigns shape to a never. This exact code is checked with tsc --strict AND run by the tsx runner on a square. Predict BOTH: what does tsc report, and what does the runner print (or does it crash)?
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; side: number };
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
// note: no "square" case
default: {
const _exhaustive: never = shape;
return _exhaustive;
}
}
}
console.log(area({ kind: "square", side: 4 }));Type Guards and Narrowing for Runtime Inspection
When you receive data from an external source — an API response, a file, user input — TypeScript cannot verify its shape at compile time. Type guards let you perform that check at runtime while preserving type information inside the guarded block.
type ApiSuccess = { status: "ok"; data: string[] };
type ApiError = { status: "error"; message: string };
type ApiResponse = ApiSuccess | ApiError;
function isSuccess(response: ApiResponse): response is ApiSuccess {
return response.status === "ok";
}
function handleResponse(response: ApiResponse): void {
if (isSuccess(response)) {
// TypeScript knows response is ApiSuccess here
console.log("Items:", response.data.join(", "));
} else {
// TypeScript knows response is ApiError here
console.error("Failed:", response.message);
}
}
handleResponse({ status: "ok", data: ["apple", "banana"] });
handleResponse({ status: "error", message: "Not found" });
User-defined type guards (response is ApiSuccess) are especially useful when debugging data flow. They make the validation logic explicit, self-documenting, and testable in isolation.
Recall
Without scrolling up: the exhaustiveness check earlier assigned the switch value to a variable of type never in the default branch. You first met discriminated unions and exhaustive switches in 17-type-narrowing. Why does that assignment compile while every case is handled, but break the moment a new union member is added?
Debugging Utilities Without Losing Type Safety
console.log works fine for most runtime debugging, but TypeScript offers patterns that go further. A typed debug helper preserves the value's type while logging it, which means you can insert it inline without breaking the surrounding code.
function debug<T>(label: string, value: T): T {
console.log(`[debug] ${label}:`, value);
return value;
}
type Order = { id: string; total: number; isPaid: boolean };
function processOrder(order: Order): string {
const validated = debug("raw order", order);
const withTax = debug("with tax", { ...validated, total: validated.total * 1.2 });
return debug("result", `Order ${withTax.id}: $${withTax.total.toFixed(2)}`);
}
processOrder({ id: "ORD-001", total: 49.99, isPaid: false });
Because debug is generic and returns T, TypeScript still knows the exact type at every step. You can insert it anywhere in a pipeline without adding type assertions or breaking downstream inference.
Putting the Techniques Together
The parseConfig function below receives raw user input and returns a validated config object, wiring together everything above: a type guard (isLogLevel), an exhaustiveness check (in describeLogLevel), and the debug utility tracing the data through. This one runs — read the [debug] trace to see each value as it is validated:
type LogLevel = "info" | "warn" | "error";
type Config = {
host: string;
port: number;
logLevel: LogLevel;
};
const LOG_LEVELS: LogLevel[] = ["info", "warn", "error"];
function isLogLevel(value: unknown): value is LogLevel {
return typeof value === "string" && (LOG_LEVELS as string[]).includes(value);
}
function debug<T>(label: string, value: T): T {
console.log(`[debug] ${label}:`, value);
return value;
}
function parseConfig(raw: Record<string, unknown>): Config {
const host = debug("host", typeof raw.host === "string" ? raw.host : "localhost");
const port = debug("port", typeof raw.port === "number" ? raw.port : 3000);
const logLevel = debug(
"logLevel",
isLogLevel(raw.logLevel) ? raw.logLevel : "info"
);
return { host, port, logLevel };
}
function describeLogLevel(level: LogLevel): string {
switch (level) {
case "info":
return "Informational messages only";
case "warn":
return "Warnings and errors";
case "error":
return "Errors only";
default: {
const _exhaustive: never = level;
throw new Error(`Unknown level: ${_exhaustive}`);
}
}
}
const config = parseConfig({ host: "example.com", port: 8080, logLevel: "warn" });
console.log(describeLogLevel(config.logLevel));
const fallback = parseConfig({ logLevel: "verbose" });
console.log(describeLogLevel(fallback.logLevel));
Try changing "verbose" to "error" and observe how the fallback path changes. Then add a new LogLevel variant and watch the exhaustiveness check fail to compile until you handle it.
Build a Log Reporter
Now turn those techniques into a self-checking program. This is a build task: a log-event reporter that formats each event by narrowing on its discriminant, guards the fatal errors, and counts them — the debugging toolkit applied to a real reporting job. Three functions are stubbed. Run it as-is and it fails immediately, naming the first stub. Implement each until every check passes and it prints All checks passed.
Build
Finish the build. Three functions are stubbed and the checks below them fail until each returns the right value. Run it as-is to see which check fails first, decide what that function is missing, then implement the three functions until it prints 'All checks passed.' The checks run top to bottom, so the first failure you see is TODO 1 — implement it first, then work down.
import assert from "node:assert";
// A log-event reporter. Debugging TypeScript is largely about making the compiler
// PROVE things for you: exhaustive switches, guarded unknowns, typed helpers. The
// domain is given and locked. Do NOT change these.
type InfoEvent = { level: "info"; message: string };
type WarnEvent = { level: "warn"; message: string; code: number };
type ErrorEvent = { level: "error"; message: string; code: number; fatal: boolean };
type LogEvent = InfoEvent | WarnEvent | ErrorEvent;
const events: LogEvent[] = [
{ level: "info", message: "started" },
{ level: "warn", message: "slow response", code: 429 },
{ level: "error", message: "db down", code: 500, fatal: true },
];
// TODO 1: format ONE event by narrowing on the `level` discriminant. One case per
// member, each reading only that member's own fields. End the default branch with an
// exhaustiveness check: `const _never: never = event; return _never;` so that adding
// a fourth level later fails to COMPILE.
// format({ level: "info", message: "hi" }) -> "INFO: hi"
// format({ level: "warn", message: "slow", code: 429 }) -> "WARN[429]: slow"
// format({ level: "error", message: "down", code: 500, fatal: true }) -> "ERROR[500]: down (fatal)"
function format(event: LogEvent): string {
// your code here
return "";
}
// TODO 2: a type guard for the fatal errors. Return `event is ErrorEvent` — true only
// when the level is "error" AND fatal is true.
// isFatal({ level: "error", message: "x", code: 500, fatal: true }) -> true
// isFatal({ level: "warn", message: "x", code: 1 }) -> false
function isFatal(event: LogEvent): event is ErrorEvent {
// your code here
return false;
}
// TODO 3: count the fatal events in a list, using isFatal to narrow the filter.
// countFatal(events) -> 1
function countFatal(list: LogEvent[]): number {
// your code here
return 0;
}
// --- Build checks: these must all pass. Do not edit below this line. ---
assert.strictEqual(format({ level: "info", message: "hi" }), "INFO: hi", "TODO 1: format should render the info line");
assert.strictEqual(format({ level: "warn", message: "slow", code: 429 }), "WARN[429]: slow", "TODO 1: format should render the warn line");
assert.strictEqual(format({ level: "error", message: "down", code: 500, fatal: true }), "ERROR[500]: down (fatal)", "TODO 1: format should render the error line");
assert.strictEqual(isFatal({ level: "error", message: "x", code: 500, fatal: true }), true, "TODO 2: a fatal error should pass the guard");
assert.strictEqual(isFatal({ level: "warn", message: "x", code: 1 }), false, "TODO 2: a warning is not a fatal error");
assert.strictEqual(isFatal({ level: "error", message: "x", code: 1, fatal: false }), false, "TODO 2: a non-fatal error is not fatal (must check fatal, not just level)");
assert.strictEqual(countFatal(events), 1, "TODO 3: countFatal should count exactly the fatal errors");
console.log("All checks passed.");
for (const event of events) {
console.log(format(event));
}
console.log("Fatal count:", countFatal(events));Expected output: All checks passed.
INFO: started
WARN[429]: slow response
ERROR[500]: db down (fatal)
Fatal count: 1
Once it passes, try two variations and predict each before running:
- Forget the fatal suffix. In
format, change the error case toreturn `ERROR[${event.code}]: ${event.message}`;(drop the(fatal)part). Predict which check fails first before running. The info and warn cases still match, but the error line is now missing its suffix, so TODO 1's third check fails withAssertionError: TODO 1: format should render the error line— expectedERROR[500]: down (fatal), gotERROR[500]: down. The kind of off-by-a-detail rendering bug that a byte-exact expected output catches immediately. - Invert the guard in the count. In
countFatal, changelist.filter(isFatal)tolist.filter((e) => !isFatal(e))so it keeps the NON-fatal events. Predict which check fails first before running — and whattsc --strictsays separately. At run time there are two non-fatal events (info and warn), socountFatal(events)returns2, and TODO 3's check fails withAssertionError: TODO 3: countFatal should count exactly the fatal errorsand2 !== 1. Heretsc --strictstays clean —countFatalreturns.length, a number, whether or not the guard narrows — so this is a runtime-only bug, the kind the exit-code grade catches but the compiler cannot: inverting a guard silently flips which set you counted.
Arrange the code
Reassemble a program that threads a value through a generic trace helper (a const arrow that logs a labelled value and returns it unchanged), applies tax, formats a summary, and logs it. The lines are shuffled. Each const consumes the binding above it, so only one order runs top-to-bottom and prints the trace followed by total=50.
console.log(summary);const price = trace("price", 40);const withTax = trace("withTax", price * 1.25);const summary = `total=${withTax}`;const trace = <T>(label: string, value: T): T => { console.log(`[${label}]`, value); return value; };
Capstone milestone
Milestone — hardening the report. Exhaustiveness checks and type guards are how the tracker's console report stays honest: a never-typed default turns a forgotten activity kind into a compile error, and guards narrow queries over the tracked entries. This is enrichment, not the load-bearing core — the report renders without it, but the checks are what keep it from silently drifting as the domain grows.
Hint: This is the reporting milestone, shared with 17-type-narrowing (narrowing per variant). It is requiredForFinal: false — the capstone report works without the exhaustiveness hardening, but adding the never check is what future-proofs it against a fourth activity kind.
- A switch narrows on the discriminant, one case per member, reading only that member's fields
- A never-typed default branch makes a forgotten union member a compile error, not a silent gap
- A user-defined type guard (value is T) drives a typed filter/query over the events
- The reporter's correctness is checked by exit code (asserts), since the runner does not typecheck
Key Takeaways
- TypeScript errors read bottom-up: the innermost line is where the actual type mismatch is
- Adding explicit intermediate types is itself a debugging technique — mismatches surface the bug
- The
nevertype enables exhaustiveness checks that turn forgotten union cases into compile errors - User-defined type guards (
value is T) validate runtime data while preserving type narrowing inside the guarded block - A generic
debug<T>helper logs values inline without breaking type inference or requiring extra variables
Pro Tip: When a TypeScript error message is hard to parse, simplify the code until the error disappears, then add complexity back one piece at a time. The moment the error reappears is the moment you have isolated the cause. This binary-search approach works faster than reading the full stack of generic instantiation traces the compiler sometimes emits.
Next Steps
Reading type errors, writing exhaustiveness checks, and using type guards to validate runtime data are exactly the skills you'll lean on when everything comes together. Next, you'll put the whole type system to work building a complete, type-safe application from scratch.
Ready to continue? Head to Capstone Project!
Next lesson
Capstone Project: Type-Safe Learning Tracker
Build a headless, strict-mode learning tracker across four milestones: a discriminated-union domain model, a generics-backed service, a hand-rolled validation layer that parses unknown input, and a narrowing-driven console report with an exhaustiveness check.
30 min