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 (15)
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 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
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!
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!
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!
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.
The part about trusting TypeScript at runtime is probably one of the easiest mistakes to make when you're starting out. It’s tempting to think that once something is typed as a string or an interface, the runtime will somehow enforce it too. The distinction between compile-time safety and validating data coming from APIs or users is something that really clicks only after you’ve seen it cause a real bug.
Absolutely. I think this is one of those concepts that is easy to understand theoretically but much harder to truly internalize until you see a real runtime bug caused by it. When you’re starting with TypeScript, it’s natural to assume that declaring something as "string" or "User" somehow makes the runtime enforce it. Understanding the difference between compile-time checking and runtime validation is probably one of the biggest mental shifts when moving from “using TypeScript” to actually understanding how TypeScript works. Thanks for sharing this!
Some comments may only be visible to logged-in visitors. Sign in to view all comments.