Adventures in converting a big app to TypeScript
We’re now over a year and a half into this process of converting to TypeScript, and we’ve learned a lot along the way, developing many of our own strategies. We knew this would be a monumental undertaking from the outset and many of the challenges we’ve faced were expected, but there have been a few unexpectedly complex hurdles along the way as well. And while the work isn’t done yet, we’re already reaping the benefits of catching bugs earlier in the development process and having increased confidence in our code.
Any reasonably complex application—multiple developers, serving customers, taking payments, etc—will benefit from the same conversion. So, if you’re considering a similar migration, our experience may help you navigate the road ahead a little easier.
Getting up and running
Broadly speaking, this manual process will involve adding the TS package (and other supporting packages for linting, testing, etc) to your project and configuring your bundler to handle TS files. For the Upsun Console, this requires adding and modifying a couple of rules in our Webpack config; for other bundlers, the process may be even simpler. You’ll also need to add a config file for TS to tell it about your project and preferences.
The main decision to make when configuring TS is how strict you want it to be—several settings impact this. With them turned off, your TS code will function essentially like JS; that is, basic type information will be inferred, but you won’t be enjoying the full power of the language.
Starting with this looser approach would lower the initial friction of converting files to TS, but it does come with some significant downsides. By using a strict approach from the outset, the compiler—and your integrated development environment—will not only keep track of types but also point out when and where it needs type information to be specified. This can take a bit more effort up-front, but if you wait until all code has been migrated before you enable those switches, you’ll be greeted with a massive error report which can be overwhelming.
Time to start converting
Once TS has been set up and an initial approach has been chosen, the next daunting decision is where to start converting. We’ve had success with a couple of different philosophies. The first is to identify and migrate leaf files. These are files that aren’t dependent on any of your other code. They only import external libraries, if that. The TS compiler can infer some types from JS files, but anything beyond the absolute basics won’t be typed, more specifically, it will have the dreaded
any type—more on that below. If a TS file isn’t dependent on JS code—which will be the case for these files—you won’t have to go down the rabbit hole of figuring out complex types without the benefit of the compiler and manually adding types that will later be automatically inferred. Simple user interface (UI) components and utility functions often fall into this category.
The other conversion approach we’ve used is to convert files upon which many other files rely. We know that their type information will be widely consumed, so the sooner we ensure they’re accurate, the easier all those conversions will be. Examples include data stores (e.g. Redux) and API communication. These central files may import other local files, so you’ll need to judge whether to convert those files early in the process as well or defer them until later on. Our guiding principle has been to find logical stopping points so that each unit of work remains relatively self-contained.
The other side of the fence here is external packages. Even leaf files may import these. Increasingly, community packages include type information directly, but many still don’t. In these cases, there are a couple of options. Many popular packages have external types available via Definitely-Typed. These types aren’t always perfectly in sync with the actual code, but they’re usually entirely adequate and a wonderful part of the open-source community.
Occasionally, you may find that you’re using a less-common library that doesn’t have any type information available. In these cases, you can easily provide your own types. For the Upsun Console, we store these in
./types and tell the compiler about them with this config:
Existing types can also be overridden with the same mechanism, and you can contribute your types back to the community via Definitely-Typed.
When to add types
It would be reductive to say that the best way to add type information is to add none at all, but the fact is that the TS compiler is very powerful and usually right about inferring types. As much as possible, we let it do its job and only manually specify types when necessary. These situations fall into a couple of categories:
The first is where type information doesn’t exist, for example, function arguments, the
Array.reduce initial arg, API responses, and
JSON.parse. In each of these cases, we must tell the compiler what it’s dealing with. In these cases, we’re fully responsible for accuracy here. The compiler will alert us if it doesn’t think the provided type makes sense, but otherwise, it trusts that we know what we’re doing.
The other category is to specify a type as a contract. If a type is changed—for example, by changing what a function returns—you may not realize it until an error is discovered downstream, which may be several imports away, if it even throws an error at all. To avoid these situations, we sometimes manually specify what type a function is expected to return—even if it could be easily inferred—so that if the return type changes, an error is guaranteed to be thrown, and it will be right where the change has been made.
The danger zone: things to be aware of when converting to TypeScript
TypeScript does bring measurable benefits to complex JS applications. However, it does have a couple of features that are easy to misuse. It should be noted that these aren’t purely evil, and we do use them occasionally, but they should be well-understood and only used when truly necessary.
The first is the
any used to be an unavoidable part of the type system, intended to represent any possible type. In practice, it effectively disables type checking and all the benefits that come with it. We can now mostly replace this with the
unknown continues to check types, but makes no assumptions about the underlying type. To perform any operation on an
unknown type, we must first prove that it’s valid. There are still rare times that we reach for
any, but it always raises the question of whether there’s a better option.
The next feature that can get you into trouble is the
as is a critical part of the language, but it puts the onus of accuracy on the developer. It’s used to cast one type to another. Again, the compiler will complain if it doesn’t think the conversion makes sense—that the types don’t sufficiently overlap—but there’s an escape hatch by casting as
unknown first. As with
any, there are times when this is called for, but we only reach for it when there’s no other option.
The final one isn’t a feature, but it is something to be wary of: complexity. The type system is incredibly powerful, but it’s possible to implement things that are hard to comprehend—both for others and for your future self. Conditional types, mapped types, and recursive types can fall into this category. As with the above caveats, this is often unavoidable in the process of conversion to TS, so we try to isolate and carefully manage this complexity. When possible, we implement a complex type as a black-box utility. We try to define the problem space and constraints at the outset so that the code does one thing well, and doesn’t have to be updated frequently, if ever. Thorough and thoughtful commenting also goes a long way to being prepared for any future changes that may come up.
The impact of migration on your Git workflow
As we conclude this post, we’ll broaden our view and look at the impact that a migration like this has on other aspects of development, beginning with Git workflow.
The initial step of converting a file from JS to TS is to change the file extension. Git is usually able to recognize that it’s still the same file, and all of the file’s history will remain with it. However, if too many changes are made to the renamed file before it’s committed, Git will no longer see it as the same file. Instead of a rename operation, it will be seen as deleting the old file and adding an entirely new one. The worst part of this is that the file’s history will be lost in the process. To get around this, we use two steps: the first only changes the extension on the relevant files. We then commit those renamed files.
If you run linting or type checking locally on pre-commit, you may need to bypass that here, as the renamed files may not pass those checks. This can be done with Git commit’s
—no-verify flag, which will skip running the pre-commit hook. Once the renamed files have been committed, we then add types where necessary and fix any errors that have surfaced.
Often, adding type information to one file will necessitate changes to others—both those that it imports and those that import it. This can be due to pre-existing bugs that weren’t visible previously. Needing to handle
undefined explicitly is something we run into frequently. If you use the
prop-types library for JS React components, it will be superseded by TS types, which are usually much more granular, and that switch will also sometimes necessitate further changes.
It can be a challenge to identify all these changes before beginning an individual migration, so the scope of one change may grow as you proceed. Again, a judicious approach is needed to find the right limits for a given migration, and we often find we need to split one up into two or more.
You may also discover underlying issues with your code in the process that aren’t even related to type information. In our experience, rather than performing that work as part of a migration, it can be better to fix it upfront and commit it separately so the scope of the migration work remains relatively small. In the end, this means we often have up to four stages for each migration:
- Fix any underlying issues that are discovered with the code in question.
- Change the file extensions for all affected files and change nothing else.
- Add necessary type information to these files and fix type (and other) issues that may be uncovered.
- Ensure no functionality has been changed in the process. This may involve manual testing, as well as linting, type checking, testing, and any other QA processes you have in place, and fix any remaining issues that are found.
Regarding that last point, you may need to make adjustments to your other dev tools to support TS. If you’re running eslint, you’ll probably want to add
@typescript-eslint/eslint-plugin and configure additional lint rules that run on your TS files. Some of them even take advantage of type checking and provide a great complement to the error checking provided by the TS compiler.
You may also need to make some changes to your test runner so it supports TS files. Jest requires TS to be transpiled to JS before running, but Vitest supports TS out of the box. The more challenging issue when it comes to writing tests in TS is that the typing doesn’t need to be nearly as stringent as your production code, and it’s not practical to hold it to the same standards. For example, needing to mock an entire complex type because a function is expecting it, even though a test is only concerned with a portion of it.
In these cases, we’re much more likely to rely on quick and dirty tricks like typecasting with
as. It’s also possible to create config overrides for the TS, eslint, and many other tools that are specific to tests. You still need to be mindful that you’re not using a hack to paper over a legitimate problem, but it’s a worthwhile trade-off to avoid having to strictly type your tests.
The big picture: our verdict on TypeScript conversion
For such a large undertaking as converting your entire code base, it would be ideal to be able to halt all other work until the migration is complete. Unfortunately, this will rarely be practical. That said, if possible, it’s beneficial to be able to prioritize one major refactor at a time. New features, bug fixes, and other day-to-day tasks will still need to be accommodated, but performing other huge-scale projects simultaneously will result in overall complexity growing exponentially.
We’ve also learned to minimize functional changes when adding types. While performing an in-depth survey of your entire code base, you’ll undoubtedly come across technical debt and other issues that would be nice to fix. These are best documented and left until a later date, because otherwise, the scope and complexity will spiral out of control, and the amount of time required for the complete conversion will too. In general, we try to isolate manageable chunks of changes that are quick to turn around into TS. They’re easier to understand and review and will result in fewer merge conflicts.
When we started this process at the beginning of 2022, we had just over 100,000 lines of JS code for Console. A year and a half later, we’re at approximately 70,000 lines each of JS and TS. We’ve also added plenty of great features along the way, and there’s more to come! At this point, all our new development starts as TS.
There have been challenges along the way. We’ve had to get all our existing tooling to interact with a new language, train the team to use it, and accept that things that were easy—or easily ignored—in JS may now require a stricter approach. But the benefits have far outweighed the downsides. We have more confidence in the code we’re running in TS. We’ve found and fixed numerous shortcomings in our older code. We can catch issues much earlier in the development process—usually before they make it to production.
A migration of this scale isn’t something to take on lightly, but it’s an attainable goal. While we still have a way to go before this project is complete, TS is now integrated smoothly into our workflow, and whether we’re adding new features or converting old ones, it’s been a great addition to the Upsun Console.