TypeScript Type Guards
Type guards are an extra way in TypeScript to validate data types. At first, they may seem redundant since TypeScript does a great job checking for types on its own. After all, that's why "type" is in the name right? I hadn't used them personally for a very long time but when I discovered a really good use case for type guards I started to use them everywhere. Just like Tapatio hot sauce, I put that "ish" on everything now. In a little bit I'll cut right towards the chase but for now, let's take a look at how basic TypeScript stuff works.
function add(foo: number, bar: number) {
return foo + bar;
}
add(10, 'Hello'); // Compiler error: type 'string' is not assignable to 'number'
Since we specified in the parenthesis that both arguments should be numbers we get an error saying that one of the arguments is a string. And normally this is all we ever really need from TypeScript. But now let's take a look at slightly more advanced examples.
Typeof Type Guards
JavaScript and TypeScript support a typeof
operator which gives basic information about the type of values we have at runtime. This operator can be compared with a set of strings (string, number, bigint, boolean, symbol, undefined, object, and function).
const foo = 'I am a string';
console.log(typeof foo === 'number'); // false
console.log(typeof foo === 'boolean'); // false
console.log(typeof foo === 'undefined'); // false
console.log(typeof foo === 'string'); // true
This is nice but I wanted to create a utility function that was a little more expressive and sat with me better because this can look kind of messy at times. So instead of the above:
function isString(arg: unknown): boolean {
return typeof arg === 'string';
}
console.log(isString('Hello World')); // true
Now this is easier to read in my opinion, especially if your code already has a bunch of equal signs. However, the fact that this utility only returns boolean information creates a small problem if we were to use this utility in a different function:
function isLongerThan5Characters(arg: unknown) {
return isString(arg) && arg.length > 5; // Error: property 'length' does not exist on type 'unknown'
};
// Since the 'typeof' type guard is scoped in a different function
// accessing the length property on arg is impossible
Because of a scoping issue, we can't automatically infer that our argument has a length property even though the code should only evaluate the stuff after &&
only if arg
is a string. This is where return type guards come in handy.
Return Type Guards
If isString
evaluates to true then there is a little more information there in the return value than simply a boolean. Outside of the value itself, we should be able to assume that the argument is a string type. Let's modify the isString
function so this makes a bit more sense.
function isString(arg: unknown): arg is string { // Type guard
return typeof arg === 'string';
}
function isLongerThan5Characters(arg: unknown) {
return isString(arg) && arg.length > 5; // No error!!!
};
Notice the type guard "arg is string
". Instead of returning just a boolean, we're also passing along the extra information that arg
is indeed a string when this function returns true. Therefore, it can be evaluated as such outside of the scope of this function. In typed languages, this is called "narrowing."
Bonus
Here's a bonus snippet I use all the time. Feel free and happy coding 🤗 .
type TOptionalConstructor = | StringConstructor | NumberConstructor | BooleanConstructor | ObjectConstructor | FunctionConstructor | BigIntConstructor | SymbolConstructor | null;
function isNullOrUndefined(arg: unknown): arg is null | undefined {
return arg === null || arg === undefined;
}
function getConstructor(arg: unknown): TOptionalConstructor {
return isNullOrUndefined(arg) ? null : (arg as {constructor: TOptionalConstructor})?.constructor;
}
function isObject(arg: unknown): arg is Record<string, unknown> {
return getConstructor(arg) === Object;
}
function isArray(arg: unknown): arg is any[] {
return Array.isArray(arg);
}
function isString(arg: unknown): arg is string {
return getConstructor(arg) === String;
}
function isNumber(arg: unknown): arg is number {
return getConstructor(arg) === Number;
}
function isEmptyObject(arg: unknown): arg is Record<string, unknown> {
return isObject(arg) && Object.keys(arg as Record<string, unknown>).length === 0;
}
function isEmptyArray(arg: unknown): arg is never[] {
return isArray(arg) && (arg as Array<unknown>).length === 0;
}
function isEmptyString(arg: unknown): arg is never {
return isString(arg) && (arg as {length: number}).length === 0;
}
function isEmpty(arg: unknown): arg is never {
return isNullOrUndefined(arg) || isEmptyObject(arg) || isEmptyString(arg) || isEmptyArray(arg);
}
function isPromise<T>(arg: unknown): arg is never {
return !isNullOrUndefined(arg) && typeof (arg as Promise<T>).then === 'function';
}