Photo by Andy Li on Unsplash

Migrating Koan’s 150,000-line JavaScript codebase to TypeScript

RJ Zaworski
Developing Koan
Published in
5 min readFeb 25, 2021

--

Like most development teams, our team at Koan is always looking for an edge in the quality and velocity of our work.

We made an early bet on an all-JavaScript stack with these goals in mind. By embracing a single, general-purpose language, the thinking went, we’d be able to standardize tooling and logic and create a familiar development experience across our entire platform. Some of this panned out and some of it didn’t. JavaScript’s benefits come with some big downsides, and the challenges of writing and evolving large JavaScript applications haven’t lessened as our team and platform have grown.

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.

Hello, highway.

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.

Existing modules…

  • …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

When a module was small enough to tackle in the context of feature work happening nearby, we did just that. For the most part the type system settled gently onto our existing JavaScript — more a credit to TypeScript’s designers than any intentional effort on our part — and modules was as simple as annotating function parameters and adding a .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.

Creating TypeScript declarations for JavaScript modules

Some of the modules in Koan’s codebase wander more than others. While they’re all good at cordoning off logic and data, some of the larger modules run to several thousand lines. In cases where it proved prohibitive (or undesirable) to break these up, we created TypeScript declaration files shadowing each JavaScript module. This allowed us to progressively type sections of a module’s interface without having to port everything over en masse.

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.js to Domains.ts, we added a neighboring declaration file with the .d.ts extension:

$ tree models
├── ...
├── Domains.d.ts
├── Domains.js
└── ...

The 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.

A minimal declaration file for Koan’s Domain model

Separating TypeScript annotations from JavaScript implementations comes with a big risk: if the implementation changes without an update to the header, TypeScript may actually work against the implemented behavior. Still, declarations proved a useful bridge across those cases where it didn’t make sense to migrate a module in one pass.

Retiring TypeScript declaration files

While TypeScript declarations are a nice transitional tool, they can easily misrepresent (or drift away from) the JavaScript they represent. As TypeScript spread across our codebase and our declaration files neared parity with the modules that implemented them, we made a priority of merging both declaration and implementation into a shiny, new TypeScript module.

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

These rules helped us quickly expand TypeScript coverage across the “active” regions of our codebase. Still, a good number of (frequently older) modules remain in JavaScript simply because we haven’t touched them. We may never touch them, either: in the spirit of a progressive migration, they may stay right where they are until the features that depend on them are evolved, replaced, or removed. Sure, we would rest easier with these modules (in whatever form) secured by static types, sure. But where the cost fell short of the anticipated benefit we’ve saved our energy and moved on to bigger things.

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.

Reflecting back

We began our migration with a modest goal:

to reduce the pain of refactoring, documenting, and verifying JavaScript

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.

Thanks to Ashwin Bhat and Andy Beers for reviewing drafts of this article.

Hey, Koan is hiring! Ready to use your technical know-how to help change how teams work together? We’d love to hear from you!

--

--