Migrating a Large Legacy React App to Typescript
Where I work enforced types on our frontend code base is THE big missing piece in the stack of tools and techniques that help us have confidence in the products we ship. Unit tests with plain ol jest, integration tests with enzyme or react-testing-library, or end-to-end tests with Cypress are all great and these play a crucial role in that confidence, for sure, and the local linters like esLint and CI linters like Sonar do as well. But types, and specifically Typescript with a strict enough configuration, covers an angle, and grants a facet of confidence, that none of these other tools are especially well suited for.
Due to proximity to the code itself, type data alone would be very useful merely as documentation, but with typescript tooling you get lots of inline helpers as well as autocomplete suggestions in code editors, This helps prevent typing mistakes and other simple human error cases, however the advantages of enforced types go beyond just that! With the strictness of the compiler set at a certain threshold where the types have to have a degree of specificity and where the compiler blocks on type mismatches, the types actually become a test in themselves.
Types ARE tests, or to be more precise, enforced types are tests. If your code always passes through a compiler before it can run, or ship, and that compiler will not emit an output if the types are not correct, then your compiler is acting in the same capacity as an continuous integration application that refuses to build your software when its’ test suite doesn’t pass, meaning your types are functioning as tests.
Moreover, types ARE tests:
- that are much faster to run [have a much shorter feedback loop] while also being much more co-located than any end-to-end or integration test,
- that ensure a holistic coherence within the codebase much better than any unit test can hope to accomplish,
- that help cover a range of errors and use cases that notoriously go unaccounted for with typical code coverage metrics, (you can get 100% coverage and still have code that will throw errors left and right on nulls or when given inputs of a type it didn’t expect.)
- that completely dodge the common errors of testing implementation details or overusing mocks which so often leads to false positives, false negatives, as well as fear of refactoring.
Of course, none of this is to say types supplant what is typically known as a test. Types don’t test logic, or functionality. They are however, an important piece of the confidence puzzle that we have been largely missing in our application up to now.
The “enforced types” part of the why statement above is key. Flow, putting type info in jsDoc comments, or just being more strict about React PropTypes, which we have used half-heatedly for a while now were all among the options considered to fill the gap of typing in this large react project.
We even looked at Dart and Flutter while working on the a recent mobile project, and honestly Dart + Flutter has plenty of appeal, the strong typing and good tooling probably go hand in hand for good reasons, however, Typescript was what we chose there, and experience on that project along with trends in the industry all show this is a very good choice.
Typescript’s compile time (rather than runtime) enforcement, superb developer tools, and widespread adoption (especially among tools and libraries we are already using) all work together to be able to make it an obviously good solution.
As with so many things, there is no “One True Way”™ to migrate a legacy project like ours to Typescript. There are two basic categories of approaches however - All at once, or piece by piece. The application in this case, and the number of developers that work on it, are both large enough that a stop everything all at once conversion is not very appealing.
More on migration approaches
The two big factors that affect how a piece by piece conversion actually happens are
- when to do a file re-name
- how/when to escalate the strictness the typescript compiler (transpiler?)
Of course it is really more of a spectrum, but again, it can be sort of broken down to two general approaches from a big picture level.
- Convert file by file in some systemic way - higher strictness to start
- Because TS checks only apply to TS - all your JS can live happily as it was, but a TS file must follow some new rules - it can’t just be plain ol JS in a TS file.
- TS can start with some strict rules in place, but in the very early phases we may need some temporary types and ignore lines to work around things that can’t really be so strict until there are more types in place.
export type FixMeLater = any
- TS check strict increases as team familiarity emerges, or as desired
- Fewer and fewer
@ts-ignorelines would be allowed as more files and components are converted
- Convert all files to .ts straight away - very low strictness to start
- bulk file rename - one and done!
- TS strictness is increased incrementally and types are added or improved as necessary to conform to the new strictness
Pros and cons for each approach, but for this situation I lean heavily towards option 1. “Convert file by file in some systemic way”
Option 2 kind of forces everyone to use TS, while option 1 makes it more opt-in. However, optional in a certain sense. For our team we said it wasn’t that “everyone who wants to should start using TS,” but more of a “on a squad by squad basis there is now the option to start using TS”. We want the people reviewing and interacting directly with TS code to be familiar enough with TS, so making sure each squad was on board together was important.
Option 2 also loses one of the key advantages of types in the early stages, it allows for very minimal typing and very little enforcement, and thus risks the team feeling like it is a fruitless effort - the effort to do TS isn’t to high at this stage but it also isn’t buying much confidence. Option 1 allows those who opt in to use Typescript to feel more of its full advantages from early on.
Either path would require some level of training, but Option 1 makes so the minimum is only need a basic overview in case they have to use or modify a component that has been converted. Option 2 would require much more alignment of training level as the each new strictness rule is implemented.
A good in depth learning resource available to our team was this in depth course on Udemy. A quicker overview is also available on YouTube
Our general path given that recommendation
Converting some larger core components will need many other types to be in place before it really makes much sense to do. The loose suggestion in the early stage is to work on moving simpler parts to TS first. We keep some of our more reusable in the
elements directory, those were good candidates as were Models, and Util files, followed by things like reducers, selectors, actions etc. Another way to think of it is as starting from files that are imported a lot but that have few imports themselves. A potential next step after most of that is done would be requiring any new components to be in TS, and the conversion of existing components to TS before any significant new work is done on them.
With the settings we picked early in the migration the compiler was not so strict that it would completely prevent us from doing or shoddy partial conversions, however doing a half hearted conversion is very discouraged. If you are going to rename the file it is best to do as thorough job of adding types as you can for the entire contents of the file. This way anyone on the team can have a reasonable assumption that if the file is a TS file it has been converted and types for it are available when importing from elsewhere.
Many type definitions will live in the files they are used in, but not all. Details on where and how shared common type definitions will be located and organized should be a piece that settles sooner than later.
A short how to:
Here are a few steps involved with migrating a file to use Typescript. Some of them may not apply to every file and they may change slightly based on the file you’re working on. In general, you can follow these steps as a checklist for work that needs to be done on each file.
- Change the file name from .js or .jsx to .ts or .tsx
- If the file also has a corresponding test file convert it as well.
- If not, consider adding one. Types are great - Types plus unit tests even better!
- Do the ‘typing’
- A React component’s set of props is one of the most crucial things that needs to be typed. If the components have PropTypes and or defaultProps use these as a guide for conversion to Typescript types. One thing to pay attention to is that TS by default expects a prop to be required. An optional property in Typescript is noted by including a question mark at the end of the property’s name.
- A component’s state also needs to be defined with types. The initial state assignment and any call to setState will be indicators of what values are present in the component’s state.
- Avoid use of the
anytype except in test files. Components themselves should be as well-defined as possible.
- Define types for function arguments and return types.
- move types to common location or use common types as appropriate
- Fix the red squiggles. Typescript tooling should alert you to cleanup or additional type clarifications that need to be made.
- Ensure unit tests pass and application compiles and starts up.
- Commit the conversion as its own git commit. Conversion may happen as part of a larger PR with a new feature that is prompting you to touch a given file that is ripe for the conversion, but keeping it as a separate commit allows for the feature addition to be viewable separate from the conversion - otherwise because of the file renaming git/github often won’t display a meaningful diff on the PR.