DEV Community

Kento Honda
Kento Honda

Posted on

What is "User-Defined Type Guard" in TypeScript?

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.

// 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'
    }
}
Enter fullscreen mode Exit fullscreen mode

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.

// 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'
    }
}
Enter fullscreen mode Exit fullscreen mode

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"

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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;

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);
Enter fullscreen mode Exit fullscreen mode

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.

// 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'.
    }
}
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)