You spend an entire afternoon adding types to your project. Every variable has an interface, every function has explicit return types, and your editor is completely free of red squiggly lines. You feel invincible.
You deploy to production.
Twenty minutes later, your error tracker pings you with this:
TypeError: Cannot read properties of undefined (reading 'toUpperCase')
Wait. How? You wrote TypeScript! Wasn't TypeScript supposed to make this exact error impossible?
If this has happened to you, welcome to the club. Almost every developer goes through a phase where TypeScript feels like an aggressive back-seat driver that yells at you constantly, yet somehow lets actual bugs slip right into production.
The problem is rarely your code. The problem is almost always your mental model.
Most beginners treat TypeScript like Java or C# bolted onto JavaScript. But TypeScript does not work like any traditional language you have used before. Today, we are going to look at how TypeScript actually thinks, why your types disappear before your app even boots, and how to stop fighting the compiler once and for all.
1. The Phantom Type System: Type Erasure
Here is the single most important truth about TypeScript:
TypeScript does not exist when your code runs.
When you run tsc (the TypeScript compiler), it does two completely separate jobs:
- It checks your code for type errors.
- It completely strips away every single
type,interface, and type annotation, spitting out plain JavaScript.
Look closely at what happens after compilation. Those beautiful interfaces you designed? Gone. The custom generic constraints? Vanished.
At runtime in Node.js or in Chrome's V8 engine, your computer is running raw JavaScript. The runtime has zero memory of what types you wrote.
The Classic Beginner Trap
Because beginners think types exist at runtime, they often write code like this:
interface User {
id: number;
name: string;
}
function processResponse(data: unknown) {
// ❌ RUNTIME ERROR: 'User' only refers to a type,
// but is being used as a value here.
if (data instanceof User) {
console.log(data.name);
}
}
JavaScript's instanceof operator checks prototypes of actual objects in memory. But User was erased during compilation! It left no trace in JavaScript, so instanceof User makes zero sense to the runtime.
Why Bad Data Crashes in Production
This also explains why external data breaks your app:
interface ApiResponse {
username: string;
}
// You tell TypeScript: "Trust me, the API returns an ApiResponse"
const response = await fetch('/api/user');
const user = (await response.json()) as ApiResponse;
// If the backend sent { error: "User not found" }, this explodes!
console.log(user.username.toUpperCase());
TypeScript trusted your annotation at compile time. But TypeScript cannot monitor the network pipe. If the backend returns null or { error: 500 }, your code will crash at runtime.
Rule of thumb: TypeScript validates what you write, not what the outside world sends you. For external boundaries (APIs, localStorage, user input), always use runtime validators like Zod or custom type guard functions.
2. Shape Over Name: Structural Typing
If you come from Java, C#, or C++, this next concept will blow your mind.
In traditional languages, type systems are nominal. A type's identity is determined by its explicit name or declaration.
In TypeScript, the type system is structural (often called compile-time duck typing). A type's identity is determined exclusively by its internal shape.
Let's look at a concrete example:
type Vector2D = {
x: number;
y: number;
};
type Point2D = {
x: number;
y: number;
};
const point: Point2D = { x: 10, y: 20 };
const vector: Vector2D = point; // ✅ 100% Valid!
In Java or C#, assigning a Point2D to a Vector2D without an explicit cast would throw a compile-time error. They have different names, so they are different types.
In TypeScript, the compiler checks the blueprint:
- Does
pointhave anxof type number? Yes. - Does
pointhave ayof type number? Yes. - Done. They are completely interchangeable.
The "Excess Property" Surprise
Here is a puzzle that confuses almost everyone when they start:
type Options = {
timeout: number;
};
function startServer(opts: Options) {
console.log(`Starting with timeout: ${opts.timeout}`);
}
// Case A: Passing an object literal directly
// ❌ ERROR: Object literal may only specify known properties,
// and 'port' does not exist in type 'Options'.
startServer({ timeout: 5000, port: 8080 });
// Case B: Passing an existing variable reference
const myConfig = { timeout: 5000, port: 8080 };
startServer(myConfig); // ✅ Valid! No errors!
Why does Case A fail while Case B succeeds when both pass the exact same properties?
Here is why: TypeScript enforces Excess Property Checks specifically on fresh object literals.
When you write an inline literal { timeout: 5000, port: 8080 }, TypeScript assumes you made a typo (like typing timeouut instead of timeout), so it strictly flags any extra fields.
But when you assign it to an intermediate variable myConfig, TypeScript switches back to pure structural typing. Because myConfig satisfies the contract of having timeout: number, TypeScript permits it.
Understanding this difference saves you hours of head-scratching.
3. The Type Spectrum: any vs unknown vs never
When developers struggle with a stubborn type error, the easiest temptation is reaching for any.
// The "I give up" button
const user: any = fetchUserData();
Using any does not solve your type problem. It simply tells the compiler: "Stop doing your job. Turn off safety for this variable and everything it touches."
It spreads like a virus. Once one variable is any, any function calling it loses autocomplete and validation.
Instead, modern TypeScript gives you a clean spectrum of tools:
1. unknown: The Safe Top Type
Whenever you truly do not know what a value is (like an API response, user input, or parsed JSON), use unknown instead of any.
unknown accepts any value, but refuses to let you do anything with it until you prove what it is:
function parsePayload(input: unknown) {
// ❌ ERROR: 'input' is of type 'unknown'.
// console.log(input.trim());
// ✅ Safe: We prove it is a string first (Type Narrowing)
if (typeof input === 'string') {
console.log(input.trim());
}
}
2. never: The Exhaustive Bottom Type
never represents a state that should mathematically never happen. It is your secret weapon for making sure you never forget a case in complex conditional logic:
type Action =
| { type: 'LOGIN'; username: string }
| { type: 'LOGOUT' }
| { type: 'SIGNUP'; email: string };
function handleAction(action: Action) {
switch (action.type) {
case 'LOGIN':
return `Welcome, ${action.username}`;
case 'LOGOUT':
return 'Goodbye';
case 'SIGNUP':
return `Signed up with ${action.email}`;
default: {
// If someone adds a new action to Action and forgets
// to add a case here, this line will NOT compile!
const _exhaustiveCheck: never = action;
return _exhaustiveCheck;
}
}
}
If tomorrow another developer adds { type: 'RESET_PASSWORD' } to Action, the TypeScript compiler will immediately flag an error inside handleAction before the code ever reaches testing.
4. Kill Optional Flag Hell with Discriminated Unions
Look at this common pattern for managing network request state:
// The "Everything Might Exist" anti-pattern
type RequestState = {
isLoading: boolean;
data?: UserData;
error?: string;
};
This looks innocent, but mathematically, this type allows 8 different combinations:
-
isLoading: true,data: user,error: "Failed" -
isLoading: false,data: undefined,error: undefined
None of these combinations make sense in real life! Yet your code has to defensively check every single property with if (state.data && !state.isLoading && !state.error).
Instead, model your domain with a Discriminated Union:
type RequestState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: UserData }
| { status: 'error'; error: string };
function renderUI(state: RequestState) {
switch (state.status) {
case 'loading':
return '<Spinner />';
case 'error':
// TypeScript knows 'error' exists here!
return `<ErrorMessage text="${state.error}" />`;
case 'success':
// TypeScript guarantees 'data' exists here!
return `<UserProfile user="${state.data.name}" />`;
case 'idle':
return '<WelcomePrompt />';
}
}
By adding a single literal string tag (status), impossible states become unrepresentable in your code. TypeScript narrows the object automatically inside each branch.
5. Stop Using "as" (Use "satisfies" Instead)
In older TypeScript codebases, you will see the as keyword everywhere:
type Theme = 'light' | 'dark';
type Palette = Record<Theme, string>;
// The "as" type assertion (A polite lie to the compiler)
const colors = {
light: '#ffffff',
dark: '#121212',
} as Palette;
// No autocomplete for exact color strings!
colors.light; // Type is just 'string', not '#ffffff'
When you use as, you force the compiler to accept your declaration. If you mistyped a hex code or omitted a required key, as will often mask the problem.
In modern TypeScript (v4.9 and above), use the satisfies operator instead:
type Theme = 'light' | 'dark';
type Palette = Record<Theme, string>;
const colors = {
light: '#ffffff',
dark: '#121212',
} satisfies Palette;
// 1. Validates that 'colors' matches Palette shape
// 2. Retains exact literal precision!
// colors.light has type '#ffffff', not generic string!
satisfies gives you the best of both worlds: it validates that your object conforms to a contract without discarding the specific literal types and properties of your data.
The Mental Shift That Changes Everything
Once you internalize these five core concepts:
- Types are completely erased at runtime. Validate external boundaries with tools like Zod or custom guards.
- TypeScript checks shapes, not names. Embrace structural compatibility instead of fighting it.
-
Avoid
any. Useunknownto enforce safety andneverto catch unhandled edge cases. - Use Discriminated Unions. Make illegal states mathematically impossible to represent.
-
Prefer
satisfiesoveras. Catch real bugs while preserving literal precision.
Suddenly, you are no longer treating TypeScript like an adversary. The constant fight with the compiler stops, and it transforms into the sharpest pair programmer you have ever had.
Looking back at my own journey, the hardest transition was unlearning the habit of trusting types at runtime. It took one painful Friday night deployment outage for that lesson to truly stick.
I am curious: what was the specific TypeScript error or mental model shift that took you the longest to wrap your head around? Have you ever had a runtime bug slip through because of type erasure? Share your battle stories or favorite type patterns below. Hearing how other engineers untangled their mental models is always one of the best ways we all level up.




Top comments (45)
Very important explanation about TypeScript! I created a JS/TS/TSX terminal with online mentor and I can immediatley able to check your example in TiyF. Can I invite as contributor that project? Because the AI mentor knowledge in this moment is very limited, and I think you will be fine for improving.
Thank you Peter! Really glad you found the examples useful. And yes, absolutely, I’d be happy to contribute to the project. Being able to test these examples directly in a JS/TS/TSX environment with an AI mentor sounds like a really interesting idea. I’d love to see how TiyF works and help improve the TypeScript-related knowledge where I can. Thanks for the invitation!
The
as ApiResponseexample is the one that keeps showing up in real stack traces. A cast is a promise to the compiler, not a check, so the crash lands a few functions away from the fetch and the trace points attoUpperCaseinstead of the boundary that let bad data in. Parsing at the edge with something likeschema.safeParse(await res.json())and failing loudly there gives you an error that names the actual problem. One small addition:noUncheckedIndexedAccesscatches a good share of the "cannot read properties of undefined" crashes that come from arrays and records rather than API responses.Yes, that’s a really good point about where the error actually appears. The dangerous part of "as ApiResponse" is not just that it can be wrong, but that the wrong assumption can travel through several functions before finally exploding somewhere like "toUpperCase()". Validating at the boundary makes the failure much more meaningful because you know exactly where the bad data entered the system. And "noUncheckedIndexedAccess" is definitely another useful layer for catching a different class of "undefined" bugs. Great addition!
Yes, and the distance hurts in error tracking too. The stack trace groups on the
toUpperCaseframe, so five different bad payloads look like one bug. When the parse fails at the boundary you get one error per endpoint with the actual field name in the message, which is way easier to triage. Do you parse every response, or only the ones from services you don't own?The
as ApiResponseexample is probably the one I've seen most in real projects. TypeScript just trusts you there, so the API can return basically anything 😄I've had
nullcome back where an object was expected, fields change without warning, and TS still happily tells you everything is fine.That's why I prefer validating it once when it comes in. Then the rest of the code can just assume the data is actually what the types say it is.
Absolutely, this is exactly the kind of issue I had in mind with the
as ApiResponseexample. The compiler trusts the assertion, but the runtime data can still be completely different from what we expect. I really like the approach of validating once at the boundary and then letting the rest of the application work with trusted data. It keeps the validation logic in one place and makes the rest of the code much easier to reason about. Thanks for sharing the real-world experience!Really good explanation, especially the part about "unknown" vs "any" and how TypeScript types disappear at runtime. That’s an easy thing to forget when working with API responses. The discriminated union example is also a great practical pattern.
Thank you! "unknown" vs "any" was one of the concepts I really wanted to highlight because they can look similar when you first encounter them, but they lead to very different levels of safety. And the runtime type erasure part is especially important when dealing with API responses. I’m also glad the discriminated union example stood out. It’s one of those patterns that looks simple but can make application state much easier to reason about.
Yeah, exactly. I think "unknown" is one of those things that makes more sense once you actually run into a real production bug. And I agree on discriminated unions - a small change in how you model state can make the rest of the code much cleaner.
Exactly! Some TypeScript features really click only after you’ve seen what happens when the assumptions are wrong at runtime. 😄 And that’s probably what I like most about discriminated unions: a small improvement in the model can remove a surprising amount of complexity from the code around it. Appreciate you sharing your experience!
Yeah, totally agree 😄 I really enjoyed your post. You seem like someone I’d enjoy working with, so I’d be happy to stay in touch and maybe build something together sometime.
Small thing in the
instanceofexample: that one never reaches runtime. Using an interface as a value fails intscwith "'User' only refers to a type, but is being used as a value here", which is the message your own code comment quotes, so the RUNTIME ERROR label undersells how early TypeScript catches it. It makes a nice contrast with theas ApiResponseexample right below: one mistake the compiler blocks because the type has no runtime value, the other it waves through because you told it to trust you. The fix for the first, a type guard like'name' in data, is also the first step toward the runtime validation the section ends on.Great catch! You're absolutely right 😅 TypeScript catches the interface misuse at compile time, while
as ApiResponsebypasses the check and can fail at runtime. That contrast actually makes the point even stronger: knowing what the compiler rejects versus what it blindly trusts is crucial. Thanks for pointing out the labeling issue!The compile-time vs runtime boundary is the part that tends to cause the most expensive TypeScript bugs. A type can prove that your code is internally consistent, but it can't prove that the value crossing an API, queue, database, or third-party boundary actually matches that contract.
One nuance I'd add is that runtime validation shouldn't stop at external HTTP requests. Internal services can drift too, especially when schemas evolve independently. Treating the boundary as
unknown → validated domain typemakes that contract explicit and prevents an invalid value from propagating deep into the system.The point about
asis important for the same reason: an assertion doesn't make the value safer; it only makes the compiler stop questioning it.satisfies, discriminated unions, and exhaustive checks are much more useful because they preserve the compiler as a verification tool rather than bypassing it.Absolutely. The internal-service point is especially important because “internal” doesn't automatically mean “trusted.” Once multiple services evolve independently, schema drift can create the same kind of runtime failures we normally associate with external APIs.
I really like the way you framed it as unknown → validated domain type. That makes the trust boundary explicit and keeps the rest of the system working with data that has actually earned its type.
And yes, that's exactly why I wanted to distinguish assertions from tools like satisfies and discriminated unions. The goal isn't just to make TypeScript accept the code, but to keep the compiler involved in verifying our assumptions. Great addition!
This is the classic 'Type Safety Illusion' trap. Developers forget that TypeScript is strictly a compile-time linter. It vanishes at runtime. If you're blindly casting API responses with as MyInterface without running a validation layer like Zod or ArkType, you're just shifting the runtime crash down the execution stack. TS guarantees type compliance for your code, not for the untrusted data entering your system infrastructure.
Exactly. “Type Safety Illusion” is a great way to describe it. TypeScript can give us a lot of confidence inside our own code, but that confidence shouldn’t automatically extend to data coming from outside the application. "as MyInterface" can make the code look strongly typed while the actual runtime value is still completely untrusted. Putting a validation layer such as Zod or ArkType at that boundary makes the mental model much more honest. Really appreciate the comment!
as MyInterface" makes the code look strongly typed while the actual runtime value is still completely untrusted. This is the exact core of the issue, Tahosin. It’s basically code-level gaslighting. Adding a parser layer like Zod at the network boundaries changes the architecture from "guessing" to "guaranteeing". It makes your TypeScript definitions mean something because they are backed by deterministic runtime assertions. Absolutely spot on addition to the point!
Haha, “code-level gaslighting” is actually a pretty accurate description. 😄 The dangerous part is that everything looks trustworthy when you're reading the TypeScript, so the failure only becomes visible much later when the real runtime data doesn't match the assumption.
That’s why I like thinking of the parser as the point where untrusted data earns the right to become a domain type. After that boundary, the TypeScript types aren't pretending to guarantee reality, they're describing data that has already been checked. Appreciate you taking the idea one step further!
Spot on. Types don't exist in the JavaScript runtime environment. If your system boundaries aren't strictly checked and sanitized at the API layer, static types are just expensive, high-effort documentation. Great write-up for beginners.
Exactly. That’s probably the most important mental model behind the whole article. TypeScript can make our assumptions very clear to the compiler, but those types disappear when the code actually runs. So if the API boundary is not trusted, the types can give us confidence without actually guaranteeing anything about the incoming data. Runtime validation is what closes that gap. Really appreciate you adding this point to the discussion!****
Totally agree, Tahosin. Running code in production based entirely on type assertions without runtime validation layer is pure masochism. 💀 TypeScript acts like a fancy compliance auditor during development, but the moment an API response schema shifts or drops a missing property, the JavaScript runtime environment throws a hard TypeError and crashes your thread. Moving that strict structural checking to the boundary ingress saves crazy amounts of debugging overhead in Sentry. Glad that mental model resonated with you!
Haha, “pure masochism” might be a little harsh, but I can't really argue with the debugging pain. 😂 The important distinction for me is that validation at the ingress boundary doesn't just prevent crashes, it also localizes the uncertainty. Once the data passes that boundary, the rest of the application can work with a known shape instead of every layer having to defend itself against malformed data.
That shift alone can make production debugging much less painful. Appreciate you taking the discussion further!
The part about
asis probably the one I would emphasize the most.A type assertion can make a boundary look safer than it actually is. Once
response.json()is asserted asApiResponse, the rest of the code reads as if the contract has already been verified, even though nothing checked the payload at runtime.That’s why I tend to think of
unknownas more than just a safer alternative toany. It makes the trust boundary visible in the code and forces validation or narrowing before the data enters the typed part of the application.The interesting part is that this also changes where you put the complexity. Instead of spreading defensive checks throughout the application, you can validate once at the boundary and keep the internal domain model strongly typed.
For production systems, that separation between untrusted input and trusted application state is probably more important than simply having strict TypeScript enabled.
Exactly. I think "as" is one of those TypeScript features that feels helpful at first, but can quietly hide the actual trust boundary. I really like your point about using "unknown" to make that boundary visible. Once the data is validated at the edge, the rest of the application becomes much easier to reason about instead of having defensive checks scattered everywhere. That separation between untrusted input and trusted domain state is something I’ve started appreciating more as well. Thanks for the thoughtful comment!
The RequestState example highlights one additional product decision: whether a failed refresh should hide data the user already had. For a dashboard I'd often keep the last good result visible and model refreshing and refresh_error as explicit states carrying that data. The union still rules out accidental combinations, but the allowed states now reflect the actual UI. I'd test the sequence success -> refresh -> failure, not only the first fetch.
I really like this distinction. My example was mainly focused on making impossible states unrepresentable, but real UI state often needs to preserve previous data while a refresh is happening. In that case, modeling something like "refreshing" or "refresh_error" while carrying the last successful data makes much more sense. The success → refresh → failure sequence is also a great testing scenario because that’s where a lot of these state-modeling decisions become visible. Thanks for taking the example one step further!
Some comments may only be visible to logged-in visitors. Sign in to view all comments.