open atlas
↑ Back to track
TypeScript type system, deep TS · 08 · 05

Common pitfalls: traps that compile clean and bite later

The senior pitfalls roundup: `as`/`!` hiding bugs, `any` leaks, structural typing letting wrong objects through, `Object.keys` returning `string[]`, array covariance, mutation invalidating narrowing, forgotten `as const`. For each: the symptom, why it compiles, the fix.

TS Senior ◷ 15 min
Level
FoundationsJuniorMiddleSenior

Every trap in this lesson has one thing in common: it compiles clean. The build is green, the PR is approved, the types look airtight — and then a userId gets passed where an orderId was expected, a narrowed value is undefined two lines later, or Object.keys hands you a loop variable you can’t index with. These are the bugs that survive review precisely because the type checker said yes. A senior’s edge isn’t knowing more syntax; it’s knowing where the checker’s “yes” is unsound or imprecise, and reaching for the right fix on reflex.

The roundup table

Each row is a trap that passes the compiler. Read it as: symptom, why it slips through, and the reflex fix.

PitfallWhy it compilesThe fix
as hides a real mismatchA cast asserts a type with no runtime check; the compiler trusts youValidate at boundaries (zod); reserve as for knowledge the compiler truly lacks
! non-null abuse! erases null/undefined from the type with no proofNarrow with a real check (if (x)), or fix the type to not include null
any leak from a libany is assignable to and from everything; it spreads silentlyType lib boundaries as unknown and validate; turn on noImplicitAny
Structural typing lets wrong IDs throughUserId and OrderId are both string — structurally identicalBrand the type: string & { __brand: "UserId" }
Object.keys(o) is string[]Extra runtime keys could exist, so keyof would be unsoundCast deliberately: Object.keys(o) as (keyof typeof o)[] (you accept the risk)
Array covariance unsoundnessDog[] is assignable to Animal[], but pushing a Cat then breaks itAccept readonly T[] in APIs so callers can’t mutate
Mutation invalidates narrowingA narrowed let/property can change between check and useUse const, copy to a local, or re-check after any call that could mutate
as const forgottenWithout it, { x: 1 } widens to { x: number }, literals to their base typeAdd as const to keep literal types and readonly tuples

Branding: defeating structural typing on purpose

Unit 01 taught that TypeScript is structural — two types with the same shape are interchangeable. Usually a feature; here a hazard. type UserId = string and type OrderId = string are the same type, so passing one where the other is wanted compiles. Branding adds a phantom property that exists only in the type:

type UserId = string & { readonly __brand: "UserId" };
type OrderId = string & { readonly __brand: "OrderId" };

function getUser(id: UserId) { /* ... */ }
const order = "ord_1" as OrderId;

// getUser(order); // Error: 'OrderId' is not assignable to 'UserId'
//   the __brand discriminates them even though both are string at runtime
const user = "usr_1" as UserId;
getUser(user); // ok

The __brand never exists at runtime (it’s string underneath), but the type now distinguishes the two, so a mixed-up ID is a compile error. This is the standard fix for “structural typing let the wrong primitive through.”

Array covariance: the canonical unsoundness

TypeScript deliberately treats arrays as covariantDog[] is assignable to Animal[] — for ergonomics. But arrays are mutable, which makes this unsound:

const dogs: Dog[] = [new Dog()];
const animals: Animal[] = dogs; // allowed — covariance
animals.push(new Cat());        // compiles! Animal[] accepts a Cat
dogs[1].bark();                 // runtime crash — it's a Cat, no bark()

The compiler accepted every line, yet dogs now contains a Cat. The fix in API design: accept readonly Animal[] when you only read, so a caller can’t smuggle a Cat into someone’s Dog[].

Why this works

Why did TS choose an unsound rule? Pure soundness would forbid Dog[] where Animal[] is expected, breaking enormous amounts of reasonable read-only code (you pass a Dog[] to a function that just iterates Animal[]). The team traded a narrow unsoundness — only triggered by mutation through the widened reference — for everyday ergonomics. readonly arrays restore soundness exactly by removing the mutation that breaks it.

Mutation invalidates narrowing

Narrowing (unit 02) is only valid until the value can change. A mutable binding or property narrowed in an if can be reset by any intervening call the compiler can’t see through:

function f(box: { v: string | null }) {
  if (box.v !== null) {
    sideEffect();        // could set box.v = null — compiler assumes it didn't
    box.v.toUpperCase(); // ^? string  — but might throw at runtime
  }
}

The compiler narrows box.v to string inside the if and keeps that assumption across sideEffect(), because it can’t prove the call left box.v alone. The fix: copy to a const local immediately after the check (const v = box.v;) so the narrowed value can’t be mutated out from under you.

Quiz

`const animals: Animal[] = dogs` (where dogs is Dog[]) compiles, then `animals.push(new Cat())` also compiles and corrupts dogs. Why does TypeScript allow this?

Order the steps

Order the diagnosis-to-fix workflow for a bug that compiled clean but crashed at runtime:

  1. 1 Symptom: a runtime crash on code the type checker approved
  2. 2 Locate the unsound or imprecise spot: an as/! escape hatch, covariance, a mutation, or a structural collision
  3. 3 Explain WHY it compiled — which guarantee TS gave up (or never had) at that line
  4. 4 Apply the reflex fix: validate, brand, use readonly/const, or add as const
  5. 5 Add a guardrail (lint rule, stricter flag) so the same class of bug can't recur
Recall before you leave
  1. 01
    Why is branding needed, and how does `string & { __brand: 'UserId' }` work without affecting runtime?
  2. 02
    Explain array covariance unsoundness and the readonly fix.
  3. 03
    Why can mutation invalidate a narrowing, and what is the reflex fix; also why is Object.keys typed string[]?
Recap

You can now name the senior pitfalls on sight — as/! escapes, any leaks, structural ID collisions, Object.keys widening, array covariance, mutation-broken narrowing, forgotten as const — and for each you know the symptom, why it compiled, and the reflex fix (validate, brand, readonly/const, deliberate casts). That closes the real-world unit: you can type the messy boundaries of real systems, consume them safely with zod and tRPC, design library APIs that infer well, and spot the traps that compile clean. Everything now converges in the typed-feature capstone in unit 09, where you build one end-to-end feature applying all of it at once. Now when you see a green build on a PR that passes a raw string ID into a function expecting a different kind of ID, you know the structural collision it hides — and you reach for a brand before approving.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 7 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.