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 };
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:
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 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
}
}
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');
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 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
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;
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.
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.
Top comments (0)