================================================================================ TYPESCRIPT FUNDAMENTALS - CHALLENGE 3 SOLUTION Chapter 7: Advanced Types Challenge: Exhaustiveness with never ================================================================================ PROBLEM: Create a union of three animal types with a makeSound() function. Use the never type to ensure all cases are handled. SOLUTION: ================================================================================ // Define three animal types type Cat = { type: "cat"; name: string }; type Dog = { type: "dog"; name: string }; type Bird = { type: "bird"; name: string }; // Union of all animals type Animal = Cat | Dog | Bird; // Helper function for exhaustiveness checking function assertNever(value: never): never { throw new Error(`Unhandled case: ${value}`); } // Main function with exhaustiveness checking function makeSound(animal: Animal): string { switch (animal.type) { case "cat": // TypeScript narrows to Cat return `${animal.name} says: Meow!`; case "dog": // TypeScript narrows to Dog return `${animal.name} says: Woof!`; case "bird": // TypeScript narrows to Bird return `${animal.name} says: Tweet!`; default: // If you forget to handle a case, animal will not be 'never' // TypeScript will show an error here return assertNever(animal); } } // Testing the solution const cat: Animal = { type: "cat", name: "Whiskers" }; const dog: Animal = { type: "dog", name: "Rex" }; const bird: Animal = { type: "bird", name: "Tweety" }; console.log(makeSound(cat)); // "Whiskers says: Meow!" console.log(makeSound(dog)); // "Rex says: Woof!" console.log(makeSound(bird)); // "Tweety says: Tweet!" ================================================================================ WHY THIS WORKS: ================================================================================ 1. The assertNever Pattern - assertNever accepts a parameter of type 'never' - 'never' means "no value can ever be here" - If you reach the default case, animal is 'never' 2. Exhaustiveness Detection - If you add a new animal type (e.g., "fish") and forget the case, the default will receive a Fish object (not 'never') - TypeScript error: "Argument of type 'Fish' is not assignable to 'never'" - This forces you to handle the new case! 3. How the Error Manifests BEFORE fix (forget Bird case): ❌ Error in default: Fish object is not assignable to never AFTER fix (add Bird case): ✅ Now animal is never in default, assertNever works! 4. Runtime Behavior - If a new animal type somehow sneaks through, assertNever() throws an error with helpful message - Prevents silent bugs in production ================================================================================ DEMONSTRATION: Adding a New Type ================================================================================ // What happens if you add a new animal type? type Reptile = { type: "reptile"; name: string }; type Animal = Cat | Dog | Bird | Reptile; // Added Reptile // ❌ ERROR! This function is now incomplete function makeSound(animal: Animal): string { switch (animal.type) { case "cat": return `${animal.name} says: Meow!`; case "dog": return `${animal.name} says: Woof!`; case "bird": return `${animal.name} says: Tweet!`; // ❌ Missing case "reptile"! default: // Error: Argument of type 'Reptile' is not assignable to parameter of type 'never' return assertNever(animal); } } // ✅ FIX: Add the missing case function makeSound(animal: Animal): string { switch (animal.type) { case "cat": return `${animal.name} says: Meow!`; case "dog": return `${animal.name} says: Woof!`; case "bird": return `${animal.name} says: Tweet!`; case "reptile": // ✅ Now TypeScript is happy! return `${animal.name} says: Hisss!`; default: return assertNever(animal); } } ================================================================================ ALTERNATIVE APPROACHES: ================================================================================ Approach 1: Using if-else instead of switch function makeSound(animal: Animal): string { if (animal.type === "cat") { return `${animal.name} says: Meow!`; } else if (animal.type === "dog") { return `${animal.name} says: Woof!`; } else if (animal.type === "bird") { return `${animal.name} says: Tweet!`; } else { // Exhaustiveness check still works! return assertNever(animal); } } Approach 2: Without assertNever (let TypeScript infer) function makeSound(animal: Animal): string { switch (animal.type) { case "cat": return `${animal.name} says: Meow!`; case "dog": return `${animal.name} says: Woof!`; case "bird": return `${animal.name} says: Tweet!`; } // ❌ Error: Function lacks ending return statement // This also detects missing cases, but less clear } Approach 3: Using exhaustiveness in object literal const soundMap: Record string> = { cat: (name) => `${name} says: Meow!`, dog: (name) => `${name} says: Woof!`, bird: (name) => `${name} says: Tweet!`, // ❌ Error if you forget one! (but only at assignment, not at call) }; function makeSound(animal: Animal): string { return soundMap[animal.type](animal.name); } ================================================================================ REAL-WORLD EXAMPLES: ================================================================================ Example 1: UI Component States type ComponentState = | { status: "loading" } | { status: "ready"; data: any } | { status: "error"; message: string }; function renderComponent(state: ComponentState): JSX.Element { switch (state.status) { case "loading": return ; case "ready": return ; case "error": return ; default: return assertNever(state); } } Example 2: Event Handlers type UserEvent = | { type: "click"; x: number; y: number } | { type: "keypress"; key: string } | { type: "scroll"; delta: number }; function handleEvent(event: UserEvent) { switch (event.type) { case "click": console.log(`Clicked at ${event.x}, ${event.y}`); break; case "keypress": console.log(`Key pressed: ${event.key}`); break; case "scroll": console.log(`Scrolled by ${event.delta}`); break; default: assertNever(event); } } Example 3: Network Request States type RequestState = | { state: "idle" } | { state: "pending" } | { state: "resolved"; data: T } | { state: "rejected"; error: Error }; function processRequest(state: RequestState): T | null { switch (state.state) { case "idle": return null; case "pending": return null; case "resolved": return state.data; case "rejected": throw state.error; default: assertNever(state); } } ================================================================================ WHY EXHAUSTIVENESS CHECKING MATTERS: ================================================================================ ❌ WITHOUT exhaustiveness checking: - Add new animal type "fish" - Forget to update makeSound() - Code compiles fine - Runtime: makeSound(fish) returns assertNever(fish) → throws error - Or silently returns undefined if you don't have default case - Bug found in production! 😱 ✅ WITH exhaustiveness checking: - Add new animal type "fish" - Code fails to compile immediately - TypeScript error points to exact location - You MUST handle the new case before running code - Bug caught at development time! 🎉 This is why discriminated unions + never pattern is considered a best practice. ================================================================================ KEY TAKEAWAYS: ================================================================================ ✅ Use never type to enforce exhaustive case handling ✅ assertNever() is a TypeScript idiom for compile-time checking ✅ Adding a new union member will cause a compile error ✅ Better than runtime errors - caught immediately ✅ Works with switch statements and if-else chains ✅ Especially valuable for state machines and large unions ✅ Prevents the "forgot to handle this case" bug entirely Common misconception to avoid: ❌ "I don't need exhaustiveness checking, my code never has that bug" ✅ "Exhaustiveness checking helps when my code EVOLVES and new cases are added" The pattern pays for itself when you refactor - TypeScript becomes your safety net! ================================================================================ TESTING & VERIFICATION: ================================================================================ // Complete test with intentional error demonstration // Step 1: Test the working version const animals: Animal[] = [ { type: "cat", name: "Whiskers" }, { type: "dog", name: "Rex" }, { type: "bird", name: "Tweety" } ]; console.log("=== Animal Sounds ==="); animals.forEach(animal => { console.log(makeSound(animal)); }); Expected output: === Animal Sounds === Whiskers says: Meow! Rex says: Woof! Tweety says: Tweet! Step 2: Try adding a new animal type (creates compile error): type Fish = { type: "fish"; name: string }; type Animal = Cat | Dog | Bird | Fish; // ❌ TypeScript error: // Argument of type 'Fish' is not assignable to parameter of type 'never'. Step 3: Fix the error by adding the case: case "fish": return `${animal.name} says: Glub glub!`; Now it compiles and works perfectly! ================================================================================