
TypeScript has become the de facto standard for building robust, maintainable, and scalable JavaScript applications. Yet, migrating a large production codebase to TypeScript can be a daunting task, especially when you're dealing with 160,000 lines of mission-critical JavaScript and users with real money in your system, and their tax returns to submit.
At WorkMade, I decided the pain would be worth the plunge (after founder buy-in!) and benefit us in the long-run, so we took on the challenge and migrated our entire codebase to TypeScript over six weeks – without any downtime. Here's how we did it, the challenges we faced, and the tools that made it possible. Shoutout to Anya Hargil and JD for joining me on this wild journey, and humouring me with my initial motivation and plan to carry this out...
The Challenge: Migrating at Scale Without Disruption
When working on a large-scale production application, the last thing you want is downtime. Our application was powering crucial financial and banking services, and any interruption would directly impact our customers. Yet, we knew the long-term benefits of TypeScript – better developer experience, increased maintainability, and a safer codebase – were worth the effort.
The main challenges we faced were:
- Zero Downtime Requirement: We needed to continue deploying new features and fixes while the migration was underway. This was a seed-stage startup with the first crop of faithful users - ensuring their (stable, positive) continued experience was paramount; this was their livelihood we were handling.
- 160,000 Lines of Code: This wasn't just a weekend project – the sheer volume required a strategic approach. The API was a straight-up Node.js Express app serving a GraphQL API, with a Websocket server t'boot.
- Multiple Environments: Our code was powering Lambda Workers in AWS and a Node.js GraphQL API running in ECS. A mistake could impact multiple services, and multiple users.
The Strategy: Maintaining a Separate Branch
The key to a smooth migration was maintaining a separate branch. Here's how we approached it:
- Create a Migration Branch: We created a dedicated branch where all
.js
files were renamed to.ts
. This branch would be the playground where TypeScript would be gradually introduced. I was in the CTO role at the time, so I sat in the quarterback position, stepping back from feature/bug work, to orchestrate this migration. At seed-stage, it can be hard to hope off theSHIP IT NOW
train and invest some time in a longer-term pay-off, but this is one I felt confident and sure about. - Periodic Rebasing: To keep up with ongoing feature development on the
main
branch, we periodically rebased the migration branch. This ensured no conflicts when we eventually merged back. This meant that I only had to convert the new.js
files into.ts
files each time, because I'd set a different output directory for the TypeScript files to compile into. - Incremental Typing: We introduced TypeScript incrementally, starting with the most critical modules, progressively moving towards full typing. One of the more tedious tasks was updating all the existing
require()
calls to use the mode modernimport / export
syntax. I think one of the first TypeScript compiles I attempted has over a 1000 individual, mostly-unique, errors. In those moments, you just have to put your favourite album on and plough down the list monotously. - Continuous Integration (CI) Validation: Every rebase triggered our CI pipeline, running all tests to validate the stability of the TypeScript version, including the Jest tests and Checkly monitoring tests.
Testing and Staging: Ensuring Zero Downtime
After TypeScript compiled without errors and all tests passed, we deployed the TypeScript version to our Staging environment. Here's what we did next:
- 2-3 Days of Solid Testing: We tested the application rigorously in the Staging environment, using Checkly for uptime monitoring to ensure no regression issues. I honestly couldn't imagine running an app in production without some form of uptime monitoring - and Checkly is a fantastic choice that I'd evangelise at every opportunity. We not only had basic uptime monitoring for the API being up and healthy, I also built checks to assess specific operations in the system (signing up, logging in and money movement predominantly) - with the money movement checks only taking place in Staging, as to not create too much accounting nonsense and noise. You can use a simple egg-timer (picture the ones with the sand in, where you tip it upside down to trigger the count down) - set up two bank accounts, and transfer $1 back and forth between the accounts until one is empty, then start filling it back up from the other account - essentially creating your own money movement heartbeat.
- Environment Parity: We switched all Lambda Workers in AWS and the Node.js GraphQL API in ECS over to the TypeScript version in Staging. This mimicked the Production environment closely. This was a fun part. Our Lambda workers were pointing to the existing JavaScript source files to run, however, as soon as we deployed, they'd no longer exist, so I set a conditional line in the CDK file to tell Docker how to run the new TypeScript-compiled-JavaScript file if it was there, or the original JavaScript source file as a fallback, to account for the multi-step deployment race condition. This looked something like:
cmd: [
'sh',
'-c',
`[ -f src/workers/${props.folderName}/move-money.js ] && node src/workers/${props.folderName}/move-money.js || node lib/workers/${props.folderName}/move-money.js`,
],
This meant the workers would still be available and invokable in some fashion, even if it was still using the original JavaScript. The next time the deployment happened, we could safely remove the conditional line.
- Zero Downtime Deployment: Confident from the Staging tests, we pushed the changes to Production. Everything went smoothly, with no downtime or incidents. Yay for us...
Results: Enhanced Developer Experience and Bug Fixes
The results were immediately noticeable:
- Developer Experience (DX) Boost: Developer productivity and happiness skyrocketed thanks to TypeScript's enhanced editor support and better refactoring capabilities.
- Bug Detection: The TypeScript compiler caught several bugs that had gone unnoticed in the JavaScript version, increasing overall application stability. Win's from the off...
Tools and Extensions That Made It Possible
During the migration, several VSCode extensions and tools significantly eased the process:
- ESLint with TypeScript Support: For consistent code quality and linting.
- Typescript Hero and TypeScript Import Sorter: For automatically organizing imports.
- ts-migrate and jscodeshift: To automate some of the repetitive tasks.
- Checkly: For end-to-end monitoring and uptime checks during Staging tests.
Conclusion: Was It Worth It?
Absolutely. Migrating to TypeScript was a challenging but rewarding endeavor. Not only did it improve the developer experience, but it also enhanced the stability and maintainability of our codebase. The transition was seamless, thanks to strategic planning, a robust CI/CD pipeline, and the right set of tools. The feedback loop for catching silly bugs was reduced tenfold.
If you're considering migrating a large-scale production application to TypeScript, take the plunge. The long-term benefits are well worth the initial investment. Give me a shout if you want any more tips!