TypeScript Runtime Type Checking

Utilizing TypeScript’s Type System and Overcoming JavaScript Quirks for Robust Runtime Checks

Aziz Nal
ITNEXT

--

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.

⚠️ Note: For the following examples, I don’t extend the base `Error` class for the sake of keeping the example simpler, but you should 100% extend the `Error` class when making custom errors.

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 };

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) {}
}

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:

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

class AuthError extends CustomError {}

class BackendError extends CustomError {}

class UnknownError extends CustomError {}

The code above is perfectly valid. Though typescript can’t tell the difference, maybe we don’t need it to! 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
}
}

Great! Now we took care of all issues we mentioned before, but why is the article 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');

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' };

Zero warnings. Zero complaints from typescript. 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

instanceof does NOT work when you don’t 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 declared in a type, allowing us to effectively 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;

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:
// …
}
}

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 can now help us narrow the type of error correctly for each case

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;

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.

--

--

Writer for

Full Stack Web Developer. Very passionate about software engineering and architecture. Also learning human languages!