DEV Community

Cover image for TypeScript Runtime Type Checking
Aziz
Aziz

Posted on

TypeScript Runtime Type Checking

Intro

Typescript is the best thing to happen to Javascript since the Great Callback Pyramid crumbled.

We got types, autocomplete, compile-time issue catching, and the world was finally at peace, until someone decided to use instanceof (yes it was me).

You see, even though we got all of the aforementioned goodies, there are still a few quirks of the typescript type system that may fly under the radar since they don't arise often, especially when you're writing relatively simple applications.

In this article, I want to dive into the topic of objects. Specifically, the different ways an object can be instantiated, why they matter, and how the instanceof operator can be used / misused.

I'll back everything up with a practical example about error handling, and show you how we can get the flexibility of typescript's structured typing system with the power of runtime object type checks.


An Example Use case

You're a frontend dev and you've been asked to implement error handling for an application. Let's say for this application that the types of errors you need to handle are:

  • authentication errors
  • backend errors
  • any uncaught errors that don't fit into the previous two types.

Let's see how we would implement something for that.

1. What if we only used typescript?

Like a good developer, we start things simple by creating a type for each of our errors:

type AuthError = { reason: string };

type BackendError = { reason: string };

type UnknownError = { reason: string };
Enter fullscreen mode Exit fullscreen mode

Right out of the gate, we have a few major issues:

  • To typescript, these are all the same thing
  • To javascript, these don't even exist
  • Code Repetition

Let's move on to something better.

2. What if we used classes?

Good idea! After all, classes are also available during runtime which solves the second issue.

class AuthError {
    constructor(public reason: string) {}
}

class BackendError {
    constructor(public reason: string) {}
}

class UnknownError {
    constructor(public reason: string) {}
}
Enter fullscreen mode Exit fullscreen mode

We still have two issues. These are still the same thing for typescript, and we're still repeating ourselves.

The code repeating issue is simple enough to take care of. We can make a super class and have the others extend it:

class CustomError {
    constructor(public reason: string) {}
}

class AuthError extends CustomError {}

class BackendError extends CustomError {}

class UnknownError extends CustomError {}
Enter fullscreen mode Exit fullscreen mode

The code above is perfectly valid. Though typescript can't tell the difference, maybe we don't need it do! After all, we can use instanceof and the following code works perfectly fine:

function handleError(error: CustomError) {
    if (error instanceof AuthError) {
        // do something
    } else if (error instanceof BackendError) {
        // do something
    } else if (error instanceof UnknownError) {
        // do something
    }
}
Enter fullscreen mode Exit fullscreen mode

Great! Now we took care of all issues we mentioned before, but the article is not finished yet. Uh oh. Something is about to change your view about something in javascript.

The issue is hidden in how instanceof works.

In the simplest terms, someObject instanceof SomeClass returns true if someObject was created by or inherits from SomeClass's constructor, meaning there has to be a new keyword in their somewhere. Big deal, so what? We'll just throw our errors like this:

throw new AuthError('Session is expired or something');
Enter fullscreen mode Exit fullscreen mode

Okay. fair enough, except that this is the wild untamed land of javascript, where you could also do this:

throw { reason: 'Session is expired or something' };
Enter fullscreen mode Exit fullscreen mode

Zero warnings. Zero errors. But that's the not even the worst part. You've just lost the ability to check this at runtime:

const errorObj: AuthError = { reason: 'Session is expired or something' };

console.log(errorObj instanceof AuthError); // -> False
Enter fullscreen mode Exit fullscreen mode

instanceof does NOT work when you don't use instantiate objects with the new keyword.

Even though this is a rather unlikely issue, I've personally lost faith in instanceof. Whenever an issue happens in this area of the code, a little voice in my head will keep telling me "What if it's because of that really rare bug you read about once?"

We need a different approach. One that saves us from the quirks of javascript, and allows typescript to truly shine.

3. Back to types again

Introducing tags! A tag is simply a property with constant value which placed in a type, allowing us to confidently narrow the type of a given object during both compile-time and runtime.

Here's how we could define our errors now:

type AuthError = {
    reason: string,
    errorType: 'auth-error', // <- tag
};

type BackendError = {
    reason: string,
    errorType: 'backend-error', // <- tag
};

type UnknownError = {
    reason: string,
    errorType: 'unknown-error', // <- tag
};

type CustomError = AuthError | BackendError | UnknownError;
Enter fullscreen mode Exit fullscreen mode

Interestingly, CustomError is now on the bottom of our hierarchy instead of at the top. Using the | (union) operator, we tell typescript that a CustomError is one of any of the given types. We are able to narrow which one it is exactly using the tag.

Here's how the handleError function can be re-written:

function handleError(error: CustomError) {
    switch (error.errorType) {
        case 'auth-error':
            // ...
        case 'backend-error':
            // ...
        default:
            // ...
    }
}
Enter fullscreen mode Exit fullscreen mode

The coolest part? Typescript is helping you along every step of the way, and is fully aware of exactly the type of error you're handling.

Typescript infers the narrowed types of the errors since we provided a union with the same key

Note that for this to work, the tag needs to have the same name for all the types (errorType in the above example).

If you're interested in how to print the types this way (using // ^?) check out this extension (I'm not affiliated, just think it's awesome).

Now it wouldn't even matter if we rawdog it and throw objects directly. Typescript would get mad and force us to add a tag:

// ERROR: Property 'errorType' is missing in type '{ reason: string; }' but required in type 'AuthError'.
const errorObject: AuthError = {
    reason: string;
};

throw errorObject;
Enter fullscreen mode Exit fullscreen mode

Conclusion

In conclusion, this article underscores the importance of understanding Typescript and Javascript type systems explained with an error handling example.

Through a step-by-step exploration, we discovered that using tags is a powerful, reliable solution. This approach takes advantage of Typescript's structured typing system and allows for runtime object type checks, resulting in more robust and maintainable code.

By mastering these nuances, we can fully harness Typescript's potential while avoiding language pitfalls.

For more insight into typescript, I highly recommend the Effective Typescript book by Dan Vanderkam.

Top comments (0)