Skip to main content
Hypersphere
Article Cover

TypeScript Typeguards: To use or not to use?

TypeScript's static typing adds a layer of reliability and maintainability to JavaScript. Typeguards are a cornerstone of TypeScript and custom Typeguards can provide a lot of powerful typing options that would otherwise be impossible to achieve.

In this article, I will go through built-in ways of typeguarding and how to create custom ones. I will also explain why it's not always a good idea to use custom typeguards and when you might want to rely on the built-in type narrowing of the language itself.

Basics of Typeguards #

Typeguards are fundamental in TypeScript when dealing with uncertain types, particularly useful with unions and when dealing with variables typed as any. They can help you narrow down the type. Here’s a basic example using built-in typeof typeguard:

function formatInput(input: number | Date) {
  if (typeof input === "number") {
    // input is number
    return input.toLocaleString();
  } else {
    // input is Date
    return input.toISOString();
  }
}

In this case, typeof is a Typeguard that helps TypeScript distinguish between a number and a Date type. Note that inside the body of the if condition we can use a method of the number and Date types respectively without any extra type casting. TypeScript can automatically narrow down the type for us.

Custom Typeguards #

The built-in Typeguards might not always be enough. When you want to deal with more advanced logic, you might want to create your own custom TypeGuard. TypeScript allows you to do so using the following syntax:

interface Point {
  x: number;
  y: number;
}

function isPoint(value: any): value is Point {
  return value && typeof value.x === 'number' && typeof value.y === 'number';
}

Custom Typeguards are functions that return a boolean value, indicating if an input is of a certain type. Instead of simply typing the function as returning boolean, we type it with type predicative, which is a special TypeScript syntax that will make TypeScript treat a variable as a specific type if the function returns true.

Once implemented, you can use the typeguard in the if statements and TypeScript will automatically narrow the type for you:

const processData(data: unknown) => {
    if (isPoint(data)) {
        // data is typed here as Point, you can perform all Point operation here, no extra checks needed.
        console.log(computeDistanceToOrigin(data.x, data.y))
    }
    // Here data is still typed as unknown
}

When Custom TypeGuards are not needed? #

Let's imagine the following union type in your module that renders some content provided to it. The content can be either text, image or video and you need to handle them differently.

interface TextContent {
  type: 'text';
  content: string;
}

interface ImageContent {
  type: 'image';
  src: string;
  altText: string;
}

interface VideoContent {
  type: 'video';
  src: string;
  autoplay: boolean;
}

type Content = TextContent | ImageContent | VideoContent;

It might be tempting to write a typeguard as the following:

// Do not use this snippet, we will come up with something better ;)
const isTextContent = (content: Content): content is TextContent => {
    return content.type === 'text'
}

const isImageContent = (content: Content): content is ImageContent => {
    return content.type === 'image'
}

const processContent = (content: Content) => {
    if (isTextContent(content)) {
        // content: TextContent
    } else if (isImageContent(content)) {
        // content: ImageContent
    } else {
        // content: VideoContent
    }
}

The solution above works but is very explicit. We can improve it using type narrowing using discriminated unions. The name might sound intimidating but all it means is that TypeScript automatically narrows down types based on common properties in union types. In our example, a simple check on the type string literal will help TypeScript decide which of the types we are dealing with. No custom typeguards are required.

const processContent = (content: Content) => {
    if (content.type === 'text') {
        // content: TextContent
    } else if (content.type === 'image') {
        // content: ImageContent
    } else {
        // content: VideoContent
    }
}

We did not need to use any custom typeguard here.

We can even use switch statement in this case and simplify the code further:

const processContent = (content: Content) => {
    switch (content.type) {
        case 'text':
            // content: TextContent
            break;
        case 'image':
            // content: ImageContent
            break;
        case 'video':
            // content: VideoContent
            break;
    }
}

For deeper dive into disriminated unions, check out the official TypeScript Documentation about the topic.

When custom Typeguards are useful? #

Sometimes we might need to use custom typeguards nevertheless. Quite often our guard logic might go beyond simple type safety rules and it might be based on the business logic that can't be expressed in the type system itself.

interface UserModel {
    id: string;
    name: string;
    isAdmin: boolean;
}

interface BookModel {
    id: string;
    title: string;
    isInStock: boolean;
}

type Model = UserModel | BookModel

const isUserModel = (model: Model): model is UserModel => {
    return model.id.startsWith('user_')
}

const isBookModel = (model: Model): model is BookModel => {
    return model.id.startsWith('book_')
}

const logModel = (model: Model) => {
    if (isUserModel(model)) {
        console.log(`User ${model.name} (${model.isAdmin ? 'admin' : 'regular user'})`)
    } else {
        console.log(`Book ${model.title} ${model.isInStock ? '(in stock)' : ''}`)
    }
}

logModel({
    id: 'book_1234',
    title: 'Pride and Prejudice',
    isInStock: true
})

In the example above the rules go beyond the TypeScript type system but based on the business logic we can deduct that any model that's id starts with book_ is a book model. This can help us narrow the type down even though without it we would need to check for presence of other properties instead.

When Typeguards can go wrong? #

Remember that typeguards are only as good as the logic you plug into them. The example from the previous section is good as long as we know our system guarantees ids matching the model shape. TypeScript will not check anything else and will trust us that the custom typeguard properly matched the type for us, so the following code will throw a runtime error.

logModel({
    id: 'book_444',
    name: string;
    isAdmin: true
})

In many cases, we can guarantee business logic constraints like that with no problem whatsoever, but it's important to remember that real-world data might not always conform to our expectations or assumptions.

A robust application should include additional runtime checks or validation mechanisms to ensure that the data actually matches the types as defined in TypeScript, when in doubt. This is especially critical in cases where data is dynamically loaded, user-generated, or comes from external sources.