Understanding and Implementing Type Guards In TypeScript

Updated on · 9 min read
Understanding and Implementing Type Guards In TypeScript

TypeScript, an open-source language developed and maintained by Microsoft, is a superset of JavaScript that adds static types to the language. It has grown to include such advanced features as generic types, conditional types, template literal types, tools for inferring types and mapped types, among others. While TypeScript's static typing system is a tremendous asset, it can sometimes be tricky to determine the type of an object at runtime, particularly when dealing with complex data structures or object-oriented programming constructs. This is where type guards come in.

Type guards are a powerful feature in TypeScript that allows you to narrow down the type of an object within a certain scope. With type guards, you can perform specific checks to determine the type of an object and then use that object in a way that is type-safe according to the TypeScript compiler.

For instance, if you have a variable that could be a string or a number, you can use a type guard to check if the variable is a string. If the type guard check passes, TypeScript will then allow you to use the variable as a string within the scope of the type guard check.

Understanding and effectively using type guards is an essential skill when programming in TypeScript. Not only can they help to prevent type-related bugs, but they also make code cleaner and easier to understand. In the following sections, we'll look into the concept of type guards, explore the different types of type guards that TypeScript offers, and learn how to use them in practical scenarios.

What are type guards

TypeScript is designed to help developers manage the types of their variables and function returns in a way that's more robust than regular JavaScript. When multiple types are possible, such as in union types, the compiler cannot safely predict the type without help. This is where type guards come into play. They are used to inform the compiler about the type of a variable inside a block of code, which allows the compiler to assume the correct type and expose the relevant properties and methods. This is crucial in TypeScript because it allows you to achieve more robust error handling and write safer code by ensuring that you're using variables according to their actual types.

For instance, let's say we have a variable that could either be a string or an array of strings. If we want to use a method that is specific to the array type, like push(), we would first need to ensure that the variable is indeed an array. We can achieve this using a type guard.

typescript
let variable: string | string[]; if (Array.isArray(variable)) { // Within this if block, TypeScript now knows that 'variable' is an array of strings variable.push("new item"); } else { // Here, TypeScript knows that 'variable' is a string console.log(variable.toUpperCase()); }
typescript
let variable: string | string[]; if (Array.isArray(variable)) { // Within this if block, TypeScript now knows that 'variable' is an array of strings variable.push("new item"); } else { // Here, TypeScript knows that 'variable' is a string console.log(variable.toUpperCase()); }

In this code, Array.isArray(variable) is the type guard. If this expression evaluates to true, TypeScript knows that variable is an array of strings within the following block. If it evaluates to false, TypeScript knows that variable is a string in the else block.

Type guards are essential for writing robust, bug-free TypeScript code. They provide a way to ensure that you're using variables correctly according to their types, and they help the TypeScript compiler understand the types of variables in different parts of your code.

If you're curious about other advanced TypeScript techniques, you may find this article helpful: TypeScript Template Literal Types: Practical Use-Cases for Improved Code Quality.

Types of type guards in TypeScript and practical examples

TypeScript provides several mechanisms to implement type guards. Here, we'll discuss the most commonly used ones - typeof, instanceof, and user-defined type guards - and illustrate their usage with practical examples.

Using typeof for primitives

The typeof type guard is one of the simplest and most common ways to narrow down types in TypeScript. It checks whether a variable is of a certain primitive type, like string, number, boolean or symbol.

typescript
function processInput(input: string | number) { if (typeof input === "string") { return input.toUpperCase(); // TypeScript knows 'input' is a string here } else { return input.toFixed(2); // TypeScript knows 'input' is a number here } }
typescript
function processInput(input: string | number) { if (typeof input === "string") { return input.toUpperCase(); // TypeScript knows 'input' is a string here } else { return input.toFixed(2); // TypeScript knows 'input' is a number here } }

In this function, input can either be a string or a number. The typeof type guard checks the type of input, and depending on the result, the function performs different operations.

Using instanceof for class instances

The instanceof type guard is used for narrowing down types when dealing with classes and their instances. It checks whether an object is an instance of a particular class. This is particularly useful when working with complex object-oriented patterns where an object could be an instance of one of several possible classes in a hierarchy.

typescript
class Dog { bark() { return "Woof!"; } } class Cat { purr() { return "Meow!"; } } function makeSound(animal: Dog | Cat) { if (animal instanceof Dog) { return animal.bark(); // TypeScript knows 'animal' is a Dog } else { return animal.purr(); // TypeScript knows 'animal' is a Cat } }
typescript
class Dog { bark() { return "Woof!"; } } class Cat { purr() { return "Meow!"; } } function makeSound(animal: Dog | Cat) { if (animal instanceof Dog) { return animal.bark(); // TypeScript knows 'animal' is a Dog } else { return animal.purr(); // TypeScript knows 'animal' is a Cat } }

In this example, animal could be an instance of either the Dog or Cat class. The instanceof type guard checks the class of animal, and the function calls the appropriate method based on the result.

User-defined type guards

TypeScript also allows you to define your own type guards. These are useful when you want to perform more complex type checks.

A user-defined type guard is a function that returns a type predicate (i.e., a boolean expression that is dependent on a type). Here's how you can define one, on the example of working with form event types:

typescript
interface ChangeEvent { type: "change"; target: HTMLInputElement; value: string; } interface SubmitEvent { type: "submit"; target: HTMLFormElement; fields: Record<string, string>; } // User-defined type guard to determine if the event is a ChangeEvent function isChangeEvent(event: ChangeEvent | SubmitEvent): event is ChangeEvent { return event.type === "change"; } // Function to handle form events, utilizing the user-defined type guard function handleFormEvent(event: ChangeEvent | SubmitEvent) { if (isChangeEvent(event)) { // The TypeScript compiler now knows that 'event' is a ChangeEvent console.log( `Handling change event on field: ${event.target.name}, with value: ${event.value}`, ); } else { // Since the event is not a ChangeEvent, it must be a SubmitEvent console.log("Handling form submit event with fields:", event.fields); } }
typescript
interface ChangeEvent { type: "change"; target: HTMLInputElement; value: string; } interface SubmitEvent { type: "submit"; target: HTMLFormElement; fields: Record<string, string>; } // User-defined type guard to determine if the event is a ChangeEvent function isChangeEvent(event: ChangeEvent | SubmitEvent): event is ChangeEvent { return event.type === "change"; } // Function to handle form events, utilizing the user-defined type guard function handleFormEvent(event: ChangeEvent | SubmitEvent) { if (isChangeEvent(event)) { // The TypeScript compiler now knows that 'event' is a ChangeEvent console.log( `Handling change event on field: ${event.target.name}, with value: ${event.value}`, ); } else { // Since the event is not a ChangeEvent, it must be a SubmitEvent console.log("Handling form submit event with fields:", event.fields); } }

In this example, isChangeEvent is a user-defined type guard that checks if an event is a ChangeEvent. The event is ChangeEvent syntax is a type predicate that tells TypeScript that the result of the function is a boolean expression that depends on the type of event. This allows TypeScript to narrow down the type of event within the handleFormEvent function based on the result of the type guard check.

Advanced usage

Type guards are a powerful feature in TypeScript that can be used in a variety of scenarios. In this section, we'll explore some advanced use cases for type guards, including working with union types and discriminated unions.

Narrowing and discriminated unions with type guards

Discriminated unions are a pattern in TypeScript that allows you to combine union types and literal types to create a type that can be a set of specific values. This is particularly useful in scenarios where you have a variable that could be one of several different shapes.

typescript
interface Circle { kind: "circle"; radius: number; } interface Square { kind: "square"; sideLength: number; } type Shape = Circle | Square;
typescript
interface Circle { kind: "circle"; radius: number; } interface Square { kind: "square"; sideLength: number; } type Shape = Circle | Square;

In this case, the Shape type is a discriminated union of Circle and Square. The kind property acts as a discriminant, allowing you to differentiate between the possible types.

To narrow a discriminated union, you can use a type guard that checks the discriminant property:

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

In the getArea function, TypeScript can narrow down the type of shape within each case branch based on the kind property. This allows you to access the appropriate properties for each type without any type errors.

Using the "in" operator

The in operator is another way to create type guards in TypeScript. It can be particularly useful when working with objects and there's a need to check if they contain a certain property.

Let's extend our Shape example with a new Rectangle type:

typescript
interface Circle { kind: "circle"; radius: number; } interface Square { kind: "square"; sideLength: number; } interface Rectangle { kind: "rectangle"; width: number; height: number; } type Shape = Circle | Square | Rectangle;
typescript
interface Circle { kind: "circle"; radius: number; } interface Square { kind: "square"; sideLength: number; } interface Rectangle { kind: "rectangle"; width: number; height: number; } type Shape = Circle | Square | Rectangle;

In our getArea function, we can now use the in operator as a type guard:

typescript
function getArea(shape: Shape) { if ("radius" in shape) { // shape is treated as Circle here return Math.PI * shape.radius ** 2; } else if ("sideLength" in shape) { // shape is treated as Square here return shape.sideLength ** 2; } else if ("width" in shape && "height" in shape) { // shape is treated as Rectangle here return shape.width * shape.height; } }
typescript
function getArea(shape: Shape) { if ("radius" in shape) { // shape is treated as Circle here return Math.PI * shape.radius ** 2; } else if ("sideLength" in shape) { // shape is treated as Square here return shape.sideLength ** 2; } else if ("width" in shape && "height" in shape) { // shape is treated as Rectangle here return shape.width * shape.height; } }

In this example, the in operator checks whether specific properties (radius, sideLength, width, height) exist in the shape object. Depending on which check passes, TypeScript narrows the type of shape within that branch of the code, allowing us to access the appropriate properties of each shape without any type errors.

Type guards with mapped types

Mapped types in TypeScript create new types by transforming properties from an existing type, usually involving some operation on each property. When an object's shape is transformed using a mapped type, type guards help in verifying that the object still conforms to a specific structure.

typescript
// Original type type OriginalPerson = { name: string; age: number; hasPet: boolean; }; // Mapped type that makes all properties optional type PartialPerson = { [P in keyof OriginalPerson]?: OriginalPerson[P]; }; // Type guard for PartialPerson function isPartialPerson(person: any): person is PartialPerson { return ( ("name" in person && typeof person.name === "string") || ("age" in person && typeof person.age === "number") || ("hasPet" in person && typeof person.hasPet === "boolean") ); } // Function that takes an object and a mapped type as arguments function processPerson<T extends PartialPerson>(person: T): void { if (isPartialPerson(person)) { // TypeScript safely infers 'person' to be 'PartialPerson' within this block console.log("Processed PartialPerson:", person); } } const maybePerson = { name: "Bob", age: 30, }; processPerson(maybePerson); // OK, 'maybePerson' satisfies 'PartialPerson'
typescript
// Original type type OriginalPerson = { name: string; age: number; hasPet: boolean; }; // Mapped type that makes all properties optional type PartialPerson = { [P in keyof OriginalPerson]?: OriginalPerson[P]; }; // Type guard for PartialPerson function isPartialPerson(person: any): person is PartialPerson { return ( ("name" in person && typeof person.name === "string") || ("age" in person && typeof person.age === "number") || ("hasPet" in person && typeof person.hasPet === "boolean") ); } // Function that takes an object and a mapped type as arguments function processPerson<T extends PartialPerson>(person: T): void { if (isPartialPerson(person)) { // TypeScript safely infers 'person' to be 'PartialPerson' within this block console.log("Processed PartialPerson:", person); } } const maybePerson = { name: "Bob", age: 30, }; processPerson(maybePerson); // OK, 'maybePerson' satisfies 'PartialPerson'

In the example above, isPartialPerson is a type guard that checks if the input person object matches the PartialPerson mapped type structure. If the check passes, TypeScript narrows the type of person within the processPerson function, allowing us to safely use it as a PartialPerson.

Common mistakes and best practices

Type guards in TypeScript are a powerful tool for ensuring type safety in your code. However, like any tool, they must be used correctly to be effective. In this chapter, we'll look at some common mistakes developers make when using type guards and discuss best practices for using them effectively.

  • Over-reliance on any type: One of the most common mistakes in TypeScript development is overusing the any type, which essentially bypasses TypeScript's type checking. Instead of relying on this catch-all type, when using type guards, always aim to use precise types.

  • Incorrect use of typeof and instanceof: These type guards are beneficial but should be used correctly. For instance, typeof is best suited for primitive types (like string, number, boolean), while instanceof is appropriate for custom classes. It's important to understand the correct usage of these type guards and apply them properly in your code.

  • Not considering all possible types in a union: When using type guards with union types, a common mistake is not handling all possible types within the union. This oversight could lead to runtime errors. To counter this, always handle all possible types in a union. One approach is to use exhaustive type checking, using a never type in a default case in a switch statement. This technique will cause TypeScript to throw an error if there are unhandled cases, helping to prevent bugs.

  • Missing out on user-defined type guards: TypeScript offers the ability to create custom type guards, but developers often overlook this feature. User-defined type guards are especially beneficial when working with complex types as they can encapsulate complicated type-checking logic, improving code readability and maintainability.

  • Not using discriminated unions for complex types: For complex types with shared fields, developers sometimes miss the opportunity to use discriminated unions. This misstep can lead to more complicated and error-prone type-checking code. Discriminated unions allow you to differentiate between types based on a common "tag" or "kind" field, making it easier to handle different types in a type-safe manner. Always consider using discriminated unions when dealing with complex types.

By avoiding common pitfalls and adhering to best practices, you can make effective use of type guards to improve the safety and reliability of your TypeScript code. It's well worth investing the time to understand and correctly use this powerful feature of the TypeScript language.

Conclusion

In this article, we've explored the importance and usage of type guards in TypeScript. From understanding what type guards are to diving into advanced topics like union types and discriminated unions, hopefully, you've gained a deeper understanding of this crucial aspect of TypeScript's type system.

Type guards are a powerful tool that can help you write safer, more reliable code. While they can seem complex at first, with practice and understanding, they become an invaluable part of your TypeScript toolkit.

By leveraging the power of type guards, you can take full advantage of TypeScript's static typing, leading to better, more predictable, and less error-prone code.

References and resources