What is "User-Defined Type Guard" in TypeScript?
Tags: typescript, learningtypescript, beginners
Published: 2024-05-08 02:17:24

## Intro

Currently, I'm using `Next.js` with `TypeScript` on a project that I've worked on in my workplace. One day, my senior front-end developer in my team reviewed my pull request and suggested that it'd be better to utilize `User-Defined Type Guard` than to use `type assertion` to make the type guard more secure.

When I received this feedback, I was trying to remember what `User-Defined Type Guard` was in TypeScript. At the time, I successfully managed to fix my pull request by replacing `type assertion` with `User-Defined Type Guard`. Then, I decided to write an article illustrating the meaning of `User-Defined Type Guard` in detail.

So now is the time to look into what exactly `User-Defined Type Guard` is and how we should use it properly.

## What is "User-Defined Type Guard" all about?

"User-Defined Type Guard" is a TypeScript's special syntax for functions returning a boolean, and they also include the indication of whether an argument has a particular type or not. It is useful when developers would like to implement different codes based on the type of the argument of functions.

Here is a simple code snippet of using `User-Defined Type Guard`. I'll explain it in detail.

```typescript
// Function returning boolean to judge if the typeof argument is string or number
function isNumberOrString(value:unknown): value is string | number {
    return !!['number','string'].includes(typeof value);
}

function logStringOrNumber (value:number | string | null | undefined) {
    if(isNumberOrString(value)) {
        console.log(value)
        // typeof value: string | number
        return value.toString();
    } else {
        // typeof value: null | undefined
        return 'The value is neither string nor number'
    }
}
```
In this code snippet, there are two functions; `isNumberOrString` and `logStringOrNumber`. `logStringOrNumber` returns the string value of its parameter only if `typeof value` (this function's parameter) is `string` or `number`, but the type of parameter contains any possibilities (null, undefined, and so on) in addition to string or number.

To narrow down the value type, `isNumberOrString` is executed to make sure it is string, number, or others.

The return type of `isNumberOrString` is defined as `value is string | number` by using `is` keyword. This return type tells TypeScript that if value is `number | string` is true, blocks of code inside `logStringOrNumber` function must have a value of type `number | string`. At the same time, if value is `number | string` is false, they must have a value of `null | undefined`. 

So that means if I remove `value is string | number` return type from `isNumberOrString`, the type error would appear on the code line `return value.toString();` like below.

```typescript
// User-Defined Type Guard
function isNumberOrString(value:unknown) {
    return !!['number','string'].includes(typeof value);
}

function logStringOrNumber (value:number | string | null | undefined) {
    if(isNumberOrString(value)) {
        console.log(value)
        // Error: 'value' is possibly 'null' or 'undefined'.
        return value.toString();
    } else {
        return 'The value is neither string nor number'
    }
}
```
That is because without return type using `is` keyword, `isNumberOrString` function just returns the boolean and does not narrow down the type of value (parameter of `isNumberOrString`).

## Another Example of "User-Defined Type Guard"

```typescript
export type Colors = "RED" | "GREEN" | "GOLD" | "BLUE" | "PURPLE";

const COLORS_ARRAY = ["RED", "GREEN", "GOLD", "BLUE", "PURPLE"];

const COLOR_ICON_PATH_MAP: Record<Colors, string> = {
  RED: "/assets/images/red-color-icon.png",
  GREEN: "/assets/images/green-color-icon.png",
  GOLD: "/assets/images/gold-color-icon.png",
  BLUE: "/assets/images/blue-color--icon.png",
  PURPLE: "/assets/images/purple-color-icon.png",
};

// User-Defined Type Guard: Function returning boolean to judge if the typeof argument (colorName) has a corresponding color name in `Colors`type.
const isColorIconName = (colorName: string): colorName is Colors => {
  return !!COLORS_ARRAY.includes(colorName);
};

const ColorIcon = ({ colorName }: { colorName: string }) => {
  // This constant variable has the image path of color icon
  const colorIconPath = isColorIconName(colorName)
    ? COLOR_ICON_PATH_MAP[colorName]
    : "/assets/images/plain-color-icon.png";

  return (
    <Image
      src={colorIconPath}
      alt="color-icon"
      width={24}
      height={24}
    />
  );
};

const UserPageSection:FC = ({ userId }: { userId: string }) => {
  // Obtain user color from API
  const userColor:string = getUserColor(userId);

  return (
    <div>
      <h2>User Page</h2>
      <div>
        <ColorIcon colorName={userColor} />
      </div>
    </div>
  );
}
```
Let's say each user in a Next.js application has a different color icon on their user page. `UserPageSection` component has `ColorIcon` component that displays the image of colorIcon with `Image` component from `next/image`.

To display corresponding color icon based on the current user, `ColorIcon` component has a constant variable whose name is `colorIconPath` that contains the image path of the color icon. But the type of argument `colorName` in `ColorIcon` is `string`, not `Color` because the parameter of it is obtained from the API call in the parent component (`UserPageSection`).

So it is necessary to ensure if `colorName` is included in `Colors` type or not in order to return the correct color icon path. If `colorName` is `RED`, `isColorIconName` returns true, and `colorIconPath` gets the image path as `/assets/images/red-color-icon.png` from the result of `COLOR_ICON_PATH_MAP[colorName]`.

That is because `RED` is certainly one of the elements of `Colors` type. If the parameter of `colorName` is `BLACK`, `colorIconPath` will get `/assets/images/plain-color-icon.png` because `isColorIconName` function returns false.

## Use Case with plain JavaScript Objects

`User-Defined Type Guard` also has advantages for plain JavaScript Objects. What exactly does it mean?

Let's say there are two types defined with JavaScript Object and a function with an argument whose type is one of these two types. If you want to identify the type of argument in this function, you can also take advantage of the `User-Defined Type Guard` like the code sample below;

```typescript
type Cat = {
    name: string;
    age: number;
    run:() => void;
}

type Bird = {
    name: string;
    age: number;
    fly:() => void;
}

// User-Defined Type Guard
function isCat(arg: any): arg is Cat {
    return !!('run' in arg);
}

function checkAnimal(animal:Cat | Bird) {
    if(isCat(animal)) {
        // Type of animal: Cat
        animal.run() // Ok
    } else {
        // Type of animal: Bird
        animal.fly() // Ok
    }
}

const cat:Cat = {
    name:'Test cat',
    age:3,
    run:() => console.log('Test cat is running!')
}

// The results of log: "Test cat is running!"
checkAnimal(cat);
```
Throughout the code snippet above, `isCat` function has the roll as `User-Defined Type Guard` so that `checkAnimal` function has no type errors because of the correct type checking. 

In this case, it is unable to use `typeof` operator like `typeof animal === Cat` instead of the condition `isCat(animal)` in `checkAnimal` function because `typeof` operator only interprets the type of `animal` argument as object. So there is no meaning of doing type check with `typeof` here.

(The code `typeof animal === Cat` also returns an error from TypeScript)

## Point of Caution

Although `User-Defined Type Guard` is a powerful tool for developers, there is a precaution about its usage. When `User-Defined Type Guard` function returns true, that also means you'll have some risks of getting type error in the false cases.

```typescript
// User-Defined Type Guard
function isValueGreaterThan10(val: any): val is number {
    return !!(val > 10)
}

function checkValueNumber(num: number | null) {
    if(isValueGreaterThan10(num)) {
        console.log('The num is greater than 10')
        // Type of num: number
        return num.toString() // Ok
    } else {
        console.log('The num is less than 10 or null')
        // Type of num: null
        return num?.toString() // Error: Property 'toString' does not exist on type 'never'.
    }
}
```
The error above on `num?.toString()` implies the fact that if the num is less than 9, `isValueGreaterThan10` returns false and then the type of `num` is recognized as `null`. Therefore TypeScript cries with the code line `num?.toString()`.

We should carefully use `User-Defined Type Guard` not to cause unexpected type errors like in this case. It could be better to avoid introducing `User-Defined Type Guard` when it is unnecessary.

## Conclution

Through writing this article, I could interpret the concept of `User-Defined Type Guard` more clearly. `User-Defined Type Guard` enables us to secure type guard if we utilize it correctly. But at the same time, there is a scenario defining the wrong `User-Defined Type Guard`, and it will result in implementing codes wrongly. I hope this article is helpful for TypeScript learners well!

## References

- [LearningTypeScript](https://learning.oreilly.com/library/view/learning-typescript/9781098110321/)
- [TypeScript Deep Dive](https://basarat.gitbook.io/typescript/type-system/typeguard#user-defined-type-guards)
- [【TS】今さら聞けないユーザ定義型ガード](https://zenn.dev/nekoniki/articles/d40acfd3c26583)
- [Narrowing](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates)