User-defined type guards

8 minutes read

To make your TypeScript code more reliable and easier to maintain, it's important to understand the differences between built-in and user-defined type guards. Built-in type guards are good for basic type checking but might not suffice for more complex or unique situations. That's where user-defined type guards come in. They let developers create custom type checks that are more precise and tailored to specific scenarios, going beyond what TypeScript's built-in guards can do. In this topic, we will explore user-defined type guards in TypeScript. We will also look into how they differ from built-in type checks, the benefits they offer, and how to use them effectively in real-world coding situations.

Implementation of user-defined type guards

User-defined type guards let you create custom functions to check if a variable matches a specific type. These type guards are useful for complex types that TypeScript can't determine on its own or through the use of built-in type guards alone. You define them within a function that returns either true or false, with the function signature indicating the type being checked.

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

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

To clarify the explanation, consider the simple code example above. In this example, we define types of animals using interfaces: Fish and Bird. Each animal interface has a specific method: swim for Fish and fly for Bird. This setup facilitates the identification of the animal type we're dealing with, allowing us to choose the appropriate action for each one.

In this case, we can't use typeof or instanceof for type checking because they are intended for primitive types or class instances, not interfaces. Since we're using interfaces to define our animal types (Fish and Bird), we need a more distinctive method to identify them. Now, let's look at the following code:

function isFish(animal: Fish | Bird): boolean {
  return (animal as Fish).swim !== undefined;
}

function move(animal: Fish | Bird) {
  if (isFish(animal)) {
    // Saying that `animal` is a Fish
    (animal as Fish).swim();
  } else {
    // Since it's not a Fish, we say that `animal` must be a Bird
    (animal as Bird).fly();
  }
}

In this TypeScript code, we have a function called isFish that checks if an animal is a Fish. This function serves as a user-defined type guard, which Lisa TypeScript in understanding the type of the variable. When the function returns true, TypeScript knows that the variable animal is of the Fish type. Additionally, this code introduces the as keyword, utilized in (animal as Fish) for type assertion. By using the as keyword, we instruct TypeScript to treat our variable's type as the one we've declared. In our case, we're asserting that 'animal' should be treated as a Fish. We then proceed with type checking to see if animal possesses a swim method, which is expected of a Fish. If the swim method is there, then animal is indeed a Fish.

In the move function, we can now use the isFish function to determine the animal's type. If the animal is a Fish, TypeScript will recognise animal as a Fish, and we can call the swim method. Otherwise, TypeScript will deduce that animal must be a Bird, and we can call the fly method after asserting the animal as a Bird with the as keyword. This way, TypeScript gives us more accurate type information based on the isFish function's results.

Using type predicates and more practices

Another common practice for creating user-defined type guards involves using type predicates. In TypeScript, a type predicate is a function that checks a variable's type during runtime. When crafting a type predicate, you use the 'is' keyword in the format parameterName is Type in a conditional block to check the variable's type. This tells TypeScript that the function is verifying whether the parameter is of a specific type. Type predicates can be used on both primitive and complex types, including interfaces and classes.

interface StringContainer {
  value: string;
  format: () => string;
}

interface NumberContainer {
  value: number;
  round: () => number;
}

Let's take a closer look at how to use type predicates in creating user-defined type guards in TypeScript. In the example above, we define two interfaces, StringContainer and NumberContainer. StringContainer has a value property that is a string and a format method. NumberContainer has a value property that is a number and a round method.

function isStringContainer(container: StringContainer | NumberContainer): container is StringContainer {
  return typeof container.value === 'string';
}

Here, we have our user-defined type guard. The function isStringContainer takes a container, which could be either a StringContainer or a NumberContainer. It uses the built-in typeof operator to check if the value property of container is a string. If so, the function confirms that the container is a StringContainer. The function's return type, container is StringContainer, uses the type predicate 'is'. This tells TypeScript that the container is indeed a StringContainer wherever this function returns true. Also, this example shows how we can combine built-in type guards with our own, exemplified by the use of the typeof operator.

function processContainer(container: StringContainer | NumberContainer) {
  if (isStringContainer(container)) {
    console.log(container.format());
  } else {
    console.log(container.round());
  }
}

In the last part of our code, we have a function named processContainer that takes a container, which could be either a StringContainer or a NumberContainer. Here, we use our isStringContainer type guard in an if statement. If isStringContainer(container) returns true, TypeScript infers that container is a StringContainer within the if block, allowing us to safely call the format method. Conversely, if isStringContainer(container) returns false, TypeScript concludes that container must be a NumberContainer within the else block, enabling us to safely call the round method.

In TypeScript, user-defined type guards, such as the one shown in our isStringContainer example, offer a flexible approach to performing detailed type checks at runtime. They can include built-in type guards and type predicates, but they aren't restricted to these examples. We can tailor them to meet the specific needs of our code.

Conclusion

TypeScript's user-defined type guards are really helpful in enhancing code stability and manageability. They fill the gaps left by built-in type guards, allowing for more precise and customized type checking in complex scenarios. By utilizing type predicates, type assertions, and built-in type guards, user-defined type guards provide a flexible means to check types at runtime, resulting in robust and adaptable code.

How did you like the theory?
Report a typo