TypeScript Type Guards

🔥 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 fornull)'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')) // trueWhile 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);
}