TypeScript
Completed
-
Section 1: Getting Started
-
Section 2: Basic Types
-
Section 3: Functions and Objects
-
Section 4: Advanced Types
-
Section 5: Object-Oriented TypeScript
-
Section 6: Working with Modules
-
Section 7: Tooling and Practice
-
Section 8: Type-Level Programming
-
Section 9: TypeScript with Backends
-
Section 10: Testing Typed Code
-
Section 11: Data Validation with Types
-
Section 12: Practical Projects
-
Section 13: Compiler Internals
-
Section 14: Configuration Deep Dive
-
Section 15: Enums, Symbols, and Special Types
-
Section 16: Working with Async Code
-
Section 17: TypeScript and the DOM
-
Section 18: Advanced Generics Practice
-
Section 19: Working with Third-Party Types
-
Section 20: Monorepo and Large-Scale Practices
-
Section 21: Common Pitfalls and Best Practices
-
Section 22: Interview Practice
-
Section 23: Handbook: Narrowing In Depth
-
Section 24: Handbook: Object Types In Depth
-
Section 25: Handbook: Classes In Depth
-
Section 26: Handbook: Modules In Depth
-
Section 27: Handbook: Declaration Files In Depth
-
Section 28: JSX and Namespaces
-
Section 29: Compiler Configuration Reference
-
Section 30: More Practice Exercises
-
Section 31: Handbook: Everyday Types Deep Dive
-
Section 32: Utility Types Full Reference
-
Section 33: Decorators Reference
-
Section 34: Mixins and Advanced OOP Patterns
-
Section 35: Iterators and Generators Typing
-
Section 36: More Type-Level Programming Practice
-
Section 37: TypeScript Ecosystem Tools
-
Section 38: TypeScript with Testing Frameworks
-
Section 39: TypeScript for Library Authors
-
Section 40: More Interview Practice
-
Section 41: Handbook: Functions In Depth
-
Section 42: Handbook: Type Manipulation Deep Dive
-
Section 43: More Real-World Patterns
-
Section 44: TypeScript Release Notes Highlights
-
Section 45: Final Practice and Review
28: Discriminated Unions
When I first started using TypeScript, I fell into a trap that I still see developers walk into today. I thought the best way to handle "varying states"—like the result of an API call—was to create one large interface where most of the properties were optional. It felt intuitive. If the request failed, I'd just leave the data property undefined and fill in the error property.
The "Everything is Optional" Trap
Let's look at how that actually plays out in code. You might write something like this:
interface ApiResponse {
data?: UserProfile;
error?: string;
isLoading: boolean;
}
function handleResponse(response: ApiResponse) {
if (response.isLoading) {
return "Loading...";
}
// Here is the problem:
// TypeScript doesn't know if 'data' exists just because 'isLoading' is false.
return `Welcome, ${response.data.name}`; // Error: Object is possibly 'undefined'.
}
The issue here is that the type system has no way of knowing that data and error are mutually exclusive. In the eyes of TypeScript, it's perfectly legal for a response to have both data and error, or worse, neither. You end up fighting the compiler with non-null assertions (!) or endless if` checks, which defeats the whole purpose of using a strong type system.
Defining Clear Boundaries with Discriminants
The professional way to handle this is with a Discriminated Union. Instead of one "do-it-all" interface, we create several distinct types and give them one shared property—the "discriminant"—which holds a literal value. This literal value acts as a tag that TypeScript can use to narrow the type down with absolute certainty.
I prefer using a property called type or status. Watch how the logic shifts when we explicitly define the states:
type RequestState =
| { status: 'loading' }
| { status: 'success'; data: UserProfile }
| { status: 'error'; message: string };
function handleResponse(state: RequestState) {
switch (state.status) {
case 'loading':
return "Loading...";
case 'success':
// TypeScript now knows for a FACT that 'data' exists here.
// No optional chaining or '!' needed.
return `Welcome, ${state.data.name}`;
case 'error':
// Similarly, 'message' is guaranteed to exist here.
return `Error: ${state.message}`;
}
}
By using the literal string 'success' as a tag, we've created a mathematical guarantee. If status is 'success', the object must have a data property. If it's 'error', it must have a message. I find that this approach doesn't just satisfy the compiler; it actually makes the code easier to read for other humans because the business logic is explicitly mapped to the types.
One quick tip: always use a switch statement or an if/else block for narrowing. Avoid trying to "cast" the type. The beauty of discriminated unions is that the narrowing happens automatically based on the value of that discriminant property.
📋 Practical Task
Building a Multi-Step Payment Processing State Machine
You are building a payment checkout flow. The payment process can be in one of four states: idle, processing, completed, or failed. Each state carries different data.
- Idle: No extra data.
- Processing: Needs a
transactionId(string). - Completed: Needs a
confirmationCode(string) and atimestamp(Date). - Failed: Needs an
errorCode(number) and aretryable(boolean) flag.
Your Task:
- Define a union type called
PaymentStatususing a discriminant property namedstate. - Create a function called
getPaymentMessagethat takes aPaymentStatusobject as an argument. - Use a
switchstatement to return a specific string for each state:- Idle: "Please enter your details."
- Processing: "Processing transaction [transactionId]..."
- Completed: "Success! Your code is [confirmationCode]."
- Failed: "Payment failed ([errorCode]). Retry possible: [true/false]."
Ensure that you do not use any type assertions (as) or non-null assertions (!). Rely entirely on the discriminated union for type narrowing.
There are no comments for now.