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
| Technique | Use When |
|---|---|
typeof x === "string" | Checking primitives |
if (x) | Checking truthy/falsy |
"prop" in obj | Checking property existence |
switch (x.type) | Discriminated unions |
function isX(x): x is Type | Reusable custom guards |
const _: never = x | Exhaustiveness checking |
Next up: Generics - creating reusable, type-safe functions and interfaces!