🔍 Advanced Type Narrowing in TypeScript
The Story of the Shape-Shifting Detective
Imagine you’re a detective 🕵️ at a magical museum. Objects in this museum can look like anything at first—a painting, a statue, or a treasure chest. But you have special detective tools that help you figure out exactly what each object is.
TypeScript’s Type Narrowing is exactly like that! It helps you take something that could be many things and figure out exactly what it is.
🎭 What is Type Narrowing?
Think of a toy box with different toys inside. You reach in without looking. You might grab:
- A teddy bear 🧸
- A toy car 🚗
- A puzzle piece 🧩
Type Narrowing is how you figure out which toy you grabbed—without guessing wrong!
type Toy = Bear | Car | Puzzle;
function playWith(toy: Toy) {
// TypeScript: "Could be any toy!"
// We need to narrow it down!
}
🏷️ Type Predicates: Your Personal Label Maker
A type predicate is like a label maker. You create a special function that says “YES, this IS a bear!” or “NO, this is NOT a bear!”
The Magic Words: is
function isBear(toy: Toy): toy is Bear {
return (toy as Bear).fur !== undefined;
}
That toy is Bear part is the type predicate. It tells TypeScript: “If this function returns true, treat the toy as a Bear!”
See It In Action
function playWith(toy: Toy) {
if (isBear(toy)) {
// TypeScript knows: toy is Bear! 🧸
console.log(toy.fur); // ✅ Safe!
}
}
Real Example
interface Fish {
swim: () => void;
}
interface Bird {
fly: () => void;
}
// Our type predicate function
function isFish(pet: Fish | Bird): pet is Fish {
return (pet as Fish).swim !== undefined;
}
function move(pet: Fish | Bird) {
if (isFish(pet)) {
pet.swim(); // ✅ TypeScript knows!
} else {
pet.fly(); // ✅ Must be Bird!
}
}
🔮 Inferred Type Predicates: TypeScript Reads Your Mind!
Sometimes TypeScript is SO smart, it figures out the type predicate by itself!
The Old Way (TypeScript < 5.5)
// You had to write this:
function isString(x: unknown): x is string {
return typeof x === "string";
}
The New Way (TypeScript 5.5+)
// TypeScript now understands this!
const isString = (x: unknown) =>
typeof x === "string";
// It automatically knows this narrows to string!
Array Filtering Magic ✨
This is where it gets REALLY cool:
const mixed = [1, "hello", 2, "world"];
// Before: got (string | number)[]
// After: TypeScript infers string[]!
const strings = mixed.filter(
x => typeof x === "string"
);
// strings is now string[]! 🎉
How It Works
graph TD A["Function Returns Boolean"] --> B{Uses Type Checks?} B -->|Yes| C["TypeScript Infers Predicate"] B -->|No| D["Regular Boolean"] C --> E["Automatic Narrowing!"]
⚡ Assertion Functions: The Confidence Booster
An assertion function is like a strict teacher who says: “This MUST be true, or class is OVER!”
The asserts Keyword
function assertIsNumber(
val: unknown
): asserts val is number {
if (typeof val !== "number") {
throw new Error("Not a number!");
}
}
Why Use Them?
Regular predicates return true or false. Assertion functions throw errors if something’s wrong!
function process(value: unknown) {
assertIsNumber(value);
// If we reach here, TypeScript KNOWS
// value is a number! 🎯
console.log(value.toFixed(2)); // ✅ Safe!
}
asserts vs is
| Feature | is (Predicate) |
asserts |
|---|---|---|
| Returns | boolean |
void |
| On false | Returns false |
Throws error |
| Use case | Conditional checks | Validation |
Assert Condition Pattern
function assert(
condition: boolean,
msg: string
): asserts condition {
if (!condition) {
throw new Error(msg);
}
}
function divide(a: number, b: number) {
assert(b !== 0, "Cannot divide by zero!");
// TypeScript knows b is not 0 here
return a / b;
}
🎪 Discriminated Unions: The Name Tag System
Imagine a party where everyone wears a name tag with their role: “Chef 👨🍳”, “Doctor 👩⚕️”, or “Artist 🎨”. You know exactly what each person does just by reading their tag!
The Discriminant Property
Every type has a unique tag (called a discriminant):
interface Circle {
kind: "circle"; // 👈 The tag!
radius: number;
}
interface Square {
kind: "square"; // 👈 Different tag!
size: number;
}
interface Triangle {
kind: "triangle"; // 👈 Another tag!
base: number;
height: number;
}
type Shape = Circle | Square | Triangle;
Using the Tags
function getArea(shape: Shape): number {
switch (shape.kind) {
case "circle":
// TypeScript knows: shape is Circle!
return Math.PI * shape.radius ** 2;
case "square":
// TypeScript knows: shape is Square!
return shape.size ** 2;
case "triangle":
// TypeScript knows: shape is Triangle!
return (shape.base * shape.height) / 2;
}
}
Why This Works
graph TD A["Shape with kind"] --> B{Check kind} B -->|"circle"| C["Must be Circle"] B -->|"square"| D["Must be Square"] B -->|"triangle"| E["Must be Triangle"]
Common Patterns
API Responses:
type Result<T> =
| { status: "success"; data: T }
| { status: "error"; message: string }
| { status: "loading" };
function handle(result: Result<User>) {
if (result.status === "success") {
console.log(result.data.name); // ✅
}
}
🛡️ Exhaustiveness Checking: Never Miss a Case!
What if someone adds a new shape but forgets to handle it? Exhaustiveness checking catches this!
The never Trick
function getArea(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.size ** 2;
case "triangle":
return (shape.base * shape.height) / 2;
default:
// This line catches missing cases!
const _exhaustive: never = shape;
throw new Error(`Unknown: ${_exhaustive}`);
}
}
What Happens If You Miss One?
// Someone adds a new shape:
interface Pentagon {
kind: "pentagon";
side: number;
}
type Shape = Circle | Square | Triangle | Pentagon;
// Now the switch shows an error! 🚨
// "Type 'Pentagon' is not assignable to 'never'"
Helper Function Pattern
function assertNever(x: never): never {
throw new Error(`Unexpected: ${x}`);
}
function getArea(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.size ** 2;
case "triangle":
return (shape.base * shape.height) / 2;
default:
return assertNever(shape); // Compile error if missing!
}
}
Why never?
graph TD A["All Cases Handled"] --> B["Nothing Left"] B --> C["Only never Fits"] C --> D["If Something Remains..."] D --> E["Error! You Missed It!"]
🎯 Putting It All Together
Here’s our detective museum in action:
// Type predicate
function isArtwork(item: unknown): item is Artwork {
return (item as Artwork).artist !== undefined;
}
// Assertion function
function assertValidItem(
item: unknown
): asserts item is MuseumItem {
if (!item || typeof item !== "object") {
throw new Error("Invalid item!");
}
}
// Discriminated union
type MuseumItem =
| { type: "painting"; artist: string }
| { type: "statue"; material: string }
| { type: "artifact"; age: number };
// Exhaustiveness check
function describe(item: MuseumItem): string {
switch (item.type) {
case "painting":
return `Painted by ${item.artist}`;
case "statue":
return `Made of ${item.material}`;
case "artifact":
return `${item.age} years old`;
default:
const _check: never = item;
return _check;
}
}
🌟 Quick Reference
| Tool | Purpose | Keyword |
|---|---|---|
| Type Predicate | Check & narrow | is |
| Inferred Predicate | Auto-detect | (automatic) |
| Assertion Function | Validate or throw | asserts |
| Discriminated Union | Tag-based narrowing | Common property |
| Exhaustiveness | Catch missing cases | never |
🚀 You Did It!
You’ve learned how TypeScript’s detective tools work:
- Type Predicates → Custom type checks with
is - Inferred Predicates → TypeScript figures it out!
- Assertion Functions → Validate or throw with
asserts - Discriminated Unions → Tag-based narrowing
- Exhaustiveness Checking → Never miss a case with
never
Now go forth and narrow those types with confidence! 🎉
