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.
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.
| Pitfall | Why it compiles | The fix |
|---|---|---|
as hides a real mismatch | A cast asserts a type with no runtime check; the compiler trusts you | Validate at boundaries (zod); reserve as for knowledge the compiler truly lacks |
! non-null abuse | ! erases null/undefined from the type with no proof | Narrow with a real check (if (x)), or fix the type to not include null |
any leak from a lib | any is assignable to and from everything; it spreads silently | Type lib boundaries as unknown and validate; turn on noImplicitAny |
| Structural typing lets wrong IDs through | UserId and OrderId are both string — structurally identical | Brand the type: string & { __brand: "UserId" } |
Object.keys(o) is string[] | Extra runtime keys could exist, so keyof would be unsound | Cast deliberately: Object.keys(o) as (keyof typeof o)[] (you accept the risk) |
| Array covariance unsoundness | Dog[] is assignable to Animal[], but pushing a Cat then breaks it | Accept readonly T[] in APIs so callers can’t mutate |
| Mutation invalidates narrowing | A narrowed let/property can change between check and use | Use const, copy to a local, or re-check after any call that could mutate |
as const forgotten | Without it, { x: 1 } widens to { x: number }, literals to their base type | Add 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); // okThe __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 covariant — Dog[] 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.
`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 diagnosis-to-fix workflow for a bug that compiled clean but crashed at runtime:
- 1 Symptom: a runtime crash on code the type checker approved
- 2 Locate the unsound or imprecise spot: an as/! escape hatch, covariance, a mutation, or a structural collision
- 3 Explain WHY it compiled — which guarantee TS gave up (or never had) at that line
- 4 Apply the reflex fix: validate, brand, use readonly/const, or add as const
- 5 Add a guardrail (lint rule, stricter flag) so the same class of bug can't recur
- 01Why is branding needed, and how does `string & { __brand: 'UserId' }` work without affecting runtime?
- 02Explain array covariance unsoundness and the readonly fix.
- 03Why can mutation invalidate a narrowing, and what is the reflex fix; also why is Object.keys typed string[]?
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.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.