Skip to content

How the Checker Thinks

Every earlier module in this course taught you to use the type system: write a type, narrow a union, constrain a generic, read the resulting error. That’s enough to write correct TypeScript by hand. It is not enough to review TypeScript you didn’t write — and increasingly, most of the TypeScript in front of you was generated by a tool, not typed by a person who was thinking about assignability while they wrote it.

An AI-written function compiles for one of two very different reasons: because the types are actually sound, or because the checker’s rules happen to have a gap the generated code fell into by accident. any leaking silently through a boundary, a type predicate that lies, a generic parameter that only ever appears in the return position — all of these compile with zero errors, and all of them look, to someone who only knows the vocabulary of TypeScript, exactly like correct code. Telling them apart requires knowing what the checker actually did, not just what symbols appear in the signature.

This module is that model. It covers the same four questions in the same order the compiler asks them:

flowchart LR
  parse["Parse
syntax -> AST"] --> bind["Bind
names -> symbols"]
  bind --> check["Check
assignability, inference, narrowing"]
  check --> emit["Emit
AST -> JavaScript, .d.ts"]
Four questions, asked in order, on every file

Parse turns source text into a tree, purely by grammar — this is where a missing paren or an unbalanced brace lives, before the checker has any concept of types at all. Bind walks that tree once, connecting every name (a variable, a type, a class) to the symbol it refers to — this is what lets two interface Foo { ... } declarations in the same scope merge into one symbol with combined members, and what lets a function be called before its declaration appears further down the file. Check is everything this course has taught so far: is this assignable to that, what does T resolve to here, is this branch reachable, did this typeof check narrow the union. Emit turns the (by-then fully checked) AST into JavaScript and, if you asked for it, into .d.ts files — and it turns out that not everything in TypeScript’s grammar survives that step the same way.

Four lessons cover check in depth — Assignability (what “compatible” actually means), Inference (how a type argument gets picked without you writing it), and Control-Flow Analysis (how narrowing is really a graph problem) — because that’s where nearly all of the “why did the compiler accept this” and “why did the compiler reject this” questions in generated code actually live. A fifth, Erasure & Emit, covers the emit step and the specific, enumerable list of syntax that needs it. The last, Performance Model, is about check from a different angle: what makes it slow, and how to measure that instead of guessing.

What a diagnostic code tells you before you even read the message

Section titled “What a diagnostic code tells you before you even read the message”

Every tsc error starts with TS followed by a number, and the leading digit is not decoration — it tells you which stage of the pipeline above produced it, before you’ve read a single word of the message:

RangeStageSample, verified on 7.0.2
TS1xxxParse — the source isn’t even grammatically valid TypeScriptTS1110: Type expected. (from const x: = 1;)
TS2xxxCheck — semantic errors: assignability, missing properties, wrong argument count. The overwhelming majority of everyday errors live hereTS2322: Type 'number' is not assignable to type 'string'.
TS4xxxEmit, specifically declaration (.d.ts) emit — a type in a public signature can’t be printed the way the declaration file needs itTS4094: Property 'secret' of exported anonymous class type may not be private or protected.
TS5xxxOptions — the command line or tsconfig.json itself is wrong, before any source file is even readTS5023: Unknown compiler option '--bogusFlagDoesNotExist'.
TS6xxxCommand-line / suggestion diagnostics — not exactly errors about your code’s correctness, more about the project’s hygiene (unused locals, a file outside rootDir)TS6133: 'unused' is declared but its value is never read. (needs noUnusedLocals)
TS7xxxnoImplicitAny / strict-family and reachability checks — a case strict (or one of its sub-flags) specifically exists to catchTS7006: Parameter 'x' implicitly has an 'any' type., TS7027: Unreachable code detected.

None of this is guesswork — every code and message above is the real output of tsc --noEmit on TypeScript 7.0.2 for the trigger noted, produced by compiling the smallest possible file that hits it. TS9xxx is one further family this module comes back to in Erasure & Emit: diagnostics specific to isolatedDeclarations, a narrower and newer check than ordinary declaration emit.

The practical use of this table: before you even parse the message text, the leading digit already tells you whether the fix belongs in your source code (TS2xxx, most of TS7xxx), in what you’re exporting (TS4xxx), in your build configuration (TS5xxx, most of TS6xxx), or in code that is not even syntactically valid TypeScript (TS1xxx) — four very different places to go looking, collapsed by habit into “ugh, a tsc error.”

Same shape of bug, different diagnostic family

// @expect-error TS2322
// TS2xxx -- Check stage: the value's type doesn't match the annotation.
// Click "Check types" above to see the real diagnostic the checker reports.
const port: number = "8080";
// Try deleting ": unknown" below to also see a TS7xxx (noImplicitAny)
// diagnostic reported alongside it -- a different family, same click.
function handler(req: unknown) {
return req;
}
Open in TS Playground
flowchart TD
  text["Source text"] --> parse["Parse: grammar only
(TS1xxx lives here)"]
  parse --> bind["Bind: one pass, names to symbols"]
  bind --> check["Check: assignability, inference, narrowing
(TS2xxx, TS7xxx live here)"]
  check --> emit["Emit: JS + .d.ts
(TS4xxx, TS9xxx live here)"]
  config["tsconfig.json / CLI flags"] -.->|read once, before parse| parse
  config -.->|invalid options: TS5xxx| bad["Never reaches parse at all"]
Each stage only sees what the previous stage produced

The reason this pipeline is worth holding in your head, rather than treating tsc as one opaque “check my code” button, is that each stage’s failures have a completely different shape and a completely different fix. A parse error means the grammar is broken — no type information was ever consulted, so “but I annotated it correctly” is not a relevant defense. A check error means the grammar was fine but the meaning doesn’t line up — this is where nearly everything else in this course lives, and where nearly all of an AI tool’s actually-interesting mistakes live too, because a generator that has seen enough valid TypeScript syntax rarely produces TS1xxx garbage; it produces plausible-looking code that fails at the semantic layer, or worse, doesn’t fail at all because the semantic layer has a documented gap. An emit error means checking succeeded but the specific artifact you asked for (usually a .d.ts file) can’t be produced from what checking approved — a category most people never see until they try to publish a library.

Approach to reading a compiler errorBenefitCost
Read only the message textFast, works for the majority of everyday TS2xxx errorsMisses what the leading digit already told you about where the fix belongs, and misreads TS4xxx/TS5xxx/TS6xxx as “the same kind of bug” as TS2xxx
Know the pipeline (parse → bind → check → emit) and the digit rangesImmediately narrows the search: is this my code, my exports, or my config?One more thing to learn before the shortcut pays off — worthless for a single one-off error, valuable the moment you’re reviewing someone else’s (or an AI’s) TypeScript at volume
Assume “it compiled” means “it’s correct”Zero extra effortExactly the gap this whole module exists to close — assignability, inference, and narrowing all have well-known, checker-legal ways to compile something unsound
  • A tsc error is a tsc error — the number after TS is just an ID. The leading digit encodes which pipeline stage produced it, and that changes where the fix belongs: TS5xxx means your config is wrong before any source file was even read; TS4xxx means your code type-checked fine but can’t be declared; only TS2xxx/TS7xxx mean “fix this expression.”
  • If it compiles, the checker verified it’s correct. The checker verifies it’s consistent with the rules of assignability, inference, and narrowing — which, as the next three lessons show in detail, have specific, well-documented places where “compiles” and “correct” come apart on purpose (structural typing, bivariant methods, optimistic narrowing across await).
  • Emit is just “the compiler writing out the same code with types removed.” For most syntax that’s true (see Erasure & Emit) — but a short, specific list of TypeScript syntax needs the compiler to generate different runtime code, not merely delete annotations, and that’s exactly the syntax that breaks under a type-stripping runtime.

Before you evaluate a single type in a generated file, place its errors (if any) in the pipeline: a TS1xxx means don’t bother reading the types yet, the file doesn’t parse; a TS5xxx/TS6xxx means look at tsconfig.json or the build command, not the source; a TS4xxx means the code itself is fine but its declared, public API can’t be printed as-is. Only TS2xxx and most of TS7xxx are about the logic you’re actually reviewing — and this module’s next three lessons exist precisely because most of an AI tool’s interesting mistakes produce zero diagnostic in any family, which no amount of digit-reading will surface. That gap — code that’s wrong and still compiles clean — is what the rest of this module is for.

🐛 Spot the bug

interface User { id: string; name: string }
// Looks like a type-safe fetch helper.
async function getUser(id: string): Promise<User> {
const res = await fetch(`/api/users/${id}`);
return res.json();
}
async function run() {
const user = await getUser("42");
console.log(user.name.toUpperCase());
}
Show the answer

This compiles with zero diagnostics in every family from the table above — no TS1xxx, no TS2xxx, nothing. res.json() returns Promise<any>, and any is assignable to the function’s declared Promise<User> return type with no verification that the response actually has an id and a name. The function signature reads exactly like a real guarantee, but nothing in the parse/bind/check/emit pipeline ever checked the shape of what the server sends back — only that any is compatible with User, which it always is. If the endpoint’s real response is { id: "42" } with no name, user.name.toUpperCase() throws at runtime, in code that passed every stage of the checker clean. This exact failure mode — a type assigned at a boundary the checker cannot see past — is the subject of the Assignability lesson’s section on any.

What are the four pipeline stages every TypeScript file goes through?
A file produces TS5023: Unknown compiler option --bogusFlagDoesNotExist. What does the leading 5 tell you before reading further?
A generated function is declared Promise<User>, but its body is return res.json(); with no validation. Which pipeline stage would need to exist for this to be caught, and does TypeScript 7.0.2 have it?
Which diagnostic family is specifically about declaration (.d.ts) emit, not the correctness of your code's logic?
Under strict, what family does the resulting diagnostic belong to, and why?
function handler(req) {
  return req.method;
}
Compiling usey.ts with --declaration (verified on 7.0.2) reports TS4094: Property 'secret' of exported anonymous class type may not be private or protected. If InternalOnly were also exported from liby.ts, does the same code still error?
// liby.ts (private class NOT exported -- only the factory is)
class InternalOnly { private secret = 1; }
export function make() { return new InternalOnly(); }

// usey.ts
import { make } from "./liby";
export function reExport() {
  return { wrapped: make() };
}