================================================================================ TYPESCRIPT FUNDAMENTALS - CHALLENGE 2 SOLUTION Chapter 7: Advanced Types Challenge: Discriminated Union ================================================================================ PROBLEM: Create a discriminated union for API responses with three states: loading, success (with data), and error (with message). Write a function to handle each. SOLUTION: ================================================================================ // Define the three possible states using a discriminated union type APIResponse = | { status: "loading" } | { status: "success"; data: T } | { status: "error"; message: string }; // Generic handler function function handleResponse(response: APIResponse): string { switch (response.status) { case "loading": return "Loading..."; case "success": // TypeScript narrows to { status: "success"; data: T } return `Success! Data: ${JSON.stringify(response.data)}`; case "error": // TypeScript narrows to { status: "error"; message: string } return `Error: ${response.message}`; } } // Example usage with different data types interface User { id: number; name: string; } // Test with User data const loadingState: APIResponse = { status: "loading" }; console.log(handleResponse(loadingState)); // "Loading..." const successState: APIResponse = { status: "success", data: { id: 1, name: "Alice" } }; console.log(handleResponse(successState)); // "Success! Data: {"id":1,"name":"Alice"}" const errorState: APIResponse = { status: "error", message: "Failed to fetch user" }; console.log(handleResponse(errorState)); // "Error: Failed to fetch user" ================================================================================ WHY THIS WORKS: ================================================================================ 1. Discriminator Pattern - Each union member has a "status" property - status can only be one specific value - This "discriminates" (distinguishes) between the types 2. Type Narrowing in Switch - TypeScript knows that if status === "loading", the type must be the loading variant - It automatically narrows the type in each case block - You can safely access properties specific to that variant 3. Generics with Discriminated Unions - lets us reuse this pattern for any data type - Works with User, Post, Product, or any other type - Makes the code flexible and reusable 4. Exhaustiveness Checking - If you add a new status variant and forget to handle it, TypeScript will error because the switch doesn't return a value - The switch must cover all cases or have a default ================================================================================ ALTERNATIVE APPROACHES: ================================================================================ Approach 1: Without Generics (for specific type) type UserResponse = | { status: "loading" } | { status: "success"; user: User } | { status: "error"; message: string }; function handleUserResponse(response: UserResponse): void { if (response.status === "loading") { console.log("Loading user..."); } else if (response.status === "success") { console.log("User:", response.user.name); } else if (response.status === "error") { console.log("Error:", response.message); } } Approach 2: With more detailed error info type APIResponse = | { status: "loading"; progress?: number } | { status: "success"; data: T; timestamp: Date } | { status: "error"; code: string; message: string; details?: unknown }; function handleResponse(response: APIResponse): string { switch (response.status) { case "loading": const percent = response.progress ?? 0; return `Loading ${percent}%`; case "success": return `Got data at ${response.timestamp.toISOString()}`; case "error": return `Error ${response.code}: ${response.message}`; } } Approach 3: Using kind instead of status type Result = | { kind: "pending" } | { kind: "ok"; value: T } | { kind: "fail"; reason: string }; function processResult(result: Result) { switch (result.kind) { case "pending": return "waiting..."; case "ok": return `success: ${result.value}`; case "fail": return `failed: ${result.reason}`; } } ================================================================================ REAL-WORLD EXAMPLES: ================================================================================ Example 1: Async Data Fetching type DataState = | { state: "idle" } | { state: "loading" } | { state: "success"; payload: T } | { state: "error"; error: Error }; function useData(state: DataState) { switch (state.state) { case "idle": return { render: () =>
Click to load
}; case "loading": return { render: () =>
Loading...
}; case "success": return { render: () =>
{state.payload}
}; case "error": return { render: () =>
Error: {state.error.message}
}; } } Example 2: Form Submission type SubmitState = | { type: "idle" } | { type: "submitting" } | { type: "submitted"; response: any } | { type: "error"; error: string }; function renderForm(state: SubmitState) { if (state.type === "submitting") { return ; } if (state.type === "error") { return
{state.error}
; } if (state.type === "submitted") { return
Form submitted!
; } return ; } Example 3: Payment Processing type PaymentResult = | { status: "pending"; transactionId: string } | { status: "approved"; receipt: string } | { status: "declined"; reason: string } | { status: "failed"; error: string }; function handlePayment(result: PaymentResult) { switch (result.status) { case "pending": console.log(`Payment processing: ${result.transactionId}`); break; case "approved": console.log(`Payment approved! Receipt: ${result.receipt}`); break; case "declined": console.log(`Payment declined: ${result.reason}`); break; case "failed": console.log(`Payment failed: ${result.error}`); break; } } ================================================================================ KEY TAKEAWAYS: ================================================================================ ✅ Discriminated unions use a common property to distinguish types ✅ Switch statements work perfectly with discriminated unions ✅ TypeScript automatically narrows types in each case ✅ Generics make discriminated unions reusable across types ✅ Perfect for representing state machines and async operations ✅ Catches missing cases at compile-time (exhaustiveness checking) ✅ More type-safe than using booleans or multiple properties Comparison: Why NOT booleans? ❌ Bad: { loading: boolean; data?: T; error?: string } ✅ Good: { status: "loading" } | { status: "success"; data: T } | ... The discriminated union version is better because: - You can't accidentally have both loading: true AND data at the same time - The type system enforces which properties exist in each state - It's self-documenting (states are explicit) ================================================================================ TESTING & VERIFICATION: ================================================================================ // Complete test suite interface Post { id: number; title: string; body: string; } const testCases: APIResponse[] = [ { status: "loading" }, { status: "success", data: { id: 1, title: "First Post", body: "This is a great post" } }, { status: "error", message: "Network timeout" }, { status: "loading" }, { status: "error", message: "500 Internal Server Error" } ]; console.log("=== Testing All States ===\n"); testCases.forEach((response, index) => { console.log(`Test ${index + 1}: ${handleResponse(response)}`); }); Expected output: === Testing All States === Test 1: Loading... Test 2: Success! Data: {"id":1,"title":"First Post","body":"This is a great post"} Test 3: Error: Network timeout Test 4: Loading... Test 5: Error: 500 Internal Server Error ================================================================================