TL;DR
Our team at Voom converted our entire JavaScript frontend to TypeScript. We realized huge benefits in refactoring and error reduction, and encountered some bumps along the way.
Why We Made The Change
JavaScript is a very flexible language that really doesn't get in your way. On small projects and minimum viable products (MVPs) this has huge advantages. You can quickly iterate on a product, creating an experience that provides the happy path you need without a lot of boilerplate.
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.
However, the same things that make JavaScript so powerful for quick iteration can also make it tough in larger and more complex code bases. Anything can be anything. There's no telling what that object you're calling will be at runtime, or even if it will exist at all. As function declarations and object definitions begin to accumulate it becomes harder to remember exactly what each one does and what it returns.
Enter TypeScript
If you've heard anything about TypeScript you probably know that it's a superset of JavaScript. And that might its single most important feature. All valid JavaScript is valid TypeScript. All TypeScript compiles down to JavaScript.
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.
Fewer runtime errors. Type safety catches a wide array of bugs that would otherwise make it to production. Rollbar has analyzed thousands of projects and compiled the top 10 JavaScript errors they have seen. At least 8 of them can be avoided with the type safety TypeScript provides:
- 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.
Trade Offs
Nothing is perfect. When considering whether to add TypeScript we identified several trade offs over JavaScript and our previous setup.
Learning curve. TypeScript adds new concepts that don't exist in vanilla JavaScript. Understanding and defining things like types and interfaces takes time to learn. Developers with a background in other C-like, strongly-typed languages will pick up these pretty fast, but developers who haven't had experience in those kind of languages will have a steeper learning curve. Also, in addition to runtime execution and automated tests, you also have a compiler to pay attention to.
Less flexibility. In JavaScript things are really flexible. Pretty much anything can be pretty much anything. Have a function that expects two parameters? You can call it with none. Or one. Or ten. JavaScript doesn't care, and you won't get an error just because you didn't stick to the function's signature. TypeScript isn't quite so forgiving. If you call something that's undefined under some code path that you don't care about, TypeScript will care. And it will make you care, too. You'll either have to take time to alter the design of your code to prevent the underlying issue, or explicitly sacrifice the type safety it provides.
More application complexity. TypeScript can't be executed by most web browsers, so you're going to need to setup your build pipeline to handle compiling down to JavaScript. Chances are this won't be too bad considering the built-in support that Babel now provides, but it's still added complexity in a part of the application that most developers are hesitant to mess with.
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.
Lessons Learned
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.
Wrapping Up
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.