Checking Against Custom Types in TypeScript: From typeof Limitations to Type Guards

Nov 22, 2025 · Programming · 9 views · 7.8

Keywords: TypeScript | Custom Type Checking | Type Guards | typeof Operator | Type Narrowing

Abstract: This article provides an in-depth exploration of proper methods for checking custom types in TypeScript. It begins by analyzing the dual role of the typeof operator in TypeScript and its runtime limitations, explaining why typeof cannot directly check custom types. The article then details solutions through type inference and user-defined type guards, including deriving types from values, implementing type guard functions, and practical application scenarios. Complete code examples demonstrate elegant solutions for custom type checking problems.

Runtime Limitations of TypeScript's Type System

In JavaScript, the typeof operator is the standard method for checking value types. For example:

const fruit = 'apple';
console.log(typeof fruit); // Output: 'string'

const year = 2022;
console.log(typeof year); // Output: 'number'

However, in TypeScript, this direct approach fails when dealing with custom types. Consider the following custom type definition:

export type Fruit = "apple" | "banana" | "grape";

Attempting to check this type using typeof will not work:

let myfruit = "pear";
if (typeof myfruit === "Fruit") { // Error: never evaluates to true
    console.log("My fruit is of type 'Fruit'");
}

Understanding the Dual Role of typeof in TypeScript

The typeof operator in TypeScript has dual meanings depending on its context. In value contexts, it uses JavaScript's typeof operator, returning strings representing primitive types:

let bar = {a: 0};
let TypeofBar = typeof bar; // Value: "object"

In type contexts, it uses TypeScript's type query operator to examine static types:

type TypeofBar = typeof bar; // Type: {a: number}

The crucial difference is that JavaScript's typeof executes at runtime, while TypeScript's type query executes at compile time. Since TypeScript's type system is erased after compilation, type information is inaccessible at runtime.

Basic Solution: Direct String Comparison

The most straightforward approach is to compare string values individually:

let myfruit = "pear";
if (myfruit === "apple" || myfruit === "banana" || myfruit === "grape") {
    console.log("My fruit is of type 'Fruit'");
}

While this method works, it suffers from code redundancy and maintenance difficulties. When types contain multiple possible values, the code becomes verbose and error-prone.

Elegant Solution: Type Inference and Type Guards

A more elegant solution combines type inference with user-defined type guards. First, derive the type from values:

const fruit = ["apple", "banana", "grape"] as const;
type Fruit = (typeof fruit)[number];

Here, the as const assertion ensures array elements are treated as literal types rather than generic string types. (typeof fruit)[number] uses indexed access types to obtain a union of array element types.

Implementing User-Defined Type Guards

Next, create a type guard function:

const isFruit = (x: any): x is Fruit => fruit.includes(x);

The type guard function uses the x is Fruit type predicate, informing the TypeScript compiler that if the function returns true, the parameter x's type can be narrowed to Fruit.

Complete Application Example

Combining the components above:

const fruit = ["apple", "banana", "grape"] as const;
type Fruit = (typeof fruit)[number];

const isFruit = (x: any): x is Fruit => fruit.includes(x);

let myfruit = "pear";
if (isFruit(myfruit)) {
    console.log("My fruit is of type 'Fruit'");
}

Practical Value of Type Guards

The true value of type guards lies in type narrowing. Consider this scenario:

declare function acceptFruit(f: Fruit): void;
const myfruit = Math.random() < 0.5 ? "pear" : "banana";

// Direct call causes error
acceptFruit(myfruit); // Error: myfruit might be "pear"

// Safe call after type guard
if (isFruit(myfruit)) {
    acceptFruit(myfruit); // Correct: myfruit known to be "banana"
}

Within the if block, the TypeScript compiler knows that myfruit's type has been narrowed to Fruit, allowing safe passage to functions that only accept Fruit types.

Handling Edge Cases

When values don't belong to the custom type:

const myfruit = "mango";
if (isFruit(myfruit)) {
    console.log(`${myfruit} is a type of Fruit`);
} else {
    console.log(`${myfruit} is NOT a type of Fruit`);
}
// Output: "mango is NOT a type of Fruit"

While mango is technically a fruit in reality, in our type system it doesn't belong to the Fruit type, which only includes apple, banana, and grape.

Summary and Best Practices

TypeScript's type system provides powerful compile-time type checking but is completely erased at runtime. Therefore:

This pattern not only solves custom type checking problems but also provides better code organization and type safety, making it one of the core techniques in TypeScript development.

Copyright Notice: All rights in this article are reserved by the operators of DevGex. Reasonable sharing and citation are welcome; any reproduction, excerpting, or re-publication without prior permission is prohibited.