🔥 0
0
Lesson 7 of 10 20 min +100 XP

Type Narrowing

When you have a union type, TypeScript needs help knowing which specific type you're working with. Type narrowing is how you tell TypeScript "trust me, it's this type."

The Problem

function formatPrice(price: string | number): string {
  // TypeScript doesn't know if price is string or number
  return price.toFixed(2);  // ❌ Error: Property 'toFixed' does not exist on type 'string'
}

typeof Guards

Use typeof to check primitive types:

function formatPrice(price: string | number): string {
  if (typeof price === "number") {
    // TypeScript knows: price is number here
    return `${price.toFixed(2)}`;
  }
  // TypeScript knows: price is string here
  return `${parseFloat(price).toFixed(2)}`;
}

formatPrice(29.99);    // "$29.99"
formatPrice("29.99");  // "$29.99"

Common typeof Checks

function processInput(input: string | number | boolean | null): string {
  if (typeof input === "string") {
    return input.toUpperCase();
  }
  if (typeof input === "number") {
    return input.toString();
  }
  if (typeof input === "boolean") {
    return input ? "Yes" : "No";
  }
  // input is null here
  return "No value";
}

---

Truthiness Narrowing

JavaScript's truthy/falsy rules help narrow types:

interface Customer {
  id: string;
  name: string;
  email: string;
  phone?: string;  // Optional
}

function contactCustomer(customer: Customer): void {
  // phone might be undefined
  if (customer.phone) {
    // TypeScript knows: phone is string here (not undefined)
    console.log(`Calling ${customer.phone}`);
  } else {
    console.log(`Emailing ${customer.email}`);
  }
}

Checking for null/undefined

function applyCoupon(cart: Cart, couponCode: string | null): number {
  if (couponCode === null) {
    // No coupon
    return cart.total;
  }

  // TypeScript knows: couponCode is string here
  const discount = lookupDiscount(couponCode);
  return cart.total - discount;
}

// Or using truthiness
function applyCoupon(cart: Cart, couponCode: string | null | undefined): number {
  if (!couponCode) {
    // Handles null, undefined, and empty string
    return cart.total;
  }

  // couponCode is string here
  return cart.total - lookupDiscount(couponCode);
}

---

in Operator Narrowing

Check if a property exists on an object:

interface PhysicalProduct {
  id: string;
  name: string;
  price: number;
  weight: number;
  shippingCost: number;
}

interface DigitalProduct {
  id: string;
  name: string;
  price: number;
  downloadUrl: string;
}

type Product = PhysicalProduct | DigitalProduct;

function getShippingInfo(product: Product): string {
  if ("weight" in product) {
    // TypeScript knows: product is PhysicalProduct
    return `Shipping: ${product.shippingCost} (${product.weight}kg)`;
  }
  // TypeScript knows: product is DigitalProduct
  return `Download: ${product.downloadUrl}`;
}

---

Discriminated Union Narrowing

The cleanest pattern - use a type or kind field:

interface CartItem {
  type: "cart-item";
  product: Product;
  quantity: number;
}

interface GiftCard {
  type: "gift-card";
  code: string;
  balance: number;
}

interface Coupon {
  type: "coupon";
  code: string;
  discountPercent: number;
}

type CartAddition = CartItem | GiftCard | Coupon;

function addToCart(cart: Cart, item: CartAddition): void {
  switch (item.type) {
    case "cart-item":
      // TypeScript knows: item is CartItem
      console.log(`Adding ${item.quantity}x ${item.product.name}`);
      break;

    case "gift-card":
      // TypeScript knows: item is GiftCard
      console.log(`Gift card: ${item.balance}`);
      break;

    case "coupon":
      // TypeScript knows: item is Coupon
      console.log(`Coupon: ${item.discountPercent}% off`);
      break;
  }
}

---

Custom Type Guards

Create reusable functions that narrow types:

interface Customer {
  id: string;
  name: string;
  email: string;
}

interface GuestUser {
  sessionId: string;
  cartId: string;
}

type User = Customer | GuestUser;

// Custom type guard - returns true if user is Customer
function isCustomer(user: User): user is Customer {
  return "email" in user;
}

function greetUser(user: User): string {
  if (isCustomer(user)) {
    // TypeScript knows: user is Customer
    return `Welcome back, ${user.name}!`;
  }
  // TypeScript knows: user is GuestUser
  return "Welcome, guest! Sign up for 10% off.";
}

More Type Guard Examples

// Check if product is in stock
interface Product {
  id: string;
  name: string;
  price: number;
  stock: number;
}

function isInStock(product: Product): product is Product & { stock: number } {
  return product.stock > 0;
}

// Check if order is complete
type OrderStatus = "pending" | "paid" | "shipped" | "delivered" | "cancelled";

interface Order {
  id: string;
  status: OrderStatus;
  items: CartItem[];
}

function isCompletedOrder(order: Order): order is Order & { status: "delivered" } {
  return order.status === "delivered";
}

// Use it
const orders: Order[] = getOrders();
const completedOrders = orders.filter(isCompletedOrder);
// completedOrders has type: (Order & { status: "delivered" })[]

---

Exhaustiveness Checking

Ensure you handle all cases in a union:

type PaymentStatus = "pending" | "processing" | "completed" | "failed" | "refunded";

function getStatusMessage(status: PaymentStatus): string {
  switch (status) {
    case "pending":
      return "Awaiting payment...";
    case "processing":
      return "Processing your payment...";
    case "completed":
      return "Payment successful!";
    case "failed":
      return "Payment failed. Please try again.";
    case "refunded":
      return "Payment has been refunded.";
    default:
      // This ensures all cases are handled
      const _exhaustive: never = status;
      return _exhaustive;
  }
}

// If you add a new status and forget to handle it:
type PaymentStatus = "pending" | "processing" | "completed" | "failed" | "refunded" | "disputed";
// TypeScript error: Type '"disputed"' is not assignable to type 'never'

---

Real-World Example: Checkout Flow

interface EmptyCart {
  state: "empty";
}

interface ActiveCart {
  state: "active";
  items: CartItem[];
  subtotal: number;
}

interface CheckoutCart {
  state: "checkout";
  items: CartItem[];
  subtotal: number;
  shippingAddress: Address;
  paymentMethod: Payment;
}

interface CompletedCart {
  state: "completed";
  orderId: string;
  items: CartItem[];
  total: number;
}

type Cart = EmptyCart | ActiveCart | CheckoutCart | CompletedCart;

function renderCart(cart: Cart): string {
  switch (cart.state) {
    case "empty":
      return "Your cart is empty. Start shopping!";

    case "active":
      return `${cart.items.length} items - ${cart.subtotal}`;

    case "checkout":
      return `Shipping to ${cart.shippingAddress.city}...`;

    case "completed":
      return `Order ${cart.orderId} placed! Total: ${cart.total}`;
  }
}

---

Type Challenges

Challenge 1: Process Different Item Types

Complete this function to handle all item types:

interface RegularItem {
  type: "regular";
  name: string;
  price: number;
}

interface SaleItem {
  type: "sale";
  name: string;
  originalPrice: number;
  salePrice: number;
}

interface BundleItem {
  type: "bundle";
  name: string;
  items: string[];
  bundlePrice: number;
}

type Item = RegularItem | SaleItem | BundleItem;

function getItemPrice(item: Item): number {
  // Your code here
}
Solution
function getItemPrice(item: Item): number {
  switch (item.type) {
    case "regular":
      return item.price;
    case "sale":
      return item.salePrice;
    case "bundle":
      return item.bundlePrice;
  }
}

Challenge 2: Create a Type Guard

Create a type guard hasDiscount that checks if a product has a discount:

interface Product {
  id: string;
  name: string;
  price: number;
  discountPercent?: number;
}

// Your type guard here

// Usage:
const products: Product[] = [...];
const discountedProducts = products.filter(hasDiscount);
// discountedProducts should have discountPercent guaranteed
Solution
function hasDiscount(product: Product): product is Product & { discountPercent: number } {
  return product.discountPercent !== undefined && product.discountPercent > 0;
}

// Now this works:
const discountedProducts = products.filter(hasDiscount);
discountedProducts.forEach(p => {
  console.log(`${p.name}: ${p.discountPercent}% off`);  // No error!
});

---

Key Takeaways

TechniqueUse When
typeof x === "string"Checking primitives
if (x)Checking truthy/falsy
"prop" in objChecking property existence
switch (x.type)Discriminated unions
function isX(x): x is TypeReusable custom guards
const _: never = xExhaustiveness checking

Next up: Generics - creating reusable, type-safe functions and interfaces!

Union Types & Literals