TypeScript Type Guards

A type guard is a runtime check that narrows 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. In a bit, I’ll cut to the chase, but first, let’s take a quick look at how the basics of TypeScript work.
function add(foo: number, bar: number) {
return foo + bar
}
add(10, 'Hello') // Error: type 'string' is not assignable to 'number'Since we specified in parentheses that both arguments should be numbers, we get an error indicating that one of the arguments is a string. Normally, that’s all we really need from TypeScript. But now, let’s take a look at some 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') // trueThis can look messy at times so I find a simple abstraction can be helpful for readability. So instead of the above...
function isString(arg: unknown): boolean {
return typeof arg === 'string'
}
console.log(isString('Hello World')) // trueNow 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 this, we can't infer that our argument has a length property, even though the code should already assume "arg" is a string after passing the first conditional check before the twin ampersands. This is where type guards come to the rescue.
Return Type Guards
If the "isString" call evaluates to true then there should be a little more information there in the return value than simply a boolean. Outside of the return value itself, we should be able to assume that the argument passed in 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 must be a string when the return value of isString is true. Therefore, it can safely be treated as a string outside the utility. In typed languages, this is called "narrowing."
Bonus
Have a free snippet 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';
}