shield

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';
}