You spend an entire afternoon adding types to your project. Every variable has an interface, every function has explicit return types, and your edi...
For further actions, you may consider blocking this person and/or reporting abuse
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!
The type erasure section hit home for me — my first real production crash in a TS app was exactly the
as ApiResponsepattern: the backend wrapped the payload in adatafield one day, and everything downstream silently readundefined. Since then I've treatedunknownas the default at system boundaries and pushed all validation into one small parse layer, which made the rest of the codebase boring in the best possible way. One addition: enablingexactOptionalPropertyTypesplusnoUncheckedIndexedAccesscaught a surprising number of the "cannot read properties of undefined" crashes in our codebase that narrowing alone missed.That’s a really good real-world example of why the type erasure point matters. "unknown" at system boundaries is a great habit because it forces the validation step to be explicit instead of letting a type assertion quietly create false confidence.
And yes, "exactOptionalPropertyTypes" + "noUncheckedIndexedAccess" is a great addition. They catch a different class of “this should exist” assumptions that normal narrowing can easily miss. I like the way you described the result as “boring in the best possible way” because that’s exactly what good boundary validation should make the rest of the code feel like. 😄
Great breakdown! The excess property check explanation cleared up something. The never exhaustive check is a trick every TypeScript team should adopt.
Thank you! Glad the excess property check part helped. The never-based exhaustive check is one of those small patterns that becomes really valuable as a codebase grows, especially when new union cases are added later. It turns a silent omission into something the compiler can point out immediately. 🙌
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!
Great article. The key lesson is that TypeScript provides compile-time safety, not runtime validation.
Thanks 👍
nice typescript breakdown
Thank you! Glad you enjoyed the breakdown. I wanted to focus less on just TypeScript syntax and more on what actually happens when typed code meets untrusted runtime data. 🙌
nice typescript breakdown
Thanks 🙂
AI is single-player. Real work is multiplayer" is one of the hardest-hitting value propositions I've read all sprint. The friction of copy-pasting LLM responses back and forth into Slack or Teams just to get code reviews or design feedback is a massive context-switching tax. Creating a shared execution space where agents read the same files and mail as the teammates totally optimizes the team's overall throughput. Smashed it with this concept, congrats on the launch!
Thank you! And I completely agree about the context-switching tax. The interesting part isn't just having multiple AI agents, but giving them a shared execution space where they can work from the same context instead of turning every interaction into another copy-paste cycle.
That shift from “AI as a single-player tool” to something that actually participates in the team workflow is where I think the idea gets really interesting. Really appreciate the kind words! 🙌
Heavy agree, Tahosin. Treating AI like a single-player tool means the human is stuck doing all the tedious input-output pipeline routing. 💀 The constant Ctrl+C / Ctrl+V loop between Slack, your IDE scratchpad, and terminal outputs is a massive drain on focus bandwidth. The moment you compile an architecture where multiple agents natively read the same PRDs, file streams, and system logs alongside the human team, you completely break that isolation bottleneck. It’s like shifting from running isolated local scripts to deploying a fully coordinated, multi-node cloud orchestration layout.
Exactly. That “input-output pipeline routing” is the part that gets overlooked. Once the human becomes the bridge between every agent, tool, file, and communication channel, the AI may be fast but the overall workflow still isn't.
The real win is when the coordination itself becomes part of the system instead of another manual task for the human. That's the direction I'm most interested in exploring. Appreciate you pushing the idea further! 🙌