Why We Made The Change
When the entire code base can reasonably fit in your head, you don’t need help remembering what a function might return. There’s no worry about calling something that turns out to be undefined: you just make sure things happen in the right order and, in the odd case they don’t, no big deal.
It’s a simple idea with big consequences. It means you can introduce TypeScript into your project without making any changes to your production code at all. Since the latest Babel supports TypeScript out of the box, even your build process stays mostly the same.
This gives you incredible flexibility when it comes to migrating. You can convert one file at a time and all the code will continue to work together. As more of your project is converted and compiler errors addressed, the compiler knows more about your code and how it works so TypeScript becomes increasingly helpful over time.
Having the opportunity was necessary to make the change, but of course that’s not a good reason by itself. Once we knew we could adopt TypeScript we decided to for the following benefits:
The compiler. TypeScript code is run through a compiler. It’s like executing the code at runtime except it executes all of it and therefore knows what kinds of values and types variables can be and functions can return. It knows what contracts functions provide and what interface is represented by each class. And thanks to your editor so do you. If there’s a case where some object may be undefined the compiler will warn you if you try to call some property on it without accounting for the uncertainty. Ultimately this means fewer bugs and production errors.
Having a compiler is like pair programming. While you’re concentrated on figuring out what you want the code to do, your compiler is there to tell you when your code’s behavior diverges from your expectations. Additionally, you can write (and maintain) fewer tests. The compiler replaces whole classes of tests, such as type assertions on function input, return value type, and handling null or undefined values.
- Uncaught TypeError: Cannot read property
- TypeError: ‘undefined’ is not an object
- TypeError: null is not an object
- TypeError: Object doesn’t support property
- TypeError: ‘undefined’ is not a function
- TypeError: Cannot read property ‘length’
- Uncaught TypeError: Cannot set property
- ReferenceError: event is not defined
Easier refactoring. Once a code base is operating on TypeScript making changes becomes much easier. If you need to change a function signature, just make the change and the compiler will tell you every place you need to update. In general refactoring is also safer since you are more likely to know you’ve changed the behavior of the code in a way that you didn’t expect.
Editor/IDE superpowers. TypeScript has built-in first class support in VS Code, which is our go-to editor, and other editors have extensions to add support. When working in TypeScript the editor can give you in-line feedback about types and issues in real time. Intellisense/autocomplete is much faster and more accurate since the compiler has a much more thorough understanding of the code.
Large community. The project itself is open source and backed by Microsoft, so there’s lots of support and a big community to draw on. In fact, TypeScript is routinely listed as one of the most popular languages on the web. GitHub’s most recent State of the Octoverse lists it as the 7th most popular language among developers, and the 5th fastest growing. The practical benefit this offers is that answers are generally pretty easy to find.
Harder to ignore bugs. All software has bugs. Sometimes, especially when spiking or building out an MVP, a developer may not care as much about identifying and solving every bug. If you’re more concerned with making the happy path a prototype work as fast as possible, TypeScript could slow you down.
How We Made the Change
Once we decided to make the change we needed to transition in a way that was piecemeal. We couldn’t shut down feature work for a few weeks while we converted a bunch of files and made the compiler happy. Knowing that, we considered two basic approaches:
One option is to convert all files to .ts and set the compiler to its lowest level. Essentially just throwing warnings for any discovered issues. This gets you to a technically TypeScript code base very quickly, then allows you to quash those compiler issues over time. Gradually, you can increase the strictness and enforce more rules.
Another option is to set the compiler to the most strict settings from the beginning, then convert one file at a time. Files can be converted as engineers work on features. This approach has the benefits of making it clear how type safe the code you’re working in is (100% or 0%).
In our case, we opted for the second approach. We spiked into converting some of the most core logic (the repositories and entities/models relied on throughout our client-side). Otherwise, we generally converted one file at a time as features were built.
As presentational components were converted we could immediately see issues in how they relied on some of the core logic since the deepest parts of the code had already been converted.
The whole codebase was converted within a couple of months without a big rewrite.
Over the course of the transition we learned a number of things that will inform future changes.
Let the team evolve an implementation that suits them. In addition to the compiler, we use ESLint and prettier to enforce style and best practices as much as possible. But, nothing has to be rigid and inflexible. Rules should serve the developers, not the other way around.
For instance, we discovered that compilation failures completely halt the developer’s work, which is great if it’s a functional issue. However, it’s not so great if it’s about style (e.g., unused variables). So we moved any style issues, and anything not pressing to the function of the code, to the linter. The linter is run as part of a pre-commit hook, so we can still keep the codebase consistent without short-circuiting the development cycle.
Use explicit interfaces sparingly. When you’re new to TypeScript it’s tempting to toss explicit interfaces everywhere. This creates a lot of extra code that needs to be maintained. As you get more comfortable with the language, you discover that it’s actually able to infer everything you want most of the time. Wherever possible, let your classes do the talking to the compiler.
TypeScript provides resilience to change that growing codebases can benefit from. Adding it to an existing project doesn’t have to be painful, as long as the process is adaptable. Hopefully, we’ve demonstrated the whys and hows of making the switch. If you’d like to learn more about the language, check out TypeScript in 5 Minutes in the official docs.
I’m a Sr. Software Engineer with Voom/Airbus from Seattle. I love building software with an eye for quality and writing about the process.