๐ง๐๐ฝ๐ฒ๐ฆ๐ฐ๐ฟ๐ถ๐ฝ๐ ๐๐ถ๐๐ฐ๐ฟ๐ถ๐บ๐ถ๐ป๐ฎ๐๐ฒ๐ฑ ๐จ๐ป๐ถ๐ผ๐ป๐
I once spent three hours debugging a single TypeScript error.
The error said a property did not exist on a type. My logic was correct. The type checker failed because I used a wide union. I was checking the wrong field on the wrong object shape.
Discriminated unions fixed this for me.
Most developers write code like this:
type APIResponse = | { data: User[]; error: null } | { data: null; error: string }
This looks fine. But it relies on truthiness to narrow types. If data is an empty array, truthiness fails. If error is null in one place but undefined in another, your code breaks at runtime.
The fix is one shared field. You call it kind, type, or status. Every variant must have this field with a unique string value.
type APIResponse = | { kind: "success"; data: User[] } | { kind: "error"; statusCode: number; message: string } | { kind: "loading" }
Now TypeScript knows exactly which object you have when you check that one field. Use a switch statement to handle every case.
Benefits of this pattern:
- Every branch is fully typed.
- You avoid truthiness traps.
- Adding a new state triggers a compiler error if you forget to handle it.
- Changing one variant does not break the others.
I use this for API state, form state, and navigation. It replaces messy if statements with clear logic.
The cost of writing these types is low. The time you save from runtime bugs is high.
Next time you write a union, add a literal string field. Your switch statements will work better.