TypeScript Type Guards

Default avatar picture
Ryan Santos
08-04-2021
shield
🔥 TypeScript Type Guards: The Secret to Safer Code

A type guard is a programming technique that uses a runtime check to narrow the static type of a variable within a conditional block. It's a way to tell TypeScript, "If this condition is true, you can safely treat this variable as a more specific type."

I didn't use them for a long time myself, but once I discovered a really good use case for type guards, I started using them everywhere. And just like Tapatío hot sauce, I put that "ish" on everything now.

Before we get to the spicy stuff, let's review how TypeScript handles standard argument checking.

function add(foo: number, bar: number) {
  return foo + bar
}

add(10, 'Hello') // Error: type 'string' is not assignable to 'number'

Since we specified that both arguments should be numbers, we get a clear error when we pass a string. Normally, this basic check is all we need. But for more complex scenarios, we need more control.


🔍 The typeof Type Guard

JavaScript and TypeScript support the typeof operator, which gives us basic information about the type of values at runtime. This operator can be compared against a set of primitive types:

  • 'string'
  • 'number'
  • 'bigint'
  • 'boolean'
  • 'symbol'
  • 'undefined'
  • 'object' (Note: This is also the result for null)
  • 'function'

When used in an if statement, typeof acts as a basic type guard, enabling narrowing:

function printLength(arg: string | number) {
    if (typeof arg === 'string') {
        // Inside this block, 'arg' is narrowed to 'string'
        console.log(arg.length); // OK 
     }
     // ...
}
The Abstraction Problem

This can look messy at times so I find a simple abstraction can be helpful to improve readability:

function isString(arg: unknown): boolean {
  return typeof arg === 'string'
}

console.log(isString('Hello World')) // true

While this function works, it creates a serious limitation when we try to use it to enable narrowing elsewhere:

function isLongerThan5Characters(arg: unknown) {
	return isString(arg) && arg.length > 5 // Error: property 'length' does not exist on type 'unknown'
}

The issue: TypeScript only sees that isString returns a boolean. It doesn't know that returning true implies arg is a string. As far as the compiler is concerned, isString just returns true or false arbitrarily, so the type of arg remains unknown.


✨ User-Defined Type Guards (Type Predicates)

To solve the abstraction problem, we use a User-Defined Type Guard, also known as a Type Predicate. Instead of returning just a boolean, we use a specific return syntax to connect the return value (true) directly to the argument's type.

Let's modify the isString function:

function isString(arg: unknown): arg is string { // Type Predicate
  return typeof arg === 'string';
}

function isLongerThan5Characters(arg: unknown) {
	return isString(arg) && arg.length > 5; // Success! No error.
}

Notice the syntax: arg is string. We are telling the compiler: "If this function returns true, you must treat the argument (arg) as a string." This allows TypeScript to safely perform narrowing and eliminates the previous error.


🎁 Bonus: A Suite of Utility Type Guards

Here is a free, powerful snippet demonstrating how you can build a full set of reusable, type-safe utility functions using Type Predicates. Happy coding!

// Define the set of possible constructors for basic types
type TConstructor = StringConstructor | NumberConstructor | BooleanConstructor | ObjectConstructor | FunctionConstructor | BigIntConstructor | SymbolConstructor;

// Helper to check for null or undefined
function isNullOrUndefined(arg: unknown): arg is null | undefined {
	return arg === null || arg === undefined;
}

// Helper to safely retrieve the constructor property
function getConstructor(arg: unknown): TConstructor | null {
	return isNullOrUndefined(arg) ? null : (arg as {constructor: TConstructor})?.constructor;
}

// Type Guards for standard types
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;
}

// Type Guards for checking emptiness
function isEmptyObject(arg: unknown): arg is Record<string, unknown> {
	return isObject(arg) && Object.keys(arg).length === 0;
}

function isEmptyArray(arg: unknown): arg is unknown[] {
	return isArray(arg) && (arg as Array<unknown>).length === 0;
}

function isEmptyString(arg: unknown): arg is string {
	return isString(arg) && arg.length === 0;
}

// Utility to check if something is a Promise
function isPromise<T = unknown>(arg: unknown): arg is Promise<T> {
	return !isNullOrUndefined(arg) && typeof (arg as Promise<T>).then === 'function';
}

// Example of a combined check
function isEmpty(arg: unknown): boolean {
	return isNullOrUndefined(arg) || isEmptyObject(arg) || isEmptyString(arg) || isEmptyArray(arg);
}