Among the many good things Typescript offers, I'd like to address one that, in my experience, has saved me quite a few bugs.
Let's start with an example first.
The code will contain React components, but the general principle stays the same with other frameworks as well.
Let's say we have a very rudimentary loading indicator in our app:
You can see what it look like here. It's nothing special, but our users are content.
In order to display a loading indicator in our system all we need is to tell it in what state our request is, and it will display a circle in the corresponding color.
One day, we choose to allow adding a message to go along with
FAILED requests. We can modify our props interface like so:
And our component will now display the message:
A while passes and everything is just fine, but then - an engineer on our team is refactoring some old code, and rewrites some code to fetch data from your server.
When the data arrives, the engineer renders a
SUCCESSFUL loading indicator with a message, although our guidelines specifically say that successful indicator should not have a message.
What we have here is an impossible state!
An impossible state is a certain combination of fields and values, that should never co-exist simultaneously.
In other words - an "impossible state" might be a possible state in that if we disregard our company guidelines/lint rules/compiler, the state may occur, but we should never accept it, and therefore must make sure it never occurs (whether intentionally or unintentionally).
You don't need Typescript to avoid impossible states. In fact - you could get away without anything stopping you from making the impossible state mistake, given that everyone in your team is aware of it, and all of you are responsible engineers with buckets of ownership.
That might be the case today. What will happen when your company doubles in size? or triples? or quadruples?
Would you still feel like word-of-mouth is good enough?
I strongly disbelieve that. Not because I don't trust other engineers around me, I have complete faith in them. I like to think about it in exponential terms - if your team doubled in size, you'd need 4 times the efforts to preserve code quality.
To comply with that, we need some mechanism that would prevent, to the highest degree possible, the presence of such "impossible states".
One way to go about it, is to document the fact that
PENDING requests should have no message, like so:
But this method, in my opinion, is error prone - in the end the only way to find it is with a human eye, and humans are prone to failure.
A better way
But I am here to present to you a better way. There is a very simple way in which we can ensure we always have exactly what we want, nothing more and nothing less.
We can leverage Typescript's powerful Union Types. In essence, union types allow us to make new types that act as an
OR clause in a way.
Let's start with a quick example. Say we have an intelligent logger that can both print single log messages, and can concatenate log messages if passed as an array.
If we wanted to type it, we could do it naïvely like so:
Now that we know how to work with union types, we can use them to our advantage in our loading indicator.
One interface to rule them all? No
Instead of using a single interface for all the possible states of the request, we can split them up, each having their own unique fields.
The highlighted part is where the magic happens. With it we specify all the different types of props we accept, and only allow a message on
You'll immediately see that Typescript is yelling at our component:
So we'll change our component just a little:
if block Typescript is able to narrow down the type of our props from
PendingLoadingIndicatorProps | SuccessfulLoadingIndicatorProps | FailedLoadingIndicatorProps to
FailedLoadingIndicatorProps, and ensures us that the
message prop exists.
If we now tried to render our
RequestLoadingIndicator with a message and a state other than
FAILED, we would get compile time error:
We could stop at that and call it a day, or we can take it up a notch.
What if we wanted to change our
SUCCESSFUL loading indicator to show an animation, and allow consumers of our indicator to pass a callback that fires when the animation ends?
With a monolithic interface, we'd go through the same trouble as we did when we added the
See how quickly it gets out of hand?
Our union types make this a non-issue:
Now, we only allow our indicator's consumers to pass
onAnimationEnd when state is
SUCCESSFUL, and we have Typescript to enforce that.
Notice that we used
?, so we don't force anyone to pass empty functions.
Thank you for reading!
(cover photo by Matt Atherton on Unsplash)