Typescript Type Guards And Discriminated Unions Complete Guide

 Last Update:2025-06-22T00:00:00     .NET School AI Teacher - SELECT ANY TEXT TO EXPLANATION.    10 mins read      Difficulty-Level: beginner

Understanding the Core Concepts of TypeScript Type Guards and Discriminated Unions

Type Guards

Type Guards are used at runtime to test an object to determine if it's an instance of a certain class or if a variable belongs to a specific type. Type Guards are crucial when working with union types to ensure that the correct operations and property accesses are performed on the types.

Importantly:

  • They are essential for performing operations on objects whose types are not known until runtime.
  • TypeScript uses them to refine types within conditional blocks.
  • Common type guards include instanceof, in, typeof, and custom guard functions.

Built-in Type Guards:

  1. instanceof Guard: Used to check if an object is an instance of a class.

    class Dog {}
    class Cat {}
    
    let pet: Dog | Cat = new Dog();
    
    if (pet instanceof Dog) {
        // TypeScript knows 'pet' is a Dog here
        console.log("Woof!");
    } else {
        // TypeScript knows 'pet' is a Cat here
        console.log("Meow!");
    }
    
  2. in Guard: Ensures a property exists within an object.

    interface Vehicle {
        type: string;
    }
    
    interface Car extends Vehicle {
        numberOfDoors: number;
    }
    
    interface Bike extends Vehicle {
        hasPedals: boolean;
    }
    
    function isCar(vehicle: Vehicle): vehicle is Car {
        return 'numberOfDoors' in vehicle;
    }
    
    function describeVehicle(vehicle: Vehicle) {
        if (isCar(vehicle)) {
            // TypeScript knows 'vehicle' is a Car here
            console.log(`This car has ${vehicle.numberOfDoors} doors.`);
        } else {
            // TypeScript knows 'vehicle' is a Bike here
            console.log(`This bike has pedals? ${vehicle.hasPedals}`);
        }
    }
    
  3. typeof Guard: Checks the type of basic primitives like string, number, etc.

    function printInfo(info: string | number) {
        if (typeof info === 'string') {
            // TypeScript knows 'info' is a string here
            console.log(`The string is: ${info.toUpperCase()}`);
        } 
        else if(typeof info === 'number') {
            // TypeScript knows 'info' is a number here
            console.log(`The number is: ${info * 2}`);
        }
    }
    
  4. Custom Type Guard Function: A user-defined function that returns a type predicate of the form parameterName is Type.

    interface Bird {
        fly(): void;
        layEggs(): void;
    }
    
    interface Fish {
        swim(): void;
        layEggs(): void;
    }
    
    function isFish(pet: Bird | Fish): pet is Fish {
        return (pet as Fish).swim !== undefined;
    }
    
    function movePet(pet: Bird | Fish) {
        if (isFish(pet)) {
            // TypeScript knows 'pet' is a Fish here
            pet.swim();
        } else {
            // TypeScript knows 'pet' is a Bird here
            pet.fly();
        }
    }
    

Discriminated Unions

A Discriminated Union is a pattern that you can use when you have several types that have a common, singular property—often called a discriminator—and then a type guard that checks the value of the discriminator to determine which branch of the union a particular value belongs to.

Importantly:

  • They provide a way to handle multiple types in TypeScript, making the code cleaner and safer.
  • Discriminated unions make it easier to work with unions that contain complex types by using a common property to differentiate between members.

Example of a Discriminated Union:

interface Square {
    kind: "square";
    size: number;
}

interface Rectangle {
    kind: "rectangle";
    width: number;
    height: number;
}

interface Circle {
    kind: "circle";
    radius: number;
}

type Shape = Square | Rectangle | Circle;

function area(shape: Shape) {
    switch(shape.kind) {
        case "square":
            // TypeScript knows 'shape' is a Square here
            return shape.size * shape.size;
        case "rectangle":
            // TypeScript knows 'shape' is a Rectangle here
            return shape.width * shape.height;
        case "circle":
            // TypeScript knows 'shape' is a Circle here
            return Math.PI * shape.radius ** 2;
        default:
            const _exhaustiveCheck: never = shape;
            throw new Error(`Unknown shape: ${_exhaustiveCheck}`);
    }
}

Exhaustiveness Checking:

One of the benefits of discriminated unions is that they allow for exhaustiveness checking using the never type. In the previous example, TypeScript will complain if a new member is added to the Shape union without corresponding handling in the switch statement. This ensures your code doesn't accidentally fall through unhandled cases.

Key Points:

  • Discriminated unions make type safety easy by leveraging a common property.
  • The switch statement is typically used with a discriminator to safely handle different possibilities.
  • Exhaustiveness checking through never helps you prevent errors when adding new types.

Conclusion

Incorporating type guards and discriminated unions into your TypeScript projects improves type safety and reduces runtime errors. These tools not only help the compiler but also guide developers by ensuring that specific actions are taken based on the actual type of the object.

For example, when working with a discriminated union, you can safely perform operations knowing the exact structure of the object. In conjunction with type guards, this allows TypeScript to understand and enforce what actions and properties can be used on a particular type within a conditional block.

Online Code run

🔔 Note: Select your programming language to check or run code at

💻 Run Code Compiler

Step-by-Step Guide: How to Implement TypeScript Type Guards and Discriminated Unions

Understanding TypeScript Type Guards

Type guards are a special kind of type check used in TypeScript to determine the type of objects at compile time. This way TypeScript can narrow down a variable’s type and provide more specific information to the compiler.

Example 1: Using typeof as a Type Guard

Let's start with a simple example that uses typeof to guard against potential runtime type errors:

function printVariable(x: number | string) {
    if (typeof x === 'string') {
        // Here TypeScript recognizes that `x` is a string.
        return console.log('The string is: ', x.toUpperCase());
    } else if (typeof x === 'number') {
        // And here TypeScript knows that `x` must be a number.
        return console.log('The number is: ', x.toFixed(2));
    }
}

printVariable("hello"); // Output: The string is:  HELLO
printVariable(42);      // Output: The number is:  42.00

In this example:

  • We have a function printVariable that accepts a parameter x which could either be a number or a string.
  • Inside the function, we use typeof to check the type of x.
  • Depending on the result, TypeScript narrows the type and allows us to call toUpperCase() for strings and toFixed() for numbers.

Example 2: Using in as a Type Guard

The in keyword can be used as a type guard to check for the existence of a property in an object:

interface Bird {
    fly: () => void;
    layEggs: () => void;
}
  
interface Fish {
    swim: () => void;
    layEggs: () => void;
}

function getAnimalAction(animal: Bird | Fish) {
    if ('fly' in animal) {
        animal.fly();
        console.log("This animal can fly!");
    } else {
        animal.swim();
        console.log("This animal can swim!");
    }
}

let myBird: Bird = {
    fly: () => console.log("Bird flying..."),
    layEggs: () => console.log("Bird laying eggs...")
};

let myFish: Fish = {
    swim: () => console.log("Fish swimming..."),
    layEggs: () => console.log("Fish laying eggs...")
};

getAnimalAction(myBird); // Output: Bird flying...
                         //         This animal can fly!
getAnimalAction(myFish); // Output: Fish swimming...
                         //         This animal can swim!

In this example:

  • We define two interfaces: Bird and Fish.
  • Both interfaces share a common method layEggs, but Bird has fly and Fish has swim.
  • In the function getAnimalAction, we use the in operator to check which properties exist in the animal object.
  • Depending on the property, TypeScript understands the specific type and allows us to call the appropriate method.

Example 3: User-defined Type Guard

We can also create our own type guard functions that return a boolean value asserting the type of the variable within a certain scope:

class Dog {
    bark() {
        console.log("Woof woof!");
    }
}

class Cat {
    meow() {
        console.log("Meow meow!");
    }
}

function isDog(pet: Dog | Cat): pet is Dog {
    return (pet as Dog).bark !== undefined;
}

function getPetSound(pet: Dog | Cat) {
    if (isDog(pet)) {
        pet.bark(); 
    } else {
        // TypeScript knows that if pet is not a Dog, then it must be a Cat.
        pet.meow();
    }
}

let myDog: Dog = new Dog();
let myCat: Cat = new Cat();

getPetSound(myDog); // Output: Woof woof!
getPetSound(myCat); // Output: Meow meow!

In this example:

  • We create two classes Dog and Cat with distinct methods.
  • We then write a user-defined type guard isDog that returns true if the passed pet argument has a bark method.
  • Finally, in getPetSound function, when we check using isDog, TypeScript treats pet as a Dog inside the if block, and as a Cat in its else clause.

Understanding Discriminated Unions

A discriminated union is a form of a tagged union, wherein each member of the union has a property, typically called a discriminator, that has a unique literal type.

Example 1: Basic Discriminated Unions

interface Circle {
    kind: "circle";
    radius: number;
}

interface Square {
    kind: "square";
    sideLength: number;
}

type Shape = Circle | Square;

function getArea(shape: Shape) {
    switch(shape.kind) {
        case "circle":
            // Inside this block, TypeScript knows shape is a Circle.
            return Math.PI * shape.radius ** 2;
        case "square":
            // Inside this block, TypeScript knows shape is a Square.
            return shape.sideLength ** 2;
    }
}

console.log(getArea({kind: 'circle', radius: 5})); // Output: 78.53981633974483
console.log(getArea({kind: 'square', sideLength: 5})); // Output: 25

In this example:

  • We define two types: Circle and Square.
  • Both have a kind property which uniquely discriminates them from each other.
  • When handling getArea function, TypeScript uses the discriminant (shape.kind) to understand the type and narrow down the shape's type within each case.

Example 2: Enhancing Discriminated Union with Additional Properties

interface Bird {
    type: "bird";
    beakLength: number;
}

interface Fish {
    type: "fish";
    scaleTexture: string;
}

type Animal = Bird | Fish;

function describeAnimal(animal: Animal) {
    switch(animal.type) {
        case "bird":
            return "I am a bird with a beak length of " + animal.beakLength + " cm";
        case "fish":
            return "I am a fish with a scale texture like " + animal.scaleTexture;
    }
}

// Create an instance of Bird and Fish to test
let sparrow: Bird = { type: 'bird', beakLength: 4 };
let goldFish: Fish = { type: 'fish', scaleTexture: "shiny" };

console.log(describeAnimal(sparrow));  // Output: I am a bird with a beak length of 4 cm
console.log(describeAnimal(goldFish)); // Output: I am a fish with a scale texture like shiny

In this example:

  • The interfaces Bird and Fish both possess a type property which serves the purpose of discrimination.
  • Each interface adds more relevant details specific to their type (beakLength for birds and scaleTexture for fish).
  • The describeAnimal function switches based on these discriminants and accesses type-specific properties.

Example 3: Including a Default Case

It is good practice to include a default case when working with discriminated unions to catch any future possible variants that might be added.

type Animal = Bird | Fish | Reptile;

interface Reptile {
    type: "reptile";
    scaleColor: string;
    hasTail: boolean;
}

function describeAnimal(animal: Animal) {
    switch(animal.type) {
        case "bird":
            return "I am a bird with a beak length of " + animal.beakLength + " cm";
        case "fish":
            return "I am a fish with a scale texture like " + animal.scaleTexture;
        case "reptile":
            return `I am a reptile with ${animal.hasTail ? 'a' : 'no'} tail and scales are ${animal.scaleColor}.`;
        default:
            const exhaustiveCheck: never = animal;
            throw new Error(`Unknown animal type: ${exhaustiveCheck}`);
    }
}

// Test cases
let turtle: Reptile = { type: 'reptile', scaleColor: 'green', hasTail: true };

console.log(describeAnimal(turtle)); // Output: I am a reptile with a tail and scales are green.

In this example:

  • We introduce a third variant Reptile.
  • Inside describeAnimal, the default case ensures that every possible variant is handled.
  • If a new variant were added without a corresponding switch-case, the compiler would complain because animal would now have a different structure than the never type, thus catching an error during compilation.

Top 10 Interview Questions & Answers on TypeScript Type Guards and Discriminated Unions

Top 10 Questions and Answers on TypeScript Type Guards and Discriminated Unions

1. What are Type Guards in TypeScript?

function isString(val: any): val is string {
  return typeof val === 'string';
}

function example(val: string | number) {
  if (isString(val)) {
    // val is typed as string here
    console.log(val.toUpperCase());
  } else {
    // val is typed as number here
    console.log(val.toFixed(2));
  }
}

2. Can we use typeof and instanceof as Type Guards?

Answer: Yes, both typeof and instanceof can serve as type guards in TypeScript.

  • typeof works well for primitive types like string, number, boolean, and symbol.
  • instanceof is used to check against classes for object instances.
function example(val: string | number) {
  if (typeof val === 'string') {
    console.log(val.charAt(0));
  } else {
    console.log(val.toFixed(2));
  }
}

class Dog {}
class Cat {}

function animalExample(animal: Dog | Cat) {
  if (animal instanceof Dog) {
    // animal is typed as Dog here
    console.log('Woof');
  } else {
    // animal is typed as Cat here
    console.log('Meow');
  }
}

3. What are Discriminated Unions in TypeScript?

Answer: Discriminated Unions are a powerful pattern in TypeScript that involves defining a common property or literal type across different types (often an enum or a specific string literal), which TypeScript uses to discriminate between them.

type Bird = {
  type: 'bird';
  canFly: boolean;
};

type Fish = {
  type: 'fish';
  swims: boolean;
};

type Animal = Bird | Fish;

function move(animal: Animal) {
  switch (animal.type) {
    case 'bird':
      console.log(animal.canFly ? 'Flying' : 'Cant fly');
      break;
    case 'fish':
      console.log(animal.swims ? 'Swimming' : 'Cant swim');
      break;
  }
}

4. Why do we need Discriminated Unions?

Answer: Discriminated Unions provide a way to model more sophisticated data types and behaviors. They enable type safety, reduce runtime errors, and make the code more maintainable by ensuring that every possible case is handled. Using a discriminating property means TypeScript can infer the shape of the object based on the value of that property.

5. How do I create a User-Defined Type Guard?

Answer: User-Defined Type Guards are custom functions that return a boolean and have a type predicate in the form parameterName is Type. This type predicate informs TypeScript that the parameter inside the function is of the given type if the function returns true.

interface Cat {
  kind: 'cat';
  name: string;
  meow: true;
}

interface Dog {
  kind: 'dog';
  name: string;
  woof: true;
}

type Animal = Cat | Dog;

function isCat(animal: Animal): animal is Cat {
  return animal.kind === 'cat';
}

function makeAnimalSpeak(animal: Animal) {
  if (isCat(animal)) {
    console.log('Meow');
  } else {
    console.log('Woof');
  }
}

6. Explain the concept of Type Predicates in TypeScript.

Answer: Type Predicates in TypeScript are expressions used in a user-defined type guard function to tell the TypeScript compiler about the type of a variable within a particular scope. The syntax of a predicate is parameterName is Type.

For example, animal is Cat acts as a type predicate in the isCat function, which returns true only if the animal is of type Cat.

7. Can Discriminated Unions be used with classes?

Answer: While Discriminated Unions are commonly used with interfaces or types, they can also work with classes. However, it requires defining a common property that acts as the discriminator.

class Circle {
  kind: 'circle' = 'circle';
  radius: number;
  constructor(radius: number) {
    this.radius = radius;
  }
}

class Square {
  kind: 'square' = 'square';
  side: number;
  constructor(side: number) {
    this.side = side;
  }
}

type Shape = Circle | Square;

function getArea(shape: Shape) {
  switch (shape.kind) {
    case 'circle':
      return Math.PI * shape.radius ** 2;
    case 'square':
      return shape.side ** 2;
  }
}

8. In what scenarios should Discriminated Unions be preferred over Type Guards?

Answer: Discriminated Unions are preferred when you have multiple related types with common properties or fields that identify each type. They are especially useful in modeling large union types and avoiding runtime errors due to unhandled cases. Type Guards, on the other hand, are more flexible and can be used in a variety of situations that don’t necessarily involve unions.

9. What happens if I forget a case in a Discriminated Union?

Answer: If you forget a case in a Discriminated Union, TypeScript will flag an error unless you use a default case or ensure that all possible cases are explicitly handled. This is because Discriminated Unions require all cases to be accounted for to maintain type safety.

type Action = { type: 'ADD'; payload: number } | { type: 'SUBTRACT'; payload: number };

function handleAction(action: Action) {
  switch (action.type) {
    case 'ADD':
      return action.payload;
    case 'SUBTRACT':
      return -action.payload;
    // TypeScript will error if this default case is not included
    default:
      const exhaustiveCheck: never = action;
      return exhaustiveCheck;
  }
}

10. Can Type Guards and Discriminated Unions be used together?

Answer: Absolutely, Type Guards and Discriminated Unions can be used together to create more robust and flexible type-safe code. Discriminated Unions provide a clear, structured way to handle different types, while Type Guards can be used to further refine and check types within functions or conditional blocks.

You May Like This Related .NET Topic

Login to post a comment.