Converting Codecademy to TypeScript 2: Technical Changes
Previously: Part One: Converting Ourselves
Once we knew we were going to convert our main “monolith” repository from JavaScript to TypeScript, we had a few technical hurdles:
- Strategy: Would we convert all-at-once or incrementally?
- Integration: How would TypeScript fit into our existing CI/CD systems?
- Automation: What could we do to minimize work?
Adapt! React! Readapt!
There is no one-size-fits-all solution for any shift. It would be foolish of us to strongly recommend any precise path to adoption without understanding your specifics. Your team has a unique blend of legacy code, business requirements, and prior understanding. Your strategy should adapt to it.
We made a few subjective technical decisions from the start based on our blend:
- We did start with
--strictNullChecks
because the flag is just so useful, and our React code tended to be written without many violations of it. - We did not start with
--noImplicitAny
because it would require much more work upfront and is harder in a mixed JS/TS codebase; instead, we tabled that flag for later. - We did not fail pull requests that reduced type coverage percentages. Doing so inconveniences developers unfamiliar with TypeScript or in a hurry to modify existing, very-not-type-safe legacy code.
Your internal technological evangelism should probably work differently than ours. Convincing yourself and others of a choice to make is as much understanding your team members’ core motivations as it is marketing it. I’d personally recommend the classic How to Win Friends and Influence People by Dale Carnegie for broad tech evangelism strategies.
Strategy
Converting all of our files to TypeScript at once was unfortunately not a viable option. There were too many type checking complaints in our code to do a pure file rename, and ignoring them with // @ts-ignore
comments would have polluted the code base without generating the appropriate types to write clean new TypeScript code.
We needed an incremental adoption with as few structural changes and as many automated conversions as possible.
See the previous post, Converting Ourselves, for our behavioral aspects — the remainder of this post is solely the technical changes.
Integration
Our tech stack is pretty common for large web applications made post-jQuery/Knockout. Its main pieces of technology are:
- Webpack: compiles our source files (JavaScript, SCSS, etc.) into combined “bundles” that can be loaded by users’ browsers
- Babel: used by Webpack to transpile fancy new or non-standard syntax in our JavaScript files into older equivalents so that users’ browsers are guaranteed to be able to run our code
- JSX: syntactic sugar for JavaScript files that makes writing React easier
- ESLint: separately run during our builds to check our source files for obvious defects without running them
Babel
Babel’s TypeScript preset allows your TypeScript files to be picked up by your Babel transpilation with their TypeScript syntax removed. The files are treated as regular JavaScript files thereafter. Perfect!
Our single babel.config.js
change to add TypeScript was minimal, and involved us just adding @babel/preset-typescript
to our list of extended presets.
Note that the Babel TypeScript transformer is speedy because it doesn’t perform TypeScript type checking. We added a tsc
run as a separate build command to make sure we didn't introduce type errors.
You can peek at our shared open source babel-preset-codecademy if you’d like a reference. The babel.config.js
is unfortunately not open source (yet!).
Webpack
We use the standard recommended babel-loader in our Webpack configuration, which is what tells Webpack to run Babel transpilation on source files via the normal Webpack loader schema.
That meant our only effective change in Webpack was to switch the Babel loader from its default of including all JavaScript files to (j|t)sx?
, or js
, ts
, and tsx
files.
You can also peek at our shared open source webpack-config for a reference. The specific pull request is a little more complex, but visible here.
JSX
TypeScript understands JSX syntax as long as a file’s extension is .tsx
.
💯
ESLint
ESLint supports TypeScript files using the typescript-eslint project. We added typescript-eslint to our existing ESLint configuration and otherwise didn’t significantly change our existing lint steps.
ESLint’s only major hiccup was a dramatic increase in lint times that, while not the last step to finish in our parallelized build pipeline, was still an annoyance I learned by coincidence that the root cause was from including the community import ruleset; removing it fixed the lint times.
FAQ: Is ESLint Still Useful With TypeScript?
Yes!
We still use ESLint for our linting even though many of its core rules are made redundant when using TypeScript.
- Many stylistic choices and technically-acceptable-but-practically-negative code patterns aren’t banned by TypeScript.
- ESLint’s community rulesets also add a lot of value — for us, particularly around JSX/React accessibility. I couldn’t imagine writing React hooks without the rules-of-hooks ESLint rules!
Our shared ESLint configuration is also open sourced in our client-modules monorepo for reference.
FAQ: What about TSLint?
TSLint is a TypeScript equivalent to ESLint from before typescript-eslint was created. It works similarly to ESLint but with out-of-the box TypeScript support.
TSLint is dead. Stop using it. Stop using it!
Automation
In general: the less work you do in a conversion, and the more you automate it, the easier it will be. We knew we wanted to minimize work (and therefore pain) early on and were fortunate to work in an already semi-typed world thanks to our moderate usage of React prop-types.
Somewhat coincidentally, I had started work a few months earlier on a project called TypeStat: a utility that creates new and improves existing TypeScript type annotations in JavaScript and TypeScript code. Converting from JavaScript to TypeScript is a challenge many organizations face yet the existing converters I could find online were very minimal or limited to particular frameworks.
TypeStat was an absurdly useful tool in automating type migrations. It certainly didn’t do everything for us — Redux typings in particular were a pain to work though — but it was excellent at generating best-guess starting types for us to work off of. At the very least, the time spent working on it was worth the time it saved in declaring types.
If you’re in a situation at all like ours, I’d definitely encourage you to give TypeStat a try. It’s still early stage and buggy, so any GitHub issues or pull request activity would be lovely. 💖
Completion
Technical conversion work on our main codebase commenced on April 2nd, 2019: a day after our April 1st LOLCODE course launched. The conversion took us until we merged the last commit at 5pm on Tuesday, December 17th: 259 days since the technical effort began in earnest.
After the conversion finished, we ran a script to chart out conversion over time; the script created a .csv
file for each commit to our master
branch.
It used this line.sh
to, for each commit, print a row with its timestamp, JavaScript file count, and TypeScript file count.
Most of the time was spent doing small conversions, with the occasional giant batch conversion for fun.
When the conversion was completed, there were approximately 1,800 Webpack-consumed files in the Codecademy main repository. We also happened to spot several dozens of unused files during the migration, separately converted our Expo + React Native mobile app using similar strategies, and created or deleted many dozens more files. The precise number of converted files is probably a bit higher than 2,000.
Up Next: Part Three: Learnings and Next Steps