Engineering Diaries · TypeScript
§
TypeScriptRuntime SafetyArchitecture

TypeScript's Biggest Lie: Your Types Vanish the Moment Your Code Ships to Production

We treat TypeScript like a runtime safety net. It is not one. Here is where the illusion breaks down - and what you actually need to protect your production code from the data it never expected.

Abstract code flowing on a digital screen
01 · The False Promise

The Confidence That TypeScript Sells You

I remember the exact moment I became a TypeScript convert. It was 2019, I had just spent three hours tracking down a bug where user.name was undefined because someone had renamed the field to user.fullName in the API response but forgot to update the frontend. TypeScript would have caught that at compile time. I migrated our entire codebase the following sprint.

And for a while, it felt like magic. Red squiggles in the editor. Auto-complete that actually knew the shape of your objects. Refactors that told you exactly what broke. I was sold.

Here is the thing nobody told me: TypeScript's type system is a compile-time illusion. Every single type annotation, every interface, every generic constraint - all of it is erased during compilation. The JavaScript that actually runs in your user's browser has zero memory of the types you so carefully defined. None. It is as if you wrote a detailed safety manual, had it reviewed by three people, and then shredded it before handing the machine to the operator.

This is not a bug. This is by design. TypeScript's own documentation says it clearly: "TypeScript's type system is fully erased at runtime." But somehow, across thousands of codebases and millions of developers, we have collectively decided to pretend this does not matter.

It matters. And I have the production incidents to prove it.

"TypeScript gives you a bulletproof vest for the drive to work, then quietly takes it back before you walk into the building."

02 · The Erasure

What Actually Happens When tsc Runs

Let us look at what TypeScript compilation actually does. Take this perfectly typed function:

TypeScript Source
interface User {
  id: number;
  name: string;
  email: string;
  role: "admin" | "user" | "viewer";
}

function greetUser(user: User): string {
  return `Hello, ${user.name}. You are a ${user.role}.`;
}

After compilation, here is what ships to production:

Compiled JavaScript Output
function greetUser(user) {
  return `Hello, ${user.name}. You are a ${user.role}.`;
}

The entire User interface? Gone. The parameter type annotation? Gone. The return type? Gone. The union type constraining role to three specific strings? Gone.

At runtime, you could call greetUser({ id: "banana", name: 42, email: null, role: "superadmin" }) and JavaScript would happily try to execute it. No error. No warning. Just "Hello, 42. You are a superadmin." - and whatever downstream logic depends on role being one of three values is now silently broken.

"But Divyansh," I hear you say, "the compiler would have caught that call." Sure - if the data came from your own code. But when does production data ever come exclusively from your own code?

The Core Problem TypeScript validates the code you write. It cannot validate the data your code receives. API responses, WebSocket messages, URL parameters, localStorage values, postMessage payloads, form inputs - none of these pass through the TypeScript compiler. They arrive at runtime as raw JavaScript values with no type information whatsoever.
03 · The Boundary Problem

Every API Response Is unknown Whether You Admit It or Not

This is the pattern I see in virtually every TypeScript codebase I review:

The Dangerous Pattern
const response = await fetch("/api/users/123");
const user: User = await response.json();
// TypeScript now "believes" user is a User
// But response.json() returns Promise<any>
// You just told the compiler to trust you. It did.

That type annotation on line 2 is not a validation. It is a declaration of faith. You are telling the compiler: "Trust me, whatever comes back from this endpoint will match the User interface exactly." The compiler believes you. It stops checking. And every line of code downstream that accesses user.role or user.email now operates on an unverified assumption.

I have seen this break in production in ways that would make your skin crawl. A backend team renames a field from email to emailAddress and deploys on a Friday. The frontend TypeScript still compiles perfectly because the type assertion was never validated. The UI renders "undefined" where the email should be. Nobody notices until Monday because the field is below the fold on mobile.

Or worse: a backend returns null for a field your TypeScript interface marks as required. No crash, no error - just silent data corruption as null flows through your component tree and eventually gets written back to a different API as a legitimate value.

The fetch API returns Promise<any> from its .json() method. Not unknown. Not a generic. any. That is TypeScript's own standard library telling you: "I have no idea what this data looks like, and I am not going to pretend I do." But we override that honesty with a type assertion and move on.

73%
of TS codebases
use unvalidated
API type casts
0
runtime checks
added by the
TS compiler
any
return type of
response.json()
4.2x
more API bugs in
unvalidated TS vs
validated codebases
04 · The as Escape Hatch

Type Assertions: The Keyword That Silently Disables Your Safety Net

If unvalidated API responses are TypeScript's biggest blind spot, the as keyword is the tool developers use to make that blind spot permanent.

Type Assertions in the Wild
// "I know better than the compiler"
const config = JSON.parse(rawString) as AppConfig;

// "This definitely exists"
const element = document.getElementById("root") as HTMLDivElement;

// "The API always returns this shape"
const data = event.data as WebSocketMessage;

// "I'll fix the types later" (narrator: they did not)
const result = something as any as WhatIActuallyWant;

Every single as is a promise from the developer to the compiler. "I know the runtime shape of this value better than you do." And the compiler, being a compile-time tool that genuinely cannot check runtime values, has no choice but to believe you.

The double assertion on that last line - as any as WhatIActuallyWant - is the TypeScript equivalent of covering the check engine light with tape. You are not fixing the type mismatch. You are telling the compiler to stop telling you about it.

I ran a quick analysis across five production codebases at my previous company. The average was 127 type assertions per 10,000 lines of TypeScript. That is 127 places where a developer said "trust me" and the compiler stopped verifying. Some of those assertions were legitimate - narrowing a union type after a runtime check, for instance. But over 60% were what I call "faith assertions" - casting external data to an internal type without any validation.

Each faith assertion is a potential production bug that TypeScript cannot catch because you explicitly told it not to look.

A Simple Lint Rule That Helps

Add @typescript-eslint/no-explicit-any and @typescript-eslint/no-non-null-assertion to your ESLint config. They will not catch as SomeType assertions, but they close the two most common escape hatches. For as assertions specifically, consider @typescript-eslint/consistent-type-assertions with the objectLiteralTypeAssertions: "never" option.

These rules will not solve the problem. But they will make the problem visible, and visibility is the first step.

05 · The Danger Zones

Seven Places Where Your Types Are Lying to You Right Now

Beyond API responses and type assertions, there is a whole catalogue of runtime boundaries where TypeScript's guarantees silently evaporate. I have been bitten by every single one of these in production:

  1. localStorage and sessionStorage. You call JSON.parse(localStorage.getItem("settings")!) and cast it to your Settings type. But the user might have an old schema stored from before your last migration. Or the value might be null. Or another tab might have written a different shape entirely.
  2. URL query parameters. searchParams.get("page") returns a string | null. You parse it as a number and pass it to your pagination component. But what if someone types ?page=banana in the URL bar? parseInt("banana") returns NaN, and NaN propagates silently through arithmetic.
  3. postMessage between windows or iframes. The event.data payload is literally any. A malicious or buggy iframe can send you any shape of data it wants. Your TypeScript interface on the receiving end is pure decoration.
  4. WebSocket messages. Same problem as fetch, but worse - WebSocket connections are long-lived, and the server might start sending a different message format after a deployment while your client is still running the old code.
  5. Third-party script injections. Analytics libraries, A/B testing tools, and ad scripts modify the DOM and sometimes the global scope. Your TypeScript has no idea they exist, let alone what they might do to the objects you thought you controlled.
  6. Dynamic imports and code splitting. When you use import() dynamically, the module you load at runtime might not match the type you declared at compile time - especially during deployments where old HTML pages reference new chunk hashes that no longer exist.
  7. Environment variables. process.env.API_URL is typed as string | undefined, but in practice you have probably asserted it to string somewhere. If the env var is missing in one environment but present in another, your types will not save you.

Every single one of these is a boundary where data enters your application from outside the TypeScript compiler's jurisdiction. And at every single one, your types are a polite suggestion that the runtime is free to ignore.

06 · The Actual Solution

Runtime Validation: What Actually Protects Your Production Code

The fix is not to abandon TypeScript. TypeScript is genuinely excellent at what it does - catching mistakes in the code you write. The fix is to stop pretending it does something it was never designed to do: validate external data at runtime.

For that, you need a runtime validation library. The three serious options in 2026 are Zod, Valibot, and ArkType. I have used all three in production. Here is what the pattern looks like with Zod, which has the largest ecosystem:

Zod - Runtime Validation That Generates Types
import { z } from "zod";

// Define the schema ONCE - it is both the validator AND the type
const UserSchema = z.object({
  id: z.number(),
  name: z.string().min(1),
  email: z.string().email(),
  role: z.enum(["admin", "user", "viewer"]),
});

// Derive the TypeScript type FROM the schema
type User = z.infer<typeof UserSchema>;

// Now fetch with actual runtime safety
async function fetchUser(id: number): Promise<User> {
  const response = await fetch(`/api/users/${id}`);
  const json = await response.json();
  return UserSchema.parse(json); // throws if shape doesn't match
}

The critical shift here is that the type is derived from the schema, not the other way around. You are not writing a type and then hoping the data matches. You are defining what the data must look like, validating it at runtime, and letting TypeScript infer the type from that definition. The type and the validation are the same thing. They cannot drift apart.

When the backend team renames email to emailAddress, the Zod .parse() call throws immediately. You get a clear error message, a stack trace, and the exact field that failed. Not a silent undefined that propagates for three days before someone notices.

Performance Note "But runtime validation adds overhead!" Yes. Zod parsing a typical API response adds 0.1 - 0.5ms. If your API call takes 200ms, you are adding 0.25% overhead for the guarantee that your data matches what your code expects. If you think that trade-off is not worth it, I genuinely want to know what production incident would change your mind.
07 · Architecture

The Validation Architecture: Where to Draw the Line

You should not validate everywhere. That defeats the purpose of having a type system in the first place. The principle is simple: validate at the boundaries, trust internally.

Your application has a perimeter. Outside that perimeter, data is untrusted - it comes from APIs, users, storage, or other systems. Inside that perimeter, data has been validated and TypeScript's compile-time guarantees are sufficient. The validation layer sits exactly at the boundary.

  1. API client layer. Every function that calls an external API should validate the response before returning it to the rest of the application. This is the single highest-impact place to add validation. Do this first.
  2. Form input handlers. Before form data enters your state management, validate it. Libraries like React Hook Form integrate with Zod natively via their resolver API - use it.
  3. Storage reads. Whenever you read from localStorage, sessionStorage, IndexedDB, or cookies, validate the data. Old schemas, corrupt data, and cross-tab writes are real problems.
  4. Message handlers. postMessage, WebSocket onmessage, BroadcastChannel - any inter-context communication boundary needs validation.
  5. Environment configuration. Validate your env vars at application startup. If a required variable is missing, fail loudly at boot time, not silently at request time.

Inside these boundaries, you do not need Zod. You do not need runtime checks. TypeScript's compile-time types are sufficient because you have already guaranteed the data matches. This is the correct mental model: TypeScript for internal code, runtime validation for external data.

The Gateway Pattern

I structure my API layer as a set of "gateway" modules - each one responsible for a single external service. The gateway fetches, validates with Zod, and returns typed data. Every other module in the application imports from gateways, never from fetch directly. This gives you exactly one place to update when an API changes, exactly one place where validation happens, and zero type assertions anywhere else in the codebase.

It sounds obvious, but I have reviewed dozens of codebases where fetch calls with type assertions are scattered across 40+ components. Centralising them is the single biggest improvement you can make.

08 · The @types Problem

When Third-Party Type Definitions Lie to You

There is another class of type-safety illusion that gets far less attention: @types packages that do not accurately describe the library they claim to type.

The DefinitelyTyped repository is maintained by the community, not by library authors. When a library ships a new version with breaking changes, there is often a gap - sometimes days, sometimes weeks - before the corresponding @types package is updated. During that gap, your TypeScript is compiling against types that describe a version of the library you are no longer running.

I hit this with a popular date formatting library last year. The library changed the signature of a function from accepting Date | number to accepting only Date. The @types package still declared the old signature. My code passed numbers to the function, TypeScript said it was fine, and the function threw at runtime because it called .getTime() on a number.

The fix took two minutes once I knew the problem. Finding the problem took four hours because TypeScript was actively pointing me away from it. The types said the call was valid. The types were wrong.

This is getting better as more libraries ship their own type definitions rather than relying on DefinitelyTyped. But if you are using @types/* packages, be aware that they are maintained by volunteers with varying levels of attention and that they can and do fall behind.

"The most dangerous type error is the one your type checker says does not exist."

09 · The any Epidemic

How Teams Accidentally Destroy Their Own Type Safety

any is contagious. That is not a metaphor - it is literally how TypeScript's type system works. When a value is typed as any, every operation on it also returns any. Access a property? any. Call a method? any. Pass it to a function? The function's return type becomes any if it depends on the input.

How any Spreads Through Your Codebase
function processData(input: any) {
  const name = input.user.name;     // name is any
  const upper = name.toUpperCase(); // upper is any
  const len = upper.length;         // len is any
  return len > 5;                   // return type is boolean... maybe
}

// Every caller now operates on unverified data
const result = processData(apiResponse);
// result is boolean, but is it really?
// What if input.user was undefined?
// What if name was a number?

I once audited a 200,000-line TypeScript codebase and found that 11% of all typed values were any - either explicitly declared or implicitly inherited. That is not "using TypeScript." That is paying the TypeScript tax (slower builds, more verbose syntax, steeper learning curve) without getting the TypeScript benefit.

The most common sources of any infection I see:

Error handlers. The catch clause parameter is unknown since TypeScript 4.4, but older patterns and habits still produce any. Libraries that type their error callbacks as any spread it further.

Event handlers. Custom event systems often type their payload as any because the system handles multiple event types. One any payload infects every handler.

Migration shortcuts. When migrating from JavaScript to TypeScript, teams often use any as a "we'll fix this later" placeholder. Three years later, those placeholders are still there, and the code around them has grown to depend on the permissiveness.

11%
of typed values
were any in one
200k-line codebase
127
type assertions
per 10k lines
(avg across 5 repos)
3
operations before
any infects a
return type
10 · Credit Where Due

What TypeScript Is Genuinely Excellent At

I realise this article might read like a TypeScript hit piece. It is not. I use TypeScript in every project. I advocate for it in every team I join. I would not go back to plain JavaScript for any non-trivial codebase.

TypeScript is brilliant at:

Catching refactoring mistakes. Rename a property across 50 files and TypeScript tells you exactly which callsites need updating. This alone saves hours per week in any active codebase.

Documenting intent. A well-typed function signature tells you what it expects and what it returns without reading the implementation. Types are documentation that the compiler keeps honest.

Enabling tooling. Auto-complete, go-to-definition, find-all-references - these IDE features work because TypeScript understands the shape of your code. Try navigating a 100-file JavaScript project without them.

Catching logic errors in internal code. When all the data flowing through your application has been validated at the boundaries, TypeScript catches genuine mistakes - passing a string where a number is expected, accessing a property that does not exist on the type, forgetting to handle a case in a discriminated union.

TypeScript is a phenomenal tool for the job it was designed to do. The problem is not TypeScript. The problem is that we have collectively expanded our expectations of what it does to include something it explicitly does not do: runtime data validation.

"TypeScript is not a runtime safety net. It is a compile-time reasoning engine. Once you internalise that distinction, you stop being surprised when production breaks despite your green build."

11 · The Playbook

A Practical Checklist for Closing the Runtime Gap

If this article has convinced you that your codebase might have unvalidated boundaries, here is the playbook I use when inheriting or auditing a TypeScript project:

  1. Audit your type assertions. Search for as in your codebase (note the trailing space). Every result is a place where someone told the compiler to stop checking. Categorise them: legitimate narrowing, or faith assertion? Fix the faith assertions first.
  2. Find every fetch/axios call. If any of them cast the response to a type without validation, that is your highest-priority fix. Add Zod schemas for your most critical API endpoints first - user data, payment data, auth tokens.
  3. Enable strict mode. If your tsconfig.json does not have "strict": true, you are running TypeScript with the safety features turned off. Enable it. Fix the errors. It will hurt for a sprint. It will save you for years.
  4. Add noUncheckedIndexedAccess. This single compiler flag makes array and object index access return T | undefined instead of T. It catches an entire category of "cannot read property of undefined" runtime errors.
  5. Centralise your API layer. Move all external data fetching into gateway modules that validate responses. No component should call fetch directly.
  6. Validate at startup. Environment variables, feature flags, configuration files - validate all of them when the application boots. If something is wrong, fail immediately with a clear error, not ten minutes later when a user hits the broken code path.

You do not need to do all of this at once. Start with your most critical data paths - authentication, payments, user profiles - and expand outward. Every boundary you validate is one fewer production incident waiting to happen.

12 · The Bottom Line

Your Types Are a Conversation With the Compiler, Not a Contract With Reality

TypeScript's type system is a conversation between you and the compiler. You describe the shapes you intend to use, and the compiler checks that your code is internally consistent with those descriptions. That conversation is enormously valuable. It catches bugs, enables tooling, and makes large codebases manageable.

But it is a conversation that ends at compile time. The moment tsc finishes and your JavaScript ships, those shapes exist only in your source files and your build logs. The runtime does not know about them. The data flowing into your application from APIs, users, and external systems does not know about them. And when that data does not match what your types promised, JavaScript will not throw a type error. It will do something worse: it will keep running, silently, with the wrong data, until the damage surfaces somewhere you did not expect.

The fix is not more types. It is not stricter generics or cleverer utility types. The fix is accepting that TypeScript's jurisdiction ends at the compiler, and that the gap between compile time and runtime needs to be bridged by actual runtime validation at every boundary where untrusted data enters your application.

Your types are excellent. Your runtime guarantees might not be. Close the gap.

· · ·
Tools Referenced

TypeScript Zod Valibot ArkType ESLint React Hook Form

This article reflects patterns observed across multiple production codebases over five years of TypeScript development. Statistics cited (type assertion density, any prevalence) are from internal audits - your mileage may vary, but the directional trends are consistent across the industry.

TypeScript is a trademark of Microsoft Corporation. All library names referenced are trademarks of their respective maintainers. The opinions in this article are based on production experience and do not represent the views of any employer past or present.