Simplifying Type Narrowing and Guards in TypeScript

Abdul Ahad Abeer - Oct 9 - - Dev Community

Introduction to Narrowing Concept

Typescript documentation explains this topic really well. I am not going to copy and paste the same description here, rather I want to make it even simpler and shorter. Let’s look at a problem and see how the concept of narrowing helps us when coding in TypeScript.

Look at the code very carefully. The explanation is written below the code snippet.

function padLeft(padding: number | string, input: string): string {
    return " ".repeat(padding) + input;
}
Enter fullscreen mode Exit fullscreen mode

If padding is a number, it will be treated as the number of spaces we want to prepend to input since padding passed through repeat means padding has to be a number value.

Now, the moment padLeft function is called, it throws an error which looks somewhat like this:

"Argument of type 'string | number' is not assignable to parameter of type 'number'. Type 'string' is not assignable to type 'number'."

TypeScript is warning us that we’re passing a value with type number | string to the repeat function, which only accepts a number, and it’s right. In other words, we haven’t explicitly checked if padding is a number first, nor are we handling the case where it’s a string, so let’s do exactly that.

function padLeft(padding: number | string, input: string): string {
  if (typeof padding === "number") {
    return " ".repeat(padding) + input;
  }
  return padding + input;
}
Enter fullscreen mode Exit fullscreen mode

When we write if (typeof padding === "number"), TypeScript knows we are checking if padding is a number. This kind of check is called a "type guard." TypeScript looks at the code and tries to figure out the exact type of a value. When TypeScript makes a type more specific through these checks, it’s called "narrowing."

Type Guards

Type Guards are runtime checks that allow TypeScript to infer a more specific type of a variable within a conditional block. TypeScript uses these checks to "guard" against invalid operations by ensuring that the variable has the expected type.

The most common built-in type guards in TypeScript are:

  • typeof: Checks if a variable is a primitive type (e.g., string, number, boolean, etc.)

  • instanceof: Checks if a variable is an instance of a class or constructor function.

There is an example of typeof in the previous section where you can see how typeof narrows down a union type to a specific one.

But here is an example of using instanceof:

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

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

function animalSound(animal: Dog | Cat) {
  if (animal instanceof Dog) {
    animal.bark(); // TypeScript knows `animal` is a `Dog` here
  } else {
    animal.meow(); // TypeScript knows `animal` is a `Cat` here
  }
}
Enter fullscreen mode Exit fullscreen mode

In this example, instanceof is used to narrow the type of animal to either Dog or Cat. This allows us to safely call the appropriate method (bark() or meow()) based on the type of animal.

Truthiness narrowing

Truthiness might not be a word you’ll find in the dictionary, but it’s very much something you’ll hear about in JavaScript.

Truthiness narrowing in TypeScript refers to the way TypeScript refines types based on conditions (&&s, ||s, if statements, Boolean negations (!), and more) that check if a value is "truthy" or "falsy." Values like false, 0, null, undefined, NaN, and "" (empty string) are considered falsy, while all other values are truthy.

When you use an if statement, TypeScript automatically narrows the type by excluding falsy values from the possible types.

function printMessage(message: string | null) {
  if (message) {
    // TypeScript knows 'message' can't be null here, it's narrowed to 'string'
    console.log(message.toUpperCase());
  } else {
    console.log("No message provided.");
  }
}

printMessage("Hello");   // Output: HELLO
printMessage(null);      // Output: No message provided.
Enter fullscreen mode Exit fullscreen mode

In the if (message) block, TypeScript narrows the type from string | null to just string, since null is falsy and won't pass the condition.

You can always convert a value to a boolean by using the Boolean function or by using !! (double negation). The !! method has an advantage: TypeScript understands it as a strict true or false, while the Boolean function just gives a general boolean type.

const value = "Hello";

// Using Boolean function
const bool1 = Boolean(value);  // type: boolean

// Using double negation (!!)
const bool2 = !!value;         // type: true
Enter fullscreen mode Exit fullscreen mode

To be specific understand this:

  • bool1's value is a boolean type value, meaning it can be either true or false. TypeScript just knows it's a boolean, but it doesn't specify which one.

  • bool2's value is considered a literal type—either exactly true or exactly false, depending on the value of !!value. In this case, since "Hello" is truthy, bool2 will be of type true.

Equality Narrowing

Equality narrowing in TypeScript happens when TypeScript refines the type of a variable based on an equality check (like === or !==). This means that after the check, TypeScript can "narrow" the possible types of a variable because it now knows more about it.

function example(value: string | number) {
  if (typeof value === "number") {
    // Here, TypeScript knows value is a number
    console.log(value.toFixed(2));  // Safe to use number methods
  } else {
    // In this block, TypeScript narrows value to string
    console.log(value.toUpperCase());  // Safe to use string methods
  }
}
Enter fullscreen mode Exit fullscreen mode

The in operator narrowing

The in operator narrowing in TypeScript helps us figure out if an object has a specific property, and it also helps TypeScript refine (narrow) the types based on that check.

When you use "propertyName" in object, TypeScript checks if the object (or its prototype) has the property. Based on whether the check is true or false, TypeScript can understand more about the type of that object.

  • If the check is true (the property exists), TypeScript narrows the object’s type to include the types that have this property (either required or optional).

  • If the check is false (the property doesn't exist), TypeScript narrows the type to exclude types that have this property.

type Cat = { meow: () => void };
type Dog = { bark: () => void };

function speak(animal: Cat | Dog) {
  if ("meow" in animal) {
    // TypeScript now knows 'animal' must be a Cat
    animal.meow();
  } else {
    // TypeScript now knows 'animal' must be a Dog
    animal.bark();
  }
}
Enter fullscreen mode Exit fullscreen mode

In the if ("meow" in animal) check, TypeScript checks if the animal has the meow method. If it does, TypeScript knows the animal is a Cat. If not, TypeScript knows it’s a Dog.

Using type predicates

Type predicates in TypeScript are a way to narrow down the type of a variable using a function that returns a boolean. They help TypeScript understand what type a variable is after the check.

A type predicate is written as parameterName is Type. When you use this in a function, TypeScript knows that if the function returns true, the parameter is of that specific type.

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

function isFish(animal: Fish | Bird): animal is Fish {
  return (animal as Fish).swim !== undefined;  // Check if animal has a swim method
}

function move(animal: Fish | Bird) {
  if (isFish(animal)) {
    // TypeScript knows 'animal' is a Fish here
    animal.swim();
  } else {
    // TypeScript knows 'animal' is a Bird here
    animal.fly();
  }
}
Enter fullscreen mode Exit fullscreen mode

Simple explanation:

  • The isFish function checks if the animal has a swim method.

  • If it does, the function returns true, and TypeScript understands that animal is a Fish.

  • If it doesn't, TypeScript knows it's a Bird.

Discriminated unions

Discriminated Unions are a pattern in TypeScript where you can use a common property (called a "discriminant") to differentiate between different object types in a union.

Look at the following example.

interface Car {
  kind: "car";
  drive: () => void;
}

interface Bike {
  kind: "bike";
  pedal: () => void;
}

type Vehicle = Car | Bike;

function operateVehicle(vehicle: Vehicle) {
  switch (vehicle.kind) {
    case "car":
      vehicle.drive(); // TypeScript knows `vehicle` is a `Car` here
      break;
    case "bike":
      vehicle.pedal(); // TypeScript knows `vehicle` is a `Bike` here
      break;
  }
}
Enter fullscreen mode Exit fullscreen mode

Here, the kind property is the discriminant. TypeScript uses it to narrow the Vehicle type to either Car or Bike based on the value of kind.

Exhaustiveness checking

Exhaustiveness checking in TypeScript ensures that all possible cases of a union type are handled in your code. When you use type narrowing (like with if statements or switch cases), TypeScript checks if all types in a union have been considered. If any case is missing, TypeScript will give you an error, helping you catch potential bugs.

type Cat = { type: "cat"; meow: () => void };
type Dog = { type: "dog"; bark: () => void };
type Animal = Cat | Dog;

function makeSound(animal: Animal) {
  switch (animal.type) {
    case "cat":
      animal.meow();  // TypeScript knows 'animal' is Cat here
      break;
    case "dog":
      animal.bark();  // TypeScript knows 'animal' is Dog here
      break;
    default:
      // This will give an error if we add more animal types
      const _exhaustiveCheck: never = animal;
      throw new Error(`Unknown animal: ${animal}`);
  }
}
Enter fullscreen mode Exit fullscreen mode

The Animal type is a union of Cat and Dog. In the makeSound function, we check the type property of animal.

Exhaustiveness Check:

  • If we handle both cat and dog, everything is fine.

  • If we later add another animal type, like Bird, and forget to update the switch statement, TypeScript will show an error at the default case. This error happens because the default case is assigned a never type, meaning it shouldn’t happen if all possible types are accounted for.

Other Ways of Type Narrowing

Assignments: when we assign to any variable, TypeScript looks at the right side of the assignment and narrows the left side appropriately.

let x = Math.random() < 0.5 ? 10 : "hello world!";
Enter fullscreen mode Exit fullscreen mode

Type narrowing with never type: The never type in TypeScript represents values that never occur. It is often used to indicate unreachable code, such as when a function always throws an error or has an infinite loop. When TypeScript recognizes a never type, it can narrow down types effectively.

function assertIsString(value: string | number) {
  if (typeof value !== "string") {
    // If value is not a string, throw an error
    throw new Error("Not a string!");
  }
  // Here, TypeScript knows 'value' is a string
  console.log(value.toUpperCase());
}
Enter fullscreen mode Exit fullscreen mode

In the assertIsString function, if value is not a string, we throw an error. TypeScript understands that if it reaches the console.log, value must be a string. If it were anything else, the function would not complete normally, leading to a never type.

Last Words

I am sure you got the point of narrowing concept. We can have other possible ways to narrow down types. But I believe the explanation and knowledge you have gained so far from this article is more than enough to grasp the concept and utilize it. Let me know if this was beneficial for you.

. . . . . . . . . . . . .
Terabox Video Player