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'
}
}
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'
}
}
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>
);
}
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);
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'.
}
}
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!
Top comments (0)