When Type Systems Fail Us: Lessons from Complex JavaScript Applications
We’ve collectively spent years pushing for stronger type safety in JavaScript, and for good reason. In large applications, catching a undefined is not a function
at compile time instead of in production is a godsend. But there’s a trap here, one I’ve seen in multiple large-scale codebases over the years: we can become too reliant on type systems, even in TypeScript, assuming they’ll prevent entire classes of bugs when they can’t.
This post is about those blind spots: where the type system gives a false sense of safety, especially in complex JavaScript applications. Not to bash TypeScript or Flow (or any type system), but to talk about where they don’t help as much as we think, and what we can do about it.
Type Soundness Is Not a Guarantee
At a glance, type safety
feels like a binary switch: either you’re type-safe, or you’re not. But the moment you step into the world of JavaScript, even with TypeScript layered on top, things are fuzzier.
Here’s a simple example:
type User = {
id: string;
name: string;
roles?: string[];
};
function isAdmin(user: User): boolean {
return user.roles.includes("admin");
}
This code compiles cleanly. No issues from the type checker. But of course, user.roles
might be undefined
, and you’ll get a runtime error.
The mistake is subtle: optional fields are easy to overlook, especially when the rest of the codebase implicitly treats them as required. This is compounded in applications where data is coming in from network layers or external APIs.
Types exist, but unless we diligently model nullability and data loading states, the compiler can’t help us.
Schema Drift and the API Layer
In complex apps, your front-end types are often derived from backend contracts: manually, with code generation, or a mix of both. But backends evolve. When they do, the front end’s type system may not complain unless the change is catastrophic.
For instance, a backend starts returning a new union variant in a discriminated union that wasn’t accounted for:
type Event =
| { type: 'click'; timestamp: number }
| { type: 'scroll'; position: number };
// A new event type is added server-side:
| { type: 'hover'; duration: number } // but not reflected in front-end types
Your exhaustive switch
suddenly isn’t exhaustive, but TypeScript thinks it is. No errors, just a logic bug. These types of mismatches, especially in GraphQL or REST-heavy codebases, can slip through even the most disciplined CI systems unless you have schema diff checks.
TypeScript’s Structural Typing Works Against Us Sometimes
Structural typing is great… until it isn’t. In TypeScript, if two types have the same shape, they’re considered compatible. But the shape of a type doesn’t always capture its semantics.
type Email = string;
type Password = string;
function login(email: Email, password: Password) {
// ...
}
login("hunter2", "user@example.com"); // oops
This is one of those classic examples, but in practice, the problem scales up. Think of two different config objects with identical shapes but very different meaning. The compiler won’t stop you from passing one into the other’s function.
Nominal typing systems (like in Rust or Haskell) would prevent this, but TypeScript’s type system doesn’t offer that out of the box. There are workarounds (opaque types, branded types), but they require discipline and aren’t enforced naturally.
Dynamic Data and Runtime Contracts
A common pitfall in JavaScript-heavy applications is handling dynamic data: user-generated content, A/B testing payloads, 3rd-party SDKs. These often come from systems outside your control, and while you can write types for them, you’re still relying on runtime assumptions.
Let’s say you’re rendering a dynamic component from CMS data:
type CMSBlock = {
type: "image" | "text" | "video";
content: any;
};
function renderBlock(block: CMSBlock) {
switch (block.type) {
case "image":
return <ImageBlock {...block.content} />;
case "text":
return <TextBlock {...block.content} />;
case "video":
return <VideoBlock {...block.content} />;
}
}
You’ve typed block
, but block.content
is essentially any
because the schema is too dynamic or too loosely defined. You end up writing runtime guards and fallbacks, because the type system can’t help you here. The types become more of a signal than a guarantee.
This is where runtime validation libraries (e.g., zod
, io-ts
) come in. They help assert shape and structure at runtime, complementing compile-time types. But they’re not always adopted uniformly, and they can be verbose.
Types Can’t Model Application Logic
Type systems model structure, not behavior. They tell you that an object has a status
field, but not what that status means in context.
A real-world example I’ve seen: an Order
object that has both status: 'pending' | 'shipped' | 'delivered'
and a shippingDate: Date | null
.
You might expect that shippingDate
is null
unless status === 'shipped' || status === 'delivered'
, but TypeScript can’t enforce this relationship. It’s a logic invariant, not a type constraint.
You can model this using tagged unions:
type Order =
| { status: "pending"; shippingDate: null }
| { status: "shipped" | "delivered"; shippingDate: Date };
…but now you’re manually encoding every possible state combination. That’s verbose, and in a fast-moving product environment, often abandoned in favor of the simpler version. We trade off type precision for convenience, and that’s understandable, but dangerous.
So What Can We Do?
Some ideas that have helped in teams I’ve worked with:
Runtime validation should be a first-class citizen. Use libraries like
zod
to define schemas once and use them both for parsing and type inference.Tag your types semantically. Branded/opaque types for identifiers, money, email, etc., are tedious but pay off.
Add assertions in strategic places, especially around external boundaries: network responses, CMS content, 3rd-party inputs.
Treat optionality with suspicion. Be explicit about when fields are allowed to be null or undefined, and encode it meaningfully in the types.
Don’t overtrust codegen. Always review the output. Sometimes codegen masks complexity and introduces subtle mismatches.
Final Thoughts
Type systems help catch a lot of bugs, but they don’t catch all the important ones. They model structure, not truth. They guide you, but don’t enforce correctness at runtime. The more complex your app is: integrations, dynamic content, shifting backend contracts, the more likely you’ll encounter these blind spots.
The key isn’t to abandon types, it’s to use them alongside runtime guards, testing, and clear mental models. The moment you treat a type system like a silver bullet, it becomes a liability.
And if nothing else, remember: a green TypeScript build only means your types are consistent, not that your code is correct.