LEVEL 6 · LESSON 21 OF 22

Libraries and large projects Advanced

Debugging TypeScript

Read long type errors from the bottom up, make the compiler show you types, fix generic inference, trace module resolution and file inclusion, and tell type bugs from runtime bugs.

  • 50 min to read and try
  • You need: Testing TypeScript, TypeScript monorepos, Declaration files and The debugging method
  • You build: A set of diagnosed and fixed failures from a shop codebase: a misleading array error, three inference problems, a module that cannot be found, a file nobody knew was compiled, and a type-correct program that adds ₦2.5 billion to an order
Test yourself

BY THE END OF THIS LESSON YOU CAN

  • Read a multi-line type error from its most specific line upward, and move the error to the real mistake with annotations or satisfies
  • Make the compiler reveal an inferred type without an editor
  • Diagnose and fix common generic inference problems, including never[], wrong candidates and unwanted widening (NoInfer)
  • Use --traceResolution, --explainFiles and --showConfig to explain module, file and configuration problems
  • Decide whether a bug is a type problem or a runtime problem, and find the escape hatch that let bad data in

The error that blamed the wrong line

A developer builds an order for the checkout. The compiler refuses, with this:

order.ts
type Currency = "NGN" | "USD";

interface Money {
  kobo: number;
  currency: Currency;
}

interface OrderLine {
  sku: string;
  quantity: number;
  unitPrice: Money;
}

interface OrderInput {
  customerId: string;
  lines: OrderLine[];
  delivery: { address: string; fee: Money };
}

function createOrder(input: OrderInput): string {
  return `${input.customerId}: ${input.lines.length} lines`;
}

const lines = [
  { sku: "RICE-5KG", quantity: 2, unitPrice: { kobo: 1_250_000, currency: "NGN" } },
  { sku: "OIL-1L", quantity: 1, unitPrice: { kobo: 350_000, currency: "ngn" } },
];

console.log(createOrder({ customerId: "u_ada", lines, delivery: { address: "12 Allen Avenue, Ikeja", fee: { kobo: 150_000, currency: "NGN" } } }));
What npx tsc --noEmit prints
order.ts:29:48 - error TS2322: Type '{ sku: string; quantity: number; unitPrice: { kobo: number; currency: string; }; }[]' is not assignable to type 'OrderLine[]'.
  Type '{ sku: string; quantity: number; unitPrice: { kobo: number; currency: string; }; }' is not assignable to type 'OrderLine'.
    The types of 'unitPrice.currency' are incompatible between these types.
      Type 'string' is not assignable to type 'Currency'.

29 console.log(createOrder({ customerId: "u_ada", lines, delivery: { address: "12 Allen Avenue, Ikeja", fee: { kobo: 150_000, currency: "NGN" } } }));
                                                  ~~~~~

  order.ts:16:3 - The expected type comes from property 'lines' which is declared here on type 'OrderInput'
    16   lines: OrderLine[];
         ~~~~~


Found 1 error in order.ts:29

The squiggle is under lines in the call, and the message says a currency is a string. The developer checks the rice line, sees "NGN", which is a valid Currency, and is confused: the message seems to say that "NGN" is a plain string. They start adding as Currency to things.

The message is accurate; it is just answering a different question from the one the developer asked. The real mistake is "ngn" in lower case on the oil line, and the compiler never mentions it. This lesson is about getting from "the compiler is wrong" to the actual cause quickly, for type errors, for inference that goes the wrong way, for modules that cannot be found, and for bugs that no type error will ever show. You will use the general method from The debugging method (observe, reproduce, isolate, hypothesise, test), with tools that are specific to the TypeScript compiler.

Anatomy of a type error

Every tsc error has the same parts:

order.ts:29:48 - error TS2322: Type '{ …; currency: string; }[]' is not assignable to type 'OrderLine[]'.   ← headline
  Type '{ …; currency: string; }' is not assignable to type 'OrderLine'.                                    ← one level deeper
    The types of 'unitPrice.currency' are incompatible between these types.                                  ← deeper
      Type 'string' is not assignable to type 'Currency'.                                                    ← the actual clash

  order.ts:16:3 - The expected type comes from property 'lines' …                                            ← related location
The headline compares the outermost types; each indented line zooms in; the last line is the specific clash.
  • Position (file:line:column): where the compiler noticed the problem, which is where two types met. That is often not where the mistake was made.
  • Code (TS2322): searchable, and the same across versions and languages.
  • Elaboration chain: the indented lines. Each one descends one level into the structure. Read it from the bottom up: the deepest line is the smallest pair of types that do not fit.
  • Related information: the extra location at the end, here where the expected type was declared.

Applied to the order: the deepest line says a string met Currency at unitPrice.currency. Why is it string and not "ngn"? Because lines was declared without a type. When TypeScript infers the type of an array of objects written in a variable, it widens string literals to string: nothing told it that currency should stay a literal. So both lines have currency: string, the mistake was lost at the declaration, and the error appears later, at the call, about the whole array.

Move the error to the mistake

The fix for the reading problem is to state the intended type where the value is written. Either annotate the variable, or use satisfies, which checks the value against the type but keeps the value's own precise type:

order.ts
type Currency = "NGN" | "USD";

interface Money {
  kobo: number;
  currency: Currency;
}

interface OrderLine {
  sku: string;
  quantity: number;
  unitPrice: Money;
}

const lines = [
  { sku: "RICE-5KG", quantity: 2, unitPrice: { kobo: 1_250_000, currency: "NGN" } },
  { sku: "OIL-1L", quantity: 1, unitPrice: { kobo: 350_000, currency: "ngn" } },
] satisfies OrderLine[];
What npx tsc --noEmit prints
order.ts:16:61 - error TS2820: Type '"ngn"' is not assignable to type 'Currency'. Did you mean '"NGN"'?

16   { sku: "OIL-1L", quantity: 1, unitPrice: { kobo: 350_000, currency: "ngn" } },
                                                               ~~~~~~~~

  order.ts:5:3 - The expected type comes from property 'currency' which is declared here on type 'Money'
    5   currency: Currency;
        ~~~~~~~~


Found 1 error in order.ts:16

Now the error sits on the exact property, names the exact value, and even suggests the fix. This is the most useful habit in the whole lesson: when an error is far from its cause, add types closer to the data, and the compiler will point at the cause. Once fixed, the program runs:

order-fixed.ts
type Currency = "NGN" | "USD";

interface Money {
  kobo: number;
  currency: Currency;
}

interface OrderLine {
  sku: string;
  quantity: number;
  unitPrice: Money;
}

const lines: OrderLine[] = [
  { sku: "RICE-5KG", quantity: 2, unitPrice: { kobo: 1_250_000, currency: "NGN" } },
  { sku: "OIL-1L", quantity: 1, unitPrice: { kobo: 350_000, currency: "NGN" } },
];

const total = lines.reduce((sum, line) => sum + line.unitPrice.kobo * line.quantity, 0);
console.log(`${lines.length} lines, ₦${(total / 100).toFixed(2)}`);
Output of npx tsx order-fixed.ts and of the browser terminal
2 lines, ₦28500.00

Codes you will meet most

CodeMeaningLook first at
TS2322A value is not assignable to a declared typeThe deepest line of the chain
TS2345An argument does not fit a parameterThe parameter's type, and how the argument's type was inferred
TS2741 / TS2739A required property (or several) is missingThe object literal and the "is declared here" location
TS2339Property does not exist on a typeWhether the value was narrowed, or the type is wider than you think
TS2820Like TS2322, with a "Did you mean" suggestionThe suggestion: usually a typo in a literal
TS2769No overload matches this callThe last overload's error (shown), then the others
TS18046 / TS18047 / TS18048Value is unknown / possibly null / possibly undefinedA missing check, or a missing narrowing
TS7006 / TS7053Implicit any: a parameter, or an indexA missing annotation where inference had nothing to go on
TS2307 / TS7016Module not found / found but untyped--traceResolution, below

Before you touch the code

REASON IT OUT

What is this error really telling you?

A build prints six errors in packages/http/src/app.ts. Before changing anything, answer:

  1. Is the first error the cause of the others? How would you tell?
  2. For each type in the headline: did it come from an annotation you wrote, from inference, or from a declaration file? Which of those can be wrong?
  3. Is the position the place where the mistake was made, or the place where two types met?
  4. Is this a problem with the types at all? Could the program already be misbehaving at runtime with no type error, or be fine at runtime despite the error?
Show the reasoning
  1. Fix and re-check the first error before reading the rest. If an import fails (TS2307), everything imported from it becomes an error type, and later lines report nonsense such as "error is of type unknown" after an instanceof check that should have narrowed it. The example below shows exactly that.
  2. Annotations say what you intended, inference says what you wrote, declaration files say what someone else claims. Any of them can be wrong: an annotation can be too strict, inference can widen, and a declaration can lie (Declaration files).
  3. Usually where they met. Walk backwards from the position to where the value got its type, as with the lines array.
  4. Type errors describe the code, not the data. A clean compile says nothing about a JSON body that has a string where the type says number; and an error may be the compiler being cautious about a case your data never has. The last section of this lesson is about telling the two apart.

Here is the cascade from point 1, on the shop monorepo, when http is compiled on its own before its dependencies are built:

Terminal on your computer
npx tsc -p packages/http
packages/http/src/app.ts:1:30 - error TS2307: Cannot find module '@shop/auth' or its corresponding type declarations.
…
packages/http/src/app.ts:2:53 - error TS2307: Cannot find module '@shop/core' or its corresponding type declarations.
…
packages/http/src/app.ts:30:51 - error TS18046: 'error' is of type 'unknown'.

30       if (error instanceof DomainError) return `${error.code === "UNAUTHENTICATED" ? 401 : 404} ${error.code}`;
                                                     ~~~~~

The TS18046 is not a real problem in that line. DomainError could not be resolved, so instanceof DomainError narrows nothing. Build the dependencies (tsc -b) and all six errors disappear together.

Making the compiler show you a type

An editor shows the inferred type of anything you hover over, and that is the first tool to reach for. When you only have a terminal (in CI, over SSH, or in a code review), you can make tsc print a type by assigning the value to something it cannot possibly be. never is the usual choice, since nothing is assignable to it:

probe.ts
const orders = [
  { id: "ord_1", status: "paid", totalKobo: 250_000 },
  { id: "ord_2", status: "pending", totalKobo: 90_000 },
] as const;

const firstPaid = orders.find((o) => o.status === "paid");

const probe: never = firstPaid;
What npx tsc --noEmit prints
probe.ts:8:7 - error TS2322: Type '{ readonly id: "ord_1"; readonly status: "paid"; readonly totalKobo: 250000; } | undefined' is not assignable to type 'never'.
  Type 'undefined' is not assignable to type 'never'.

8 const probe: never = firstPaid;
        ~~~~~


Found 1 error in probe.ts:8

The message spells out the full type of firstPaid, and it holds a small surprise: not "either order", but exactly the paid order object, or undefined. Because of as const, each order has a literal status, and TypeScript worked out that the callback o.status === "paid" can only return true for the first one, so it narrowed the result of find (an inferred type predicate). Seeing the real type ends guesswork like this. Delete the probe when you are done. To see a type rather than a value's type, declare a value of it first: declare const sample: SomeType; const probe: never = sample;. The type tests from Testing TypeScript are the permanent version of a probe: once you know what a type should be, pin it with expectTypeOf.

TIP

Run npx tsc --noEmit --pretty false when you want one line per error (for grep, or to paste into an issue), and plain npx tsc --noEmit for the readable version with code excerpts. When there are many errors, the pretty output ends with an "Errors Files" table that shows which file to open first.

When inference goes the wrong way

Most "impossible" type errors in everyday code are inference doing exactly what it was designed to do, with less information than you assumed. Four patterns cover most of them.

1. The first candidate wins

firstOr returns the first item of a list, or a fallback:

first-or.ts
function firstOr<T>(items: readonly T[], fallback: T): T {
  return items[0] ?? fallback;
}

const cheapest = firstOr([2500, 1200], "none");
What npx tsc --noEmit prints
first-or.ts:5:40 - error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.

5 const cheapest = firstOr([2500, 1200], "none");
                                         ~~~~~~


Found 1 error in first-or.ts:5

TypeScript inferred T = number from the array, then checked the fallback against it. The error is on "none", although the real question is a design one: should the result be number | string? If yes, give the fallback its own type parameter:

first-or.ts
function firstOr<T, F>(items: readonly T[], fallback: F): T | F {
  return items[0] ?? fallback;
}

const cheapest = firstOr([2500, 1200], "none");
const nothing = firstOr([] as number[], "none");
console.log(cheapest, nothing);
Output of npx tsx first-or.ts and of the browser terminal
2500 none

2. An empty array with nothing to go on

cart.ts
const cart = { id: "C-1", skus: [] };
cart.skus.push("RICE-5KG");
What npx tsc --noEmit prints
cart.ts:2:16 - error TS2345: Argument of type '"RICE-5KG"' is not assignable to parameter of type 'never'.

2 cart.skus.push("RICE-5KG");
                 ~~~~~~~~~~


Found 1 error in cart.ts:2

An empty array inside an object literal has no elements to infer from, so its type is never[]: an array that can hold nothing. The message mentions never, which is the clue. Annotate what the array is for: const cart: { id: string; skus: string[] } = …, or skus: [] as string[].

3. An accumulator that starts as {}

totals.ts
const orders = [
  { status: "paid", kobo: 250_000 },
  { status: "pending", kobo: 90_000 },
  { status: "paid", kobo: 40_000 },
];

const totals = orders.reduce((acc, order) => {
  acc[order.status] = (acc[order.status] ?? 0) + order.kobo;
  return acc;
}, {});
What npx tsc --noEmit prints
totals.ts:8:3 - error TS7053: Element implicitly has an 'any' type because expression of type 'string' can't be used to index type '{}'.
  No index signature with a parameter of type 'string' was found on type '{}'.

8   acc[order.status] = (acc[order.status] ?? 0) + order.kobo;
    ~~~~~~~~~~~~~~~~~

totals.ts:8:24 - error TS7053: Element implicitly has an 'any' type because expression of type 'string' can't be used to index type '{}'.
  No index signature with a parameter of type 'string' was found on type '{}'.

8   acc[order.status] = (acc[order.status] ?? 0) + order.kobo;
                         ~~~~~~~~~~~~~~~~~


Found 2 errors in the same file, starting at: totals.ts:8

reduce infers the accumulator's type from the initial value, and {} is an object type with no properties, so indexing it by a string is an implicit any (TS7053). Tell reduce what you are building, with a type argument:

totals.ts
const orders = [
  { status: "paid", kobo: 250_000 },
  { status: "pending", kobo: 90_000 },
  { status: "paid", kobo: 40_000 },
];

const totals = orders.reduce<Record<string, number>>((acc, order) => {
  acc[order.status] = (acc[order.status] ?? 0) + order.kobo;
  return acc;
}, {});
console.log(totals);
Output of npx tsx totals.ts and of the browser terminal
{ paid: 290000, pending: 90000 }

4. Inference that is too generous: NoInfer

The opposite problem is worse, because there is no error at all. A tiny state-machine helper takes the list of states and the initial state:

machine.ts
function createMachine<S extends string>(states: readonly S[], initial: S) {
  return { states, current: initial };
}

const order = createMachine(["pending", "paid", "cancelled"], "shipped");
console.log(order.current, order.states.includes(order.current));
Output of npx tsx machine.ts and of the browser terminal
shipped false

"shipped" is not one of the states, yet it compiled. TypeScript collects candidates for S from both arguments and infers the union of all four strings, so "shipped" fits by definition. A never probe on order.current would show "pending" | "paid" | "cancelled" | "shipped". The NoInfer<T> utility type says "check against T, but do not use this argument to infer it":

machine.ts
function createMachine<S extends string>(states: readonly S[], initial: NoInfer<S>) {
  return { states, current: initial };
}

const order = createMachine(["pending", "paid", "cancelled"], "shipped");
What npx tsc --noEmit prints
machine.ts:5:63 - error TS2345: Argument of type '"shipped"' is not assignable to parameter of type '"cancelled" | "paid" | "pending"'.

5 const order = createMachine(["pending", "paid", "cancelled"], "shipped");
                                                                ~~~~~~~~~


Found 1 error in machine.ts:5

When a generic function accepts something it should not, ask: which arguments are sources of the type, and which should only be checked against it? Mark the second kind with NoInfer. The same question debugs the first pattern: firstOr's fallback was treated as a check, and the fix made it a source. You can always pass type arguments explicitly (firstOr<number | string>(…)) to see what the function does when inference is taken out of the picture.

"Cannot find module": tracing resolution

TS2307 means the compiler looked for a module and did not find it. --traceResolution prints every step of every lookup; it is long, so filter it to the module you care about. Here it is for the monorepo case above, where @shop/auth had not been built:

Terminal on your computer
npx tsc -p packages/http --noEmit --traceResolution
…
======== Resolving module '@shop/auth' from '~/naija-shop/packages/http/src/app.ts'. ========
Explicitly specified module resolution kind: 'NodeNext'.
Resolving in ESM mode with conditions 'import', 'types', 'node'.
…
Loading module '@shop/auth' from 'node_modules' folder, target file types: TypeScript, JavaScript, Declaration, JSON.
Searching all ancestor node_modules directories for preferred extensions: TypeScript, Declaration.
Directory '~/naija-shop/packages/http/src/node_modules' does not exist, skipping all lookups in it.
Directory '~/naija-shop/packages/http/node_modules' does not exist, skipping all lookups in it.
Directory '~/naija-shop/packages/node_modules' does not exist, skipping all lookups in it.
Found 'package.json' at '~/naija-shop/node_modules/@shop/auth/package.json'.
Entering conditional exports.
Matched 'exports' condition 'types'.
Using 'exports' subpath '.' with target './dist/index.d.ts'.
File '~/naija-shop/node_modules/@shop/auth/dist/index.d.ts' does not exist.
Failed to resolve under condition 'types'.
…
======== Module name '@shop/auth' was not resolved. ========

Read a trace like a story, and stop at the first line that surprises you:

  1. Mode and conditions: ESM mode with import, types, node. If you expected CommonJS, the problem is "type" in package.json or the file extension.
  2. Where it searched: up through node_modules folders. If it never finds the package.json, the package is not installed where this file can see it (a phantom or missing dependency).
  3. Which exports entry and condition matched, and which file that pointed to. Here everything was right until dist/index.d.ts does not exist: the package was never built.

The same three questions explain most TS2307s: wrong mode, not installed, or the exports target is missing (not built, not published, or not listed in files, as in Publishing TypeScript packages). When the trace ends in a .js file with no declaration beside it, you get TS7016 instead, and the fix is a declaration file.

"Why is this file even compiled?"

An orders service fails its type check in a file nobody on the team recognises:

Terminal on your computer
npx tsc --noEmit
scripts/backfill-2024.ts:4:48 - error TS2741: Property 'totalKobo' is missing in type '{ id: string; total: number; }' but required in type 'Order'.

4 for (const row of rows) console.log(orderLabel(row));
                                                 ~~~

  src/orders.ts:3:3 - 'totalKobo' is declared here.
    3   totalKobo: number;
        ~~~~~~~~~


Found 1 error in scripts/backfill-2024.ts:4

A one-off script from two years ago, written against an older Order type. But why is it part of the build? --explainFiles gives the reason for every file in the program:

Terminal on your computer
npx tsc --noEmit --explainFiles
…
src/orders.ts
   Imported via "../src/orders.js" from file 'scripts/backfill-2024.ts'
   Imported via "./orders.js" from file 'src/legacy-report.ts'
   Matched by default include pattern '**/*'
…
scripts/backfill-2024.ts
   Matched by default include pattern '**/*'
   File is ECMAScript module because 'package.json' has field "type" with value "module"
…

"Matched by default include pattern '**/*'": the tsconfig.json has no include, so every .ts file under the folder is compiled, scripts and all. The fix is a decision, not a cast: delete the dead script, fix it, or give the project an explicit "include": ["src", "tests"]. The same command answers "why is this @types package loaded?" (look for Entry point for implicit type library) and "why does this file count as CommonJS?" (the File is … module because lines).

What configuration is actually in effect?

With extends chains, command-line flags and options that switch on other options, the settings you read in one file are rarely the whole story. --showConfig prints the final, merged configuration and the list of files, without compiling:

Terminal on your computer
npx tsc --showConfig -p packages/http
{
    "compilerOptions": {
        "composite": true,
        "declaration": true,
        "declarationMap": true,
        "module": "nodenext",
        "moduleResolution": "nodenext",
        "outDir": "./dist",
        "rootDir": "./src",
        "skipLibCheck": true,
        "strict": true,
        "sourceMap": true,
        "target": "es2024",
        "tsBuildInfoFile": "./dist/.tsbuildinfo",
        "types": [
            "node"
        ],
        "verbatimModuleSyntax": true,
        "moduleDetection": "force",
        "isolatedModules": true,
        "preserveConstEnums": true,
        "incremental": true
    },
…
    "files": [
        "./src/app.ts",
        "./src/index.ts",
        "./src/main.ts"
    ],
…
}

Everything from tsconfig.base.json is merged in, and the last four options were never written anywhere: they are implied by others (verbatimModuleSyntax implies isolatedModules, composite implies incremental, and module: nodenext sets moduleDetection). When a setting "does not work", check here first that it is really set.

Build problems

Some failures come from what is on disk rather than what is in the code. The symptoms and their usual causes:

SymptomUsual causeCheck with
TS2307 for an internal package, only sometimesBuild order: a missing project reference, or a phantom dependencytsc -b --verbose, the boundary check from TypeScript monorepos
A change has no effect when the program runsRunning stale output: dist from an older build, or a stray compiled .js next to the sourceDelete dist and rebuild; look at the timestamps of the files that run
The build "succeeds" and writes nothingA stale .tsbuildinfo that survived a cleanWhere tsBuildInfoFile points (Compiler performance)
Errors in node_modules type filesTwo versions of a type package, or a lib/types mismatch--explainFiles, npm ls @types/node
Works in the editor, fails in CI (or the reverse)Different TypeScript versions, or the editor uses a different tsconfig.jsonnpx tsc -v in both places, --showConfig

For all of them, the most reliable first step is the one CI takes: a clean checkout, a fresh install, and a full build. If the problem disappears, it lived in leftover files; if it stays, you have a reproduction.

Type bug or runtime bug?

The last kind of TypeScript bug has no type error at all. An order comes back from the payments API, and the checkout adds the ₦1,500 delivery fee:

checkout.ts
interface Order {
  id: string;
  totalKobo: number;
}

const DELIVERY_FEE_KOBO = 150_000;

function totalWithDelivery(order: Order): string {
  return `₦${((order.totalKobo + DELIVERY_FEE_KOBO) / 100).toFixed(2)}`;
}

const body = '{"id": "ord_7", "totalKobo": "250000"}';
const order = JSON.parse(body) as Order;
console.log(order.id, totalWithDelivery(order));
Output of npx tsx checkout.ts and of the browser terminal
ord_7 ₦2500001500.00

₦4,000 of goods and delivery became ₦2.5 billion. The type check is clean, the code is "correct", and it is wrong. Debugging this with the type system is hopeless, because the types are exactly what the code claims. Debug it as a runtime problem, following the value:

  1. Observe the value, not the type. Log (or inspect in the debugger, as in Debugging tools) typeof order.totalKobo at the place where it is used. It prints string, while the type says number.
  2. Find where the type was claimed. The value came from outside, so somewhere a type was asserted rather than checked. Look for the escape hatches: as, any, !, @ts-ignore, JSON.parse and hand-written declarations. Here it is JSON.parse(body) as Order.
  3. Replace the claim with a check. Parse the data at the boundary, and fail loudly when it is wrong.
checkout-fixed.ts
interface Order {
  id: string;
  totalKobo: number;
}

function parseOrder(json: string): Order {
  const data: unknown = JSON.parse(json);
  if (typeof data !== "object" || data === null) throw new TypeError("order must be an object");
  const { id, totalKobo } = data as Record<string, unknown>;
  if (typeof id !== "string") throw new TypeError("order.id must be a string");
  if (typeof totalKobo !== "number" || !Number.isSafeInteger(totalKobo)) {
    throw new TypeError(`order.totalKobo must be whole kobo, got ${JSON.stringify(totalKobo)}`);
  }
  return { id, totalKobo };
}

try {
  parseOrder('{"id": "ord_7", "totalKobo": "250000"}');
} catch (error) {
  console.log((error as Error).message);
}
console.log(parseOrder('{"id": "ord_8", "totalKobo": 250000}'));
Output of npx tsx checkout-fixed.ts and of the browser terminal
order.totalKobo must be whole kobo, got "250000"
{ id: 'ord_8', totalKobo: 250000 }

The error now appears where the bad data enters, with the value in the message, instead of as a strange total three functions later. Runtime validation does the same job with schemas. When you review code, you can list the escape hatches mechanically; this small scanner is enough to start a conversation:

hatches.ts
const source = `const order = JSON.parse(body) as Order;
const fee = settings.fee!;
// @ts-ignore legacy formatter
const label = format(order);
function old(x: any) { return x.total; }
const safe = parseOrder(body);`;

const patterns: [string, RegExp][] = [
  ["as", /\bas\s+(?!const\b)[A-Z]\w*/],
  ["non-null !", /\w!(?=[.;,)\s])/],
  ["@ts-ignore", /@ts-ignore/],
  ["any", /:\s*any\b/],
];

source.split("\n").forEach((line, i) => {
  const hits = patterns.filter(([, re]) => re.test(line)).map(([name]) => name);
  if (hits.length) console.log(`line ${i + 1}: ${hits.join(", ")}`);
});
Output of npx tsx hatches.ts and of the browser terminal
line 1: as
line 2: non-null !
line 3: @ts-ignore
line 5: any

Each line it lists is a place where a person told the compiler "trust me". Most are fine; one of them is usually where a runtime bug that "cannot happen" came from. Some teams enforce the same with lint rules (for example banning any and unchecked as outside boundary code).

The reverse case also exists: a type error for a situation that cannot happen at runtime. Resist the as. First try to express the reason it cannot happen in the types (a narrower parameter, a discriminated union, an assertion function that checks), because the next person to change the code will not know the reason, and the compiler will.

In production

  • Annotate at boundaries: exported functions, configuration objects and data tables. Errors then appear at the mistake instead of three calls later.
  • Fix the first error first, re-run, and only then read the rest.
  • Keep the diagnostic commands in reach: --traceResolution for "cannot find module", --explainFiles for "why is this compiled", --showConfig for "which settings apply", tsc -b --verbose for "why was this rebuilt".
  • Treat escape hatches as debt: every as, any, ! and @ts-ignore is a place where the compiler stopped protecting you. Keep them at boundaries, next to a runtime check.
  • Reproduce from clean before blaming TypeScript: fresh install, deleted dist, the same TypeScript version as CI.
  • Pin what you learn: when you debug a surprising type, add a type test for it so the surprise cannot come back.

Practice

TRY IT YOURSELF

Move the error

This configuration produces one TS2322 on the call to startServer, about limits. Without running it, find the real mistake, then rewrite the code so that the compiler reports it on the exact line.

interface Limits { maxOrderKobo: number; currency: "NGN" | "USD" }
interface ServerConfig { port: number; limits: Limits[] }
declare function startServer(config: ServerConfig): void;

const limits = [
  { maxOrderKobo: 50_000_000, currency: "NGN" },
  { maxOrderKobo: 1_000_00, currency: "usd" },
];
startServer({ port: 8080, limits });
Show a solution
limits.ts
interface Limits {
  maxOrderKobo: number;
  currency: "NGN" | "USD";
}

const limits = [
  { maxOrderKobo: 50_000_000, currency: "NGN" },
  { maxOrderKobo: 1_000_00, currency: "usd" },
] satisfies Limits[];
What npx tsc --noEmit prints
limits.ts:8:29 - error TS2820: Type '"usd"' is not assignable to type '"NGN" | "USD"'. Did you mean '"USD"'?

8   { maxOrderKobo: 1_000_00, currency: "usd" },
                              ~~~~~~~~

  limits.ts:3:3 - The expected type comes from property 'currency' which is declared here on type 'Limits'
    3   currency: "NGN" | "USD";
        ~~~~~~~~


Found 1 error in limits.ts:8

The mistake is "usd" in lower case. In the original, limits was inferred with currency: string, so the error could only appear at the call and only about the whole array. satisfies Limits[] checks each element where it is written. (1_000_00 is legal, since numeric separators can go anywhere between digits, but it looks like a typo for 1_000_000; a reviewer should ask.)

TRY IT YOURSELF

Stop the extra state

A helper pickDefault(options, preferred) returns preferred if it is one of the options. With the signature below, pickDefault(["card", "transfer"], "cash") compiles. Change the signature so that it does not, and explain what changed.

function pickDefault<T extends string>(options: readonly T[], preferred: T): T
Show a solution
pick.ts
function pickDefault<T extends string>(options: readonly T[], preferred: NoInfer<T>): T {
  return options.includes(preferred) ? preferred : options[0]!;
}

const method = pickDefault(["card", "transfer"], "cash");
What npx tsc --noEmit prints
pick.ts:5:50 - error TS2345: Argument of type '"cash"' is not assignable to parameter of type '"card" | "transfer"'.

5 const method = pickDefault(["card", "transfer"], "cash");
                                                   ~~~~~~


Found 1 error in pick.ts:5

Before, both arguments were inference sources, so T became "card" | "transfer" | "cash" and "cash" fitted by definition. NoInfer<T> removes preferred from inference: T comes only from options, and preferred is checked against it.

TRY IT YOURSELF

Read the trace

A trace for import { formatNaira } from "@naija-shop/naira" ends like this. What is wrong, and what are two ways it could have happened?

Found 'package.json' at '~/shop-app/node_modules/@naija-shop/naira/package.json'.
Entering conditional exports.
Matched 'exports' condition 'types'.
Using 'exports' subpath '.' with target './dist/index.d.ts'.
File '~/shop-app/node_modules/@naija-shop/naira/dist/index.d.ts' does not exist.
Failed to resolve under condition 'types'.
Show a solution

The package is installed and its exports map is read correctly, but the declaration file it promises is not in the installed package. Either it was never published (the files field left it out, exactly the 1.0.0 release in Publishing TypeScript packages), or, for a workspace package, it was never built (the dist folder does not exist yet). ls node_modules/@naija-shop/naira/dist tells you which: an empty or missing folder in a workspace means "build it"; a folder with only .js files from the registry means "the package is broken; tell its authors, and add a local declaration meanwhile".

Recap

  • An error's position is where types met, not necessarily where the mistake is. Read the elaboration chain from the bottom up, and add annotations or satisfies near the data to move the error to the cause.
  • Fix the first error first: failed imports cascade into nonsense errors further down.
  • Make tsc show a type by assigning it to never; pin what you learn with a type test.
  • Inference problems: the first candidate wins, empty arrays become never[], reduce takes its type from the initial value, and generous inference accepts bad values (fix with NoInfer).
  • --traceResolution explains TS2307, --explainFiles explains why a file is compiled, --showConfig shows the settings really in effect.
  • A runtime bug with a clean type check came in through an escape hatch. Follow the value, find the as or any, and replace the claim with a check.

Next: Compiler performance, where you measure what the compiler spends its time on and keep a large project fast to check.

Test yourself

Five questions, picked at random from this lesson's question bank. Some ask you to choose an answer, some to predict what code prints, and some to write code and run it in the terminal. Get 4 of 5 right to pass. If you don't, read the explanations and try again: you get 5 different questions.