DEV Community

Cover image for TypeScript Type Guards
Tanya Johari
Tanya Johari

Posted on

TypeScript Type Guards

When you're building a payment system, "close enough" isn't good enough. A single undefined value or a mismatched object property can be the difference between a successful transaction and a frustrated customer (or a lost sale).

TypeScript's Type Guards are your first line of defense. They allow you to narrow down broad, uncertain types into specific ones that you can safely interact with. In this guide, we'll build a mini payment processor and learn how to use Type Guards to make it crash-proof.


1. The Problem: The "Silent Failures" of JavaScript

Imagine you have a function that processes different types of payment responses. In plain JavaScript, you might write something like this:

function processResponse(response) {
  // If response is a 'Success' object, it has a 'transactionId'
  // If it's an 'Error' object, it has a 'message'
  console.log("Payment successful! ID: " + response.transactionId);
}

// What if the API returned an error?
processResponse({ message: "Insufficent funds" }); 
// Output: "Payment successful! ID: undefined" 
Enter fullscreen mode Exit fullscreen mode

JavaScript doesn't complain; it just gives you undefined. This is a "silent failure." TypeScript helps us catch this, but only if we know how to "narrow" the types.


2. Built-in Guards: The Foundation

TypeScript provides built-in operators that perform runtime checks. When TypeScript sees these checks, it "narrows" the type for the rest of that code block.

typeof (Checking Primitives)

In our payment app, an amount might be a number or a string (if it comes from a form input).

function formatAmount(amount: string | number) {
  if (typeof amount === 'string') {
    // TypeScript knows 'amount' is a string here.
    // We can safely call string-specific methods.
    return parseFloat(amount).toFixed(2);
  }
  // If we're here, TypeScript knows 'amount' MUST be a number.
  return amount.toFixed(2);
}
Enter fullscreen mode Exit fullscreen mode

instanceof (Checking Classes)

Suppose you have different classes for CreditCard and GiftCard payments. Each has its own verification logic.

class CreditCard {
  verifyCVV() { return true; }
}

class GiftCard {
  checkBalance() { return 50.00; }
}

function verifyPayment(method: CreditCard | GiftCard) {
  if (method instanceof CreditCard) {
    // Safe to call CreditCard methods
    method.verifyCVV();
  } else {
    // Safe to call GiftCard methods
    method.checkBalance();
  }
}
Enter fullscreen mode Exit fullscreen mode

3. Custom Type Predicates: The "Bouncer"

Sometimes, simple checks aren't enough. You might want to create a reusable function to check if a payment is "complete." To do this, we use a Custom Type Predicate.

We explain the return type as value is Type. This is like a bouncer at a club: the function checks the ID and if it returns true, TypeScript allows that variable into the "exclusive" block of code as that specific type.

interface Payment {
  id: string;
  status: 'pending' | 'completed' | 'failed';
}

interface CompletedPayment extends Payment {
  status: 'completed';
  confirmedAt: Date;
}

// This is our Custom Type Predicate
function isCompleted(payment: Payment): payment is CompletedPayment {
  return payment.status === 'completed';
}

const myPayment: Payment = { id: '123', status: 'completed' };

if (isCompleted(myPayment)) {
  // TypeScript now knows myPayment has a 'confirmedAt' property!
  console.log(myPayment.confirmedAt);
}
Enter fullscreen mode Exit fullscreen mode

4. Discriminated Unions: The Gold Standard

If you only learn one pattern from this article, make it this one. By adding a single, common property (a "discriminator") to your types, you gain 100% type safety and perfect IDE autocomplete.

interface CardPayment {
  type: 'card'; // The Discriminator
  lastFour: string;
}

interface PayPalPayment {
  type: 'paypal'; // The Discriminator
  email: string;
}

type PaymentMethod = CardPayment | PayPalPayment;

function getReceipt(method: PaymentMethod) {
  switch (method.type) {
    case 'card':
      return `Charged card ending in ${method.lastFour}`;
    case 'paypal':
      return `Charged PayPal account: ${method.email}`;
  }
}
Enter fullscreen mode Exit fullscreen mode

Why this is a superpower: If you add a Bitcoin payment type later but forget to update this switch statement, TypeScript will flag an error immediately. It's like having a senior developer looking over your shoulder.


5. Anti-Patterns: The "Before and After"

As a beginner, you might be tempted to use shortcuts to silence the "red error lines." Let's look at why you shouldn't.

The "Unsafe Assertion"

Before (The "Dirty" way):

// Bypassing safety with 'as any'
const receipt = (payment as any).cardNumber; 
// If 'payment' is actually a Bank Transfer, this is 'undefined'.
Enter fullscreen mode Exit fullscreen mode

After (The "Guard" way):

if ('cardNumber' in payment) {
  const receipt = payment.cardNumber; // Safe and verified
}
Enter fullscreen mode Exit fullscreen mode

Using the in operator acts as a shield when dealing with unpredictable data from external APIs.


Summary: Your Type Guard Decision Tree

Not sure which guard to use? Follow this checklist:

  1. Is it a simple value like a string or number?
    • ✅ Use typeof.
  2. Is it an object created with new MyClass()?
    • ✅ Use instanceof.
  3. Do your types have a shared property like type or status?
    • ✅ Use Discriminated Unions (Best for complex logic).
  4. Are you checking for a specific property on a "messy" object?
    • ✅ Use the in operator.
  5. Do you want a reusable function to clean up your if statements?
    • ✅ Use a Custom Type Predicate (is).

Conclusion

Type Guards bridge the gap between TypeScript's strict rules and JavaScript's flexible reality. By implementing these patterns in your payment system, you're not just writing code—you're building a reliable, predictable engine that handles every edge case with ease.

Happy coding!


Reference:

Top comments (0)