How to read TypeScript you did not write
Every earlier module taught you to write TypeScript. This module teaches the other half of the job: reading TypeScript someone — or something — else wrote, and deciding whether the types are telling the truth. An AI assistant hands you code that compiles with zero errors, looks exactly like the idiomatic examples you have been studying, and is still wrong. tsc --strict reporting no errors is not the same as correct — it only means nothing in the file contradicts itself, which a well-placed as can guarantee no matter what the code actually does. The four habits below are the order experienced TypeScript reviewers actually read code in, and every later lesson in this module drills one of them.
Read the types first, and distrust every escape hatch
Section titled “Read the types first, and distrust every escape hatch”Before reading a single line of logic, read the signatures: what does each function accept, what does it return, what is optional, what is a union? Then, before trusting any of it, find every place the code tells the checker “trust me” instead of proving it:
as X— a type assertion. It does not check anything at runtime; it tellstscto stop checking and believe the annotation.JSON.parse(raw) as Usercompiles whether or notrawis actually aUser.any— not “unknown,” but “stop checking, and stop checking everything downstream too.” Oneanyat a boundary can erase type safety through several function calls before it hits something that finally crashes.!— the non-null assertion. It tells the checker “this is nevernull/undefinedhere,” with no runtime check backing that claim.@ts-ignore(and an un-coded@ts-expect-error) — silences whatever error was about to appear on the next line, without fixing it or even naming which error it expected.
None of these are inherently wrong — sometimes you really do know more than the checker. The point of reading them first is that each one is a place the type and the reality are allowed to diverge, and that is exactly where AI-generated code hides its mistakes: the code compiles, so it looks reviewed, but an assertion is a claim, not a proof.
Follow the boundaries
Section titled “Follow the boundaries”A boundary is any place data enters your program from somewhere the type system cannot see: JSON.parse, fetch(...).json(), process.env, localStorage.getItem, a database driver’s row type, a webhook body. TypeScript’s type for the boundary function itself is often deliberately weak (JSON.parse returns any) or simply a promise the runtime does not enforce (a .d.ts for an HTTP client claiming a response shape it never validates). Reading a boundary well means asking, for each one:
- Is the value actually validated against the type it is annotated or asserted as, or is the annotation just decoration?
- If it is
as-cast, what happens if the real data is missing a field the type promises? - Does an optional field (
user?.email) get used later without a check, past the point where the boundary’s own optionality should have been narrowed away?
Follow the promises
Section titled “Follow the promises”Every async function call returns a Promise, and TypeScript’s type system tracks the shape of that promise perfectly while saying nothing about whether anyone actually waits for it:
- Is a promise-returning call missing its
await? The call still runs, but the caller moves on before it settles — a floating promise with no compile error to show for it. - Does
Promise.all([...])operate on a tuple written inline (which keeps each position’s own type), or on an array built first and passed by reference (which often widens to a union, quietly losing which position was which)? - Is a rejected promise ever caught, or does it surface as an “unhandled rejection” the first time this code path actually fails?
Count the suppressions
Section titled “Count the suppressions”Before judging a file’s overall quality, count every as, every any, every !, every @ts-ignore/@ts-expect-error, and every place a broad type (Record<string, any>, object) stands in for a real shape. That number is a direct estimate of how much of the file’s apparent type safety is real versus asserted. A file with zero suppressions that compiles under --strict has actually been checked, end to end. A file with fifteen as casts compiled just as cleanly — and proves almost nothing.
Zero type errors, one runtime crash
interface Order { id: number; total: number; customer?: { email: string } }
async function loadOrder(raw: string): Promise<Order> { return JSON.parse(raw) as Order; // boundary: trusts the JSON shape completely}
async function billCustomer(raw: string): Promise<void> { const order = await loadOrder(raw); const email = order.customer!.email; // non-null assertion: trusts the field is always there console.log(`billing ${email} for ${order.total}`);}
billCustomer('{"id": 1, "total": 42}'); // no customer in this payload Click Check types above: zero errors, on --strict. Click Run: a TypeError on order.customer!.email, because the payload genuinely has no customer. The as at the boundary and the ! at the point of use are the two places this file was allowed to lie, and both of them did.
Mental Model
Section titled “Mental Model”flowchart TD A["Read the code"] --> B["Read every signature -- then find every as / any / ! / @ts-ignore"] B --> C["At every boundary (JSON.parse, fetch, env, storage): is the value actually validated?"] C --> D["Follow every promise: awaited? Promise.all on a tuple or a widened array? rejections caught?"] D --> E["Count the suppressions -- that count estimates how much of the file was really checked"] E --> F["Only now: judge correctness"]
Reading order for TypeScript you did not write:
1. Signatures + escape hatches -> every as / any / ! / @ts-ignore is a place types and reality may diverge2. Boundaries -> JSON.parse / fetch / env / storage: validated, or just annotated?3. Promises -> awaited? Promise.all on a tuple, or an array that widened? rejections caught?4. Suppression count -> how much of this file's "0 errors" is proven versus asserted?
A clean `tsc --strict` run answers none of these on its own.Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
| Reading signatures and escape hatches first | Finds every place the file is allowed to lie before you trust its logic | Requires knowing as/any/!/@ts-ignore by sight, not just by definition |
| Following boundaries explicitly | Catches the gap between “annotated as User” and “validated as User” | Slower than assuming a .d.ts or a cast is accurate |
| Tracing every promise | Finds floating promises and widened Promise.all tuples before they surface as flaky failures | Requires holding async control flow in your head while reading |
| Counting suppressions | Turns “does this feel safe” into a number you can compare across files or PRs | A raw count has no severity weighting — one any at a security boundary outweighs ten stylistic as const casts |
Common Misconceptions
Section titled “Common Misconceptions”- “It compiled under
--strict, so it’s correct.” Every pattern in this module’s catalog compiles under--strict— that is exactly what makes each one dangerous.--strictcatches contradictions, not assertions. - “
asand!are basically the same as a type annotation.” An annotation (const x: User = ...) is still checked against the initializer.asand!both turn checking off for that expression. - “If a human wrote it, it doesn’t need this scrutiny; only AI code does.” Every pattern in this module’s catalog also appears in human-written TypeScript — AI just produces it at a much higher and more confident-looking volume, with comments that make the suppression look deliberate.
- “Reading the return type is reading the function.” A function’s declared return type is a claim about its own body, not about what boundary data it trusted to get there.
Reading AI-generated code
Section titled “Reading AI-generated code”When a reviewer opens an AI-generated diff, this module’s order becomes a checklist:
- Grep the diff for
as,any,!./!;/!),@ts-,JSON.parse,.json()— every hit is a place to slow down. - For each hit, ask: is this assertion backed by an actual runtime check nearby, or is it the only thing standing between this line and a crash?
- For every
asyncfunction called in the diff, confirm the call siteawaits it (or deliberately doesn’t, with a comment saying why). - If the diff’s
tsc --strictoutput is clean, treat that as “no contradictions,” not “no bugs.”
🐛 Spot the bug
interface Config { retries?: number; endpoint: string }
function buildRequest(raw: unknown): Config {return raw as Config;}
function send(raw: unknown): void {const config = buildRequest(raw);console.log(`sending to ${config.endpoint.toUpperCase()} with ${config.retries} retries`);}
send({ retries: 3 });Show the answer
raw as Config compiles no matter what raw actually is — there is no runtime check here at all, just an assertion. The call send({ retries: 3 }) passes an object with no endpoint field; config.endpoint is undefined, and .toUpperCase() throws TypeError: Cannot read properties of undefined (reading 'toUpperCase'). tsc --strict reports zero errors on this file — the boundary was never actually validated, only asserted.