Like most development teams, our team at Koan is always looking for an edge in the quality and velocity of our work.
About a year back we set out to reduce the pain of refactoring, documenting, and verifying our work by porting to TypeScript — a decision that’s had several significant benefits:
- fewer “fat finger” exceptions in production
- shorter feedback cycles
- tests focused on behavior
- better documentation (especially within IDEs)
- energizing hiring and simplifying developer onboarding
All of these add up to higher quality and faster development. But it wasn’t a switch we could just flip on overnight.
We’ll do it live.
Tool adoption is a conundrum. Technologies that accelerate development over the long haul invariably consume time and energy upfront, an integration tax that’s especially acute within existing projects.
One school of thought is to pay that tax as directly as possible, halting other development until the integration is complete. While we saw TypeScript as a significant strategic opportunity, as a small team we knew we couldn’t afford to pull over for a pit stop.
The alternative was to integrate TypeScript incrementally alongside other development. While static typing offered undeniable benefits, obtaining them meant touching nearly every corner of our codebase, often in non-trivial ways. This would be less scheduled maintenance than swapping out tires while barreling down the highway, but at Koan we’re big believers in the transformative power of small, frequent habits.
On the road again
Fortunately for us, TypeScript’s design and declaration system made it possible to adopt static types progressively throughout the project. After circling up as a team, we settled on a few brief rules to help keep the migration moving along. Whenever we started work on a feature, all new modules would be written in TypeScript.
- …that were reasonably small (or highly relevant to the change) would be ported directly to TypeScript
- …that were larger or less-relevant would be typed via a declaration file
- …that were nearly at parity with their declaration files would be converted to TypeScript
- …that just wouldn’t translate to static types would be flagged as tech debt and set aside (for now)
Let’s dig into how the rules played out in practice.
New code gets typed
The easiest place to begin introducing TypeScript into our codebase was through new, standalone modules. Every new file we’ve added since starting to adopt TypeScript has been typed from the get-go. With relatively high velocity on other projects, this had a quick, meaningful impact on the type-safety of the codebase overall.
We briefly toyed with the idea of a shared git hook to reject new
.js files, but we never really needed it.
Porting modules to TypeScript
.ts file extension. In these cases, the biggest challenge was simply remembering to commit type annotations separately from logical changes. Conflating them was hardly the end of the world, but it did tend to slow down code review.
Happily, the concise, easily-typed modules we ported first included many of the most-called modules in our codebase. For larger, less-central modules, however, we needed other tactics.
As one example, consider the
Domains model that maps a Koan organization’s web domains back to their Koan account. Rather than migrating from
Domains.ts, we added a neighboring declaration file with the
$ tree models
Domains model exposes a number of different methods for creating, querying, and updating an organization’s domains. If we only needed to call a single method, however, that’s as far as the declaration would go.
Retiring TypeScript declaration files
There wasn’t any magic in this process — just a quick, manual touch from the first developer to drop by the neighborhood.
The long tail
The old rule about test coverage is true of TypeScript, too: what’s even better than writing tests (or types) is deleting old, uncovered logic and skipping the migration entirely.
We began our migration with a modest goal:
Today, the vast majority of our codebase has been ported over to TypeScript, including nearly all of the surface area we touch day-to-day. We haven’t stopped development to do it — just stuck to our rules and made steady, incremental progress as we’ve continued to build our platform.