How to write better TypeScript
Discover common pitfalls in the way we use TypeScript and learn to maximize your productivity by using the language's features properly.
TypeScript: love it or hate it, you can’t deny the fact that it’s spreading like wildfire. In fact, according to the Stack Overflow 2019 developer survey, it was listed as the third most-loved programming language and the fourth most-wanted.
We’ll be looking at some real-world code written in TypeScript that could be improved to make better use of the language’s strengths. This is by no means an exhaustive list, and I welcome you to list some you might have noticed in the comments section below.
Some of these examples involve React because I’ve noticed some instances where React code could be improved by simply making use of some TypeScript features, but the principles are by no means limited to React. Let’s dive in.
NOTE: Many code snippets in this article have been taken from real-world projects and anonymized to protect their owners.
Let’s start with one of the most useful features of TypeScript: interfaces.
In TypeScript, an interface simply specifies the expected shape of a variable. It’s as simple as that. Let’s look at a simple interface to drive the point home.
Now if any variable is defined to implement
FunctionProps, it will have to be an object with the keys
bar. Any other key addition will make TypeScript fail to compile. Let’s look at what I mean.
Now we have an object
fProps that implements the
FunctionProps interface correctly. If I deviate from the shape specified in the interface by, say, writing
fProps.foo = 100 or deleting
fProps.bar, TypeScript will complain.
fProps's shape has to match
FunctionProps exactly or there will be hell to pay.
Now that we’ve gotten that out of the way, let’s look at an example. Take this React functional component method:
Also, you could easily pass in a prop of a different expected shape to this method and you would be none the wiser because TypeScript would not complain about it.
This is just vanilla JS, and you might as well eliminate TypeScript from the project altogether if everything was written like this.
How could we improve this? Well, take a look at the arguments themselves, how they’re being used, and what shape is expected of them.
Let’s start with
props. Take a look at line 7 and you can see that it’s supposed to be an object with a key called
inputValue. On line 8, we see another key being accessed from it called
handleInputChange, which, from the context, has to be an event handler for inputs.
We now know what shape props is supposed to have, and we can create an interface for it.
Moving on to
attribute, we can use the same method to create an interface for it. Look at line 6. We’re accessing a key called
key from it (hint: it’s an object). On line 9, we’re accessing another key from it called
label, and with this information, we can go ahead and create an interface for it.
We can now rewrite the method to look like this instead:
Is it more code to write? Yes. But consider the benefits of doing this:
Why use TypeScript in the first place if you don’t care about these benefits?
Why is this? Well, if you type a variable as
any, you’re telling TypeScript to skip type-checking it. You can now assign and reassign different types to this variable, and this allows you to opt-in and out of type checking when necessary.
While there may be other use-cases for using
any, such as when you’re working with a third-party API and you don’t know what will be coming back, it is definitely possible to overuse it and, in effect, negate the advantages of TypeScript in the process.
Let’s take a look at a case where it was definitely abused.
This interface breaks my heart. There are legitimate use cases for
any, but this is not one of them.
For instance, take a look at line 2, where we’re basically specifying an array that can hold content of any type. This is a bomb waiting to explode wherever we’re mapping over categoryDetails, and we don’t account for the fact that it may contain items of different types.
NOTE: If you need to work with an array that contains elements of different types, consider using a Tuple.
Line 3 is even worse. There is no reason why
state's shape should be unknown. This whole interface is basically doing the same thing as vanilla JS with regards to type checking, i.e, absolutely nothing. This is a terrific example of interface misuse.
If you have ever written an interface like this in production code, I forgive you, but please do not let it happen again. Now, I went through the codebase where this example was plucked from to look at the expected shapes of the variables, and this is how it should look:
Much better. You get all the advantages of using TypeScript without changing the interface too much. Now let’s take a look at where using any actually makes sense.
Why is this a valid use case for
Well, for one, we’re working with an external API. On line 2, we’re specifying a function that makes a fetch request to a weather API, and we don’t know what the response should look like; maybe it’s an endpoint that returns dynamic data based on certain condition.
In that case, specifying the return type as a promise that resolves to any is acceptable.
NOTE: This is not the only approach to working with dynamic data. You could specify all the possible values coming from the endpoint in the interface and then mark the dynamic fields as optional.
On line 3, we’re also working with a function that takes in a prop that is dynamic in content. For instance, say
userContent comes from the user, and we don’t know what the user may type. In this case, typing
userContent as any is completely acceptable.
Yes, there are valid use-cases for the
any type, but please, for the love of TypeScript, avoid it as much as you possibly can without ruining the developer experience.
Now, this is a very subtle mistake I see quite a lot in React code where you may need to map over an object and access its properties dynamically. Consider this example:
The reason you can’t just do that is because of type indexing.
In TypeScript, you need to specify how an interface should be indexed into by giving it an index signature, i.e, a signature that describes the types we can use to index into the interface, along with the corresponding return types.
A quick refresher: indexing into an object looks like
We didn’t tell TypeScript what index signature
ObjectShape should have, so it doesn’t know what to do when you index into an object that implements it as we do on line 13. But how does this concern React?
Well, there are cases where you might need to iterate over a component’s state to grab certain values, like so:
This is a very common operation in React, but you can see how we may run into a problem on line 13. We’re indexing into
this.state, but the interface it implements doesn’t have an index signature. Oops.
But that is not even the mistake I’m talking about, and I’ll get to it in a moment. To fix the warning TypeScript throws, you might update the state’s interface like so:
Before we continue, it’s worth noting that, by default, adding an index signature to an interface also means you will be able to add new values that do not exist in the interface to any variable that implements it.
This will successfully get rid of the error, but now you’ve introduced a new side effect.
This is the equivalent of telling TypeScript that when
ComponentState is indexed with a string, it should return a value of type
any (basically all possible types). This could cause issues if
this.handleError was not expecting anything apart from a string or a number.
But more importantly, you can now add a new property with ANY type to whichever variable implements the interface, which, in our case, is
this.state. So this becomes valid:
Now that is the mistake I am talking about. How do we fix it, though? Well, there are actually two things we need to look out for:
So, in most cases, the proper way to fix our initial issue (indexing into an object without TypeScript complaining) would be to do this:
OK, so here’s what this piece of code is saying:
Hey, TypeScript, I would like to be able to index into this interface with a string, and I should get either a string or a number back. Oh, and please don’t let me add any other thing to any object that implements this interface that I didn’t explicitly specify.
By simply specifying the index signature return values, we’re able to solve our first issue, and by marking it as
readonly, we’re able to solve the second issue. Please watch out for this subtle issue when writing TypeScript code.
I hope you were able to learn one or two things from this article, and if you have some examples you’d like to share, please add them in the comment section below so that others can benefit.
Goodbye and happy coding ❤️.