You trust the green check. tsc passes. Your tests pass. You ship on Friday. Then Monday a Cannot read properties of undefined (reading 'name') lands in your error tracker, on a line the type system called safe. Nothing in your code changed. An API you consume changed one field, and TypeScript never noticed.
Here is the whole article in one picture:

I ship apps that consume data from other people's APIs. Here is the shape of a bug I have watched happen more than once. One response field's type said number. Then a vendor shipped an update and started returning it as a string, sometimes null. My code did the obvious thing: const profile = await res.json() as Profile. Every type looked right in the editor. The build passed. And a screen that read profile.score.toFixed(1) started crashing for a slice of users, because score now arrived as a string and strings have no toFixed. No compiler error. No test failure. Just a runtime explosion at the exact spot TypeScript promised was safe.
That bug isn't special. It's the single most common way TypeScript lets you down, and it has one root cause.
Why your types disappear
TypeScript is a compile-time tool. When you run tsc, it checks your types and then throws them away. Transpilers like esbuild, swc, and Babel skip the check entirely; they just strip the types out. Interfaces, type aliases, and annotations emit zero runtime code. What ships to production is plain JavaScript with no memory that types ever existed.
Watch what happens to a simple file:
// user.ts (what you wrote)
interface User {
id: number
name: string
}
function greet(user: User) {
return `Hi ${user.name}`
}
// user.js (what actually runs)
function greet(user) {
return `Hi ${user.name}`;
}
The interface User is gone. Not minified, not hidden. Gone. At runtime there is no User, no id: number check, nothing that inspects the shape of user. (Enums and classes are the exception; they emit real JavaScript. Interfaces and type aliases never do.)
So a type annotation is a note to the compiler, not a guard on your data. That distinction is where production breaks.
The lie hides at the boundary
Inside code you fully control, TypeScript is excellent. It tracks types across functions and catches real mistakes. The danger starts where data crosses into your program from a place the compiler cannot see.
Every one of these hands you any or an unchecked value:
const data = await res.json() // any
const parsed = JSON.parse(str) // any
const saved = localStorage.getItem('user') // string | null
const key = process.env.API_KEY // string | undefined
any is the real problem. It's the one type that turns the checker off. Assign any to a typed variable and TypeScript stops complaining, because any is assignable to everything:
interface Profile {
score: number
}
const profile: Profile = await res.json() // no error, ever
console.log(profile.score.toFixed(1)) // crashes if score is a string
The cast version is worse, because it looks deliberate and careful:
const profile = (await res.json()) as Profile
as isn't a conversion. It doesn't check anything. It's you telling the compiler "trust me, this is a Profile," and the compiler obeying without looking. If you're wrong, nobody finds out until a user hits the broken screen.
The fix: parse, don't cast
Don't tell the compiler what the data is. Prove it at runtime, at the moment it enters. Use a schema validator (Zod, Valibot, and ArkType all do this). Derive your static type from that schema, so you have one source of truth instead of two that drift apart.
import { z } from 'zod'
const Profile = z.object({
id: z.number(),
name: z.string(),
score: z.number(),
})
type Profile = z.infer<typeof Profile> // the static type, generated from the schema
const profile = Profile.parse(await res.json())
// profile is now a real Profile, checked field by field
console.log(profile.score.toFixed(1)) // safe
If the API returns score as a string, .parse() throws immediately, at the boundary, with a precise message naming the field that was wrong. The bug surfaces where it enters, not three screens later in code that looks innocent.
When you don't want to throw, use safeParse and handle the failure like any other bad input:
const result = Profile.safeParse(await res.json())
if (!result.success) {
logBadResponse(result.error) // you decide: retry, default, alert
return
}
const profile = result.data // typed and trusted
The rule is three words: parse, don't cast. A cast asserts. A parse verifies. Only one of them survives contact with real data.
Harden the rest of your boundaries
The API response is the obvious hole. The same class of bug hides in quieter places.
localStorage returns whatever a past release wrote, which may not match this release's shape:
const raw = localStorage.getItem('settings')
// Settings is a Zod schema you defined, same idea as Profile above
const settings = Settings.parse(JSON.parse(raw ?? '{}'))
Environment variables are strings or undefined, never the numbers and booleans you treat them as. Validate them once, at startup, so a missing key fails on boot instead of at 3am:
const Env = z.object({
PORT: z.coerce.number(),
API_KEY: z.string().min(1),
})
const env = Env.parse(process.env) // crashes on boot if misconfigured
Two tsconfig flags close the remaining gaps. strict turns on the checks that matter. noUncheckedIndexedAccess stops TypeScript from lying about array and record access:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true
}
}
Without that second flag, arr[10] is typed as your element type even when the array holds three items. With it, the type becomes T | undefined and the compiler makes you handle the empty case. One line of config kills a whole family of "undefined is not a function" crashes.
One last habit: prefer unknown over any at every edge. unknown also accepts anything, but it refuses to let you touch the value until you narrow it, so the compiler pushes you toward validating instead of assuming.

The takeaway
TypeScript guarantees consistency inside the boundary it can see. It guarantees nothing about data crossing into that boundary from an API, a file, storage, or a user. Your types are a compile-time contract, and the compiler tears the contract up before your code runs.
So draw the line yourself. Validate at every edge with a schema, generate your types from that schema, and trust your types everywhere inside. Parse, don't cast. Do that, and the green check finally means what you always assumed it meant.
What's the worst runtime type bug that walked straight past tsc for you? Mine is the one up top: a vendor quietly flipping a number field to a string. Tell me yours in the comments.
I write these from real work at astraedus.dev, where I build apps and tools. Building something, or stuck on something like this? Reach me at astraedus.dev or [email protected].
Get the next one in your inbox → subscribe at astraedus.dev.