Keeping a Clean House with Typescript at Scale
After starting to migrate Panaseer’s web app codebase to Typescript a couple of years back, we started noticing some issues that resulted in a series of best practices we now try and stick to. These best practices are less about the details and architecture of your code (there are more than enough blogs out there to help you with that) and focus more on the housekeeping aspects of making thousands of lines of Typescript code easy to work with.
I hope I can pass on some of those lessons learned to you and make your Typescript journey more enjoyable. If you are a developer already using Typescript, thinking about getting started with it or just interested in what the large-scale Typescript experience is like — read on.
Giant Import Paths
The first realisation we had dips more into the realm of configuration than writing code itself. As applications grow, you’ll naturally start to add more nested directories and longer, more specific file names. A consequence of that is that your Typescript import paths will start to grow. And whilst most IDEs offer support for getting the right import path for a type or class (couldn’t survive without it 🙌), the issue of dealing with horrendously long import paths remains. I don’t know about you, but I don’t want each one of my files to start with a huge clump of unreadable import definitions.
Luckily, Typescript ships with a solution. In combination with bundlers like Webpack, Typescript gives you the options to specify aliases for directory paths, allowing you to shorten something like src/app/administration/shared/components/email/subscribe/email-subscribe.component.ts to admin-email-subscribe/email-subscribe.component.ts. This results in less import code and improves readability in case you are ever wondering which type of “subscribe” component you are currently relying on.
Give your Types the Right Home
The next question you’ll usually encounter early on in your Typescript journey is where to best place your types. We started out placing most of our types in a central type file. The thinking went along the lines of “It should increase findability if you always know where to look for a type and decrease duplication”.
After our type file had grown to several hundred and then several thousand lines, it was becoming clear that the central type file was not the solution. Aside from creating a central file that most of your Typescript code depended on, we were seeing issues with types being left behind when associated classes and functions were removed. Over time, as changes spread throughout the code base, we had to keep up a constant manual effort to clean up stale types.
So what’s the solution?
You might have guessed it — place your types at the top of the file of the class or function they most closely relate to. That way, you always know whether a type is needed based on whether the associated class is still around. There will be situations, where a single class creates the need for quite a few types and adding all of those to the top of a file ends up being messy. In cases like this, we still fall back to a type file, but it’s a type file specifically belonging to that class. Through consistent naming and closeness in the directory structure, we can ensure that the class and type file come and go together.
Only Type where Necessary
Once all our types were finally in the correct place, we started noticing something else and it was getting really annoying very quickly. When you are just falling in love with Typescript and really want to embrace type systems, you might feel the urge to add type annotations wherever possible.
A symptom of over-eager Typescript usage is specifying types when you don’t need to. In a lot of cases, Typescript is smart enough to infer what type you are dealing with.
Take the example of assigning the return value of a function to a variable. Given you’ve most likely specified the return type for that function, you don’t have to add an extra type for the variable you are assigning the return value to. The problem caused by over-eager type specifications is that it makes refactoring harder. You’re adding an extra step of manually updating the explicit types you sprinkled throughout the codebase. Especially on large projects, having to touch a lot of files frequently comes with a performance hit on your build times. So only add an explicit type when you need to. It is worth adding that for variables initialised with object literals it is good practice to set an explicit type for full type safety.
If you find it difficult to figure out whether you need to specify the type or not, give this handy lint rule a try. It checks for and even removes unnecessary types in your project https://palantir.github.io/tslint/rules/no-inferrable-types/.
The most referenced no no of Typescript at scale is probably the any type. Now I agree with that, but to get the most out of your types as the application grows, I’d add another point around using optionality with care. Especially during object construction when you are building up the properties on an object, you’ll often encounter type errors because not all expected properties exist yet.
An easy “solution” (read hack), is to add the Elvis (?) operator to your type or interface properties. Especially when this practice spreads throughout an application, you start running into problems because you can’t be sure which properties actually exist on an object due to excessive optionality. You’re back to not knowing whether something exists on a data structure or not.
There are great use cases for optionality when you actually can’t rely on the existence of a property, but usage should be restricted to those cases. For the above-mentioned use case, a better approach is to create two types an strict one and an optional one. The optional one is used during the object construction, but once the building is done you replace it with the strict type. This way you end up with much more declarative types that help catch errors and give useful information.
By sticking to the above-mentioned practices, you should be able to ensure that your project stays clean, easy to navigate and flexible. Especially the last part is important! You don’t want the language/framework you code in to become a blocker or drag on innovation and clean code. Being able to easily restructure your code without any overhead or hardship in identifying connected code is paramount in today’s world of ever-changing requirements.
Taking this to heart means more time for thinking about the actual code and less time spent worrying about your process and tooling setup.