The Great CoffeeScript to Typescript Migration of 2017

// By David Goldstein • May 13, 2020

Editor’s preface: When I first joined Dropbox in May 2017, we were at the tail end of our CoffeeScript to TypeScript migration. If you wanted to make changes to a CoffeeScript file, it was considered common courtesy to convert it to TypeScript while you were at it. Parts of our codebase were still using react-dom-factories and we had a custom flux implementation predating Redux.

I knew our web platform team was moving full speed ahead to migrate us to TypeScript, but I had no idea about the scale or complexity of the migration. TypeScript has become the de-facto superset of JavaScript, and I knew it was time for us to tell this story. Most of this took place in 2017, but it’s as relevant as ever.

I approached David Goldstein, one of the lead engineers on the project to write it. We recruited Samer Masterson, another engineer on web platform at the time to fill in the details.

This post is longer than most. We wanted to capture the massive scope of migrating hundreds of thousands of lines of CoffeeScript to TypeScript. We share how we picked TypeScript in the first place, how we mapped out the migration, and when things didn’t go according to plan.

The migration wrapped up in fall 2017. In the process we developed some pretty nifty tooling, and became one of the first companies to adopt TypeScript at scale. —Matthew Gerstman

Prehistory: CoffeeScript adoption

In 2012, we were still a fairly scrappy startup of about 150 employees. The state-of-the-art in the browser was jQuery and ES5. HTML5 was still another two years away and ES6 was another three. As JavaScript itself appeared to be stagnant, we were seeking a more modern approach to web development.

At the time, CoffeeScript was all the rage. It supported arrow functions, smart this binding, even optional chaining, years before vanilla JavaScript caught up. As a result, two engineers spent their Hack Week in 2012 migrating the whole dropbox.com web application from JavaScript to CoffeeScript. Since we were still small, this could be done without any significant process. We took guidance from the CoffeeScript community and adopted their style recommendations, eventually integrating coffeelint into our workflows.

In CoffeeScript, curly braces, parentheses, and sometimes even commas are optional.

For example foo 12 is equivalent to foo(12).

Multi-line arrays can be written without commas:


// CoffeeScript
[
  "foo"
  "bar"
]

// JavaScript
["foo", "bar"]

This approach to syntax was popular and we went as far as adopting the “if it’s optional, don’t write it” recommendations from the community.

At the time, the codebase consisted of about 100,000 lines of JavaScript. This was shipped as a single bundle by concatenating each file in a pre-specified order. While many of the company’s engineers touched this code, there were fewer than 10 who were working full-time on the web.

As you can imagine, this didn’t scale well; in 2013 we adopted the RequireJS module system and started writing all new code to conform to the Asynchronous Module Definition spec, commonly known as AMD.  We did consider CommonJS, however npm and the node ecosystem had yet to take off, so we chose the tool that was designed for use in the browser. Had we made this decision a few years later, we would have likely gone with CommonJS instead.

Rumblings of a language change

This worked quite well for a few years, however by the end of 2015, our product engineers became frustrated with CoffeeScript. ES6 was released earlier that year, and included the best features of CoffeeScript and more. It supported object and array destructuring, class syntax, and arrow functions. As a result, some teams went ahead and started using ES6 on their own isolated projects.

Simultaneously, our CoffeeScript codebase was proving difficult to maintain. As CoffeeScript (and vanilla JavaScript) are both untyped, it was very easy to break something without intending to. Defensive coding was common, but had the effect of making our code harder to read. We sprinkled around extra safeguards for null and undefined, and in one of the more extreme cases, we resorted to a hack to make a constructor safe to call without new.


class URI
    constructor: (x) ->
      # enable URI as a global function that returns a new URI instance
      unless @ instanceof URI
        return new URI(x)
      ...

In addition, CoffeeScript is a whitespace-based language. This means that tabs and spaces can make the code execute in a functionally different way. This is very similar to Python, the language Dropbox was built on. Unfortunately unlike Python, CoffeeScript was much more laissez-faire about whitespace and way too lenient about punctuation in code; often “optional punctuation” actually meant “CoffeeScript will compile this to mean something different than you expected.”  

For example, we had a major production bug in the fall of 2013 due to a misplaced space character. In Python this wouldn’t compile at all; but in CoffeeScript this compiled to the wrong thing. While CoffeeScript's similarity to Python may have aided its adoption at Dropbox back in 2012, the differences were often problematic. Some of our more experienced developers chose to work by keeping the output JavaScript open side by side with their CoffeeScript code.

Sensing the growing distaste for the language, in November 2015 we ran a survey of frontend engineers at Dropbox and discovered that only 15% thought we should stay on CoffeeScript, and 62% felt we should ditch it:

The most common complaints among developers were:

  • Lack of delimiters
  • Overly opinionated syntactic sugar
  • Lack of community support for the language
  • Hard to read because of dense syntax
  • Prone to errors because of syntactic ambiguities

Armed with this feedback, we looked at the frontend landscape and decided to experiment with both TypeScript and vanilla ES6. We integrated both technologies into our dropbox.com stack to determine which one worked best for us. We considered Flow as well, but it was less popular than TypeScript and seemed to have less support for developer tooling. We decided that if we were going to go with a typed language, TypeScript was the better choice. While this would be the obviously correct decision in 2020, back in 2015 it was much less cut and dry.

In the first half of 2016, we had an engineer integrate Babel and TypeScript into our build scripts. We were now able to try out both languages on the main site. By testing in production, we came to the conclusion that TypeScript was effectively ES6 with types; and as the team had a preference for types, it was decided we would migrate to TypeScript.

However there was one small snag: since 2012, our codebase had grown to 329,000 lines of CoffeeScript; and our engineering team had grown significantly too. As a result, there was no longer a single team responsible for the whole website. We wouldn’t be able to migrate nearly as quickly as we had done when moving from JavaScript to CoffeeScript.

An optimistic migration plan

We set out to develop a migration plan. Our original plan had 5 major milestones:

M1: Basic Support

  • Add the TypeScript compiler
  • Enable TypeScript and CoffeeScript code to interoperate
  • Basic testing, internationalization, and linting on TypeScript.

M2: Make TypeScript our default language for all new code

  • Optimize developer experience
  • Migrate core libraries
  • Document best practices
  • Document how to migrate code

M3: Make TypeScript a first-class citizen of our codebase

  • Take M2 farther, more education, complete linting and testing support, convert rest of important libraries

M4: Migrate a list of our most edited files to TypeScript; targeted for April 2017

  • Manually convert a list of ~100 commonly edited files from Coffeescript to TypeScript. The original CoffeeScript would be available in git history.

M5: Remove the CoffeeScript compiler; targeted for July 2017

  • Compile and commit output JavaScript of any remaining CoffeeScript code. Source CoffeeScript would be available in git history.
  • Require any modifications to this JavaScript code to require migrating the whole file to TypeScript first.

This plan was signed off by the higher levels of engineering management.

M1, M2, and M3 were all executed smoothly throughout the second half of 2016. We built a robust Coffee/TypeScript interop. Testing was simple: we reused our existing Jasmine-based infrastructure to run tests regardless of language (we later migrated to Jest, however that is a story for another time). We integrated TSLint and wrote our style guide, which was a tweaked version of the Airbnb style guide.

M4 and M5 caused more trouble, as those actually required product teams to port pre-existing code to TypeScript. We hoped that the pre-existing code would be the responsibility of the teams who owned it. As an engineering organization, we had decided to set aside 20% of product team’s time for the year for “foundational work” and we thought that part of that blank check would be applied to this project. More on that in a bit.

CoffeeScript/TypeScript interoperability

We achieved interop for CoffeeScript and TypeScript as follows: for every CoffeeScript file, we created a corresponding .d.ts declaration file in our typings folder. We auto-created these, and the majority looked like this:


declare module "foo/bar" {
  const exports: any;
  export = exports;
}

That is, we typed everything as any. For the modules we cared about, we could either convert them to TypeScript, or incrementally improve the typings. For popular external libraries like jQuery or React, we found the typings that we could from DefinitelyTyped. For less popular libraries, we did the same default stub approach.

We put all of our TypeScript and CoffeeScript files in the same folder, so that the module id of the file was the same whether it was CoffeeScript or TypeScript.  We briefly ran into some snags while learning how AMD imports/exports corresponded to TypeScript import and export syntax; fortunately that was mostly straightforward. We did not use
--esModuleInterop, which wasn’t available until TypeScript 2.7.

The equivalent import statements are below:

TypeScript (recommended)

import * as foo from "foo";

TypeScript (not recommended)

import foo = require("foo");

were the same as the AMD JavaScript (or equivalent CoffeeScript)

define(["foo", ...], function(foo, ...) { ... }

Named exports like export const foo; could be read in CoffeeScript by importing the module and then destructuring {foo}. This provided a nice syntactic relationship with normal ES6 named imports.

There was one major surprise, TypeScript’s  export default, when imported to an AMD module, was equivalent to the object {default: ...}.

Most of our modules were able to get by with these equivalencies, but we did have a few modules that dynamically determined what they would export. As a workaround, we exported all possible exports from every file, but made them undefined in the cases where they wouldn’t have before been returned. 

Before

define([...], function(...) {
  ...
  if (foo) {
    return {bar};
  } else {
    return {baz};
  }
})

After


let foo, bar;

if (foo) {
  bar = // define bar;
} else {
  baz = // define baz;
}
// Export both regardless.
export {bar, baz}

Banning new CoffeeScript files

For M2, we implemented a hard ban on new CoffeeScript files in our codebase. This didn’t stop people from editing existing CoffeeScript—as there was plenty of that around—but it did force most engineers to start learning TypeScript.

The way we initially implemented this ban was to write a test that walked the codebase, found all the .coffee files, and asserted all the files that were found were on a whitelist. This list was populated with the paths of .coffee files that existed when the test was written. Our code review tools enabled us to require a Web Platform engineer review any changes to this test file.

Parallel to this migration, we were adopting Bazel as our build system. During the Bazel migration this test briefly broke, returning an empty list for the list of all of our CoffeeScript files, and started asserting that that empty list was a subset of our whitelist of old CoffeeScript files. Fortunately in the time between when the test was broken and when we noticed and fixed it, only 2 new .coffee files were introduced, with good intentions on the part of those authors.

We learned a lesson here: if your tests make any assumptions, try to make sure that they test those assumptions and fail when they break, instead of passing without testing anything useful. Our original test would have benefited from asserting that the list of CoffeeScript files was non-empty—as anything that broke our ability to build that list would have been noticed if such an assert existed.

When fixing, we added a strict equality check against our whitelist so that as files were deleted, we enforced they also got removed from our whitelist, and then couldn’t be reintroduced (without being explicitly re-added). We’ve taken this approach to all of our whitelisting efforts since, as it tends to make breakages in the test’s assumptions very obvious, and also ensures that we don’t let people unknowingly regress ongoing migrations. There is a small downside: changes that shrink the whitelist incur our blocking code review, but those are uncontroversial and we try to accept those reviews promptly (within a business day).

Early experience: We didn’t miss CoffeeScript’s syntactic sugar

One of our concerns when we were originally choosing a language to migrate to was that ES6 & TypeScript did not include all of the features of CoffeeScript. While we got arrow functions, destructuring, and class syntax, the obvious missing operation was the CoffeeScript ? and ?. operators.

We originally thought we’d miss those. However once we adopted TypeScript 2.0’s
--strictNullChecks, we found that we didn’t need them. Most usage of the optional chaining operators were just to deal with uncertainty about what could be undefined or null, and TypeScript helped us eliminate that uncertainty.

Funnily enough, both optional chaining and nullish coallescing were recently re-added to vanilla JavaScript, and have shown up in the TypeScript language, though with some small syntax changes and differences from the original CoffeeScript variants.

Competing priorities

In the second half of 2016, a parallel team formed to implement a redesign and rearchitecture of our website using React. At the end of the year, they were given performance goals for the new site to hit before it could ship. That team was aiming to ship the new website by the end of the first quarter of 2017, right around our original M4 milestone. Shipping the redesigned website, dubbed “Maestro,” took precedence over scheduling the work to migrate their parts of the website to TypeScript; and many other teams with website presence had also been brought in to update their pages to match the new design’s layout, colors, and general design language.

The Maestro team, in the end, promised that while they wouldn’t do the work in Q1, they would get to it in Q2. They held up that bargain; the new website shipped with many features rewritten in React and TypeScript, and the ones that weren’t completely rewritten but were on our commonly edited CoffeeScript files list, were ported in Q2.

One of the tools we used during the migration was an up-to-date list of “highly edited” CoffeeScript files. We strongly encouraged the community to convert these. Unfortunately, the issue remained. We were attempting to hit M4, but this list included about 100 files and took a lot of community encouragement to get converted. This milestone did not ship on time.  

Extrapolating from this, it become clear that the plan to actually delete the CoffeeScript compiler was not happening anytime soon. While the list of highly edited CoffeeScript files was only 100 files, we still had over 2000 in our codebase. Even if a file wasn’t considered part of the “highly edited” list, any of them were just a single feature request away from having a team directed to them.

Postponing M5

The M5 milestone caused a lot of confusion in the organization; while the documentation was pretty clear on what it meant, we often summarized it succinctly as “get rid of the CoffeeScript compiler.”

An alternative interpretation had arisen. Many people believed that while it wouldn’t be possible to write CoffeeScript after the deadline, product teams could just edit the supposedly read-only code, or even edit the CoffeeScript, and check in the new compiled code.

As a platform engineer, this idea was horrifying. Had we simply checked in compiled code, we would lose support for i18n and linting on a massive subset of our codebase; the only way these things were going to work with compiled code—without extra investment we weren’t planning—was to assume the code did not change.

Furthermore, the milestone didn’t make a lot of sense from the platform point of view. One of the key drivers of getting rid of the compiler was to have a single-language codebase and focus our attention on TypeScript tooling.

While we may been able to pretend that was the case with “read-only JavaScript,” it was unclear if this was any better than leaving them as CoffeeScript files. And as we mentioned earlier, we were also in the midst reimplementing our build system with Bazel. That work was nearing completion and we had already paid the cost of implementing support for both CoffeeScript and TypeScript compilers.

So in June, the TypeScript migration was postponed indefinitely. While it was still going to happen, there was no ETA for when it would actually be completed.

In hindsight this decision seems to have been inevitable given our initial migration strategy. Assuming a rate of roughly 1000 lines of code converted per engineering day (including testing and code review), it would have taken a year of one engineer’s time to complete the migration. That rate was actually very optimistic, as the actual reported rate of progress was more like 100 lines per day, which would have taken closer to 10 engineering years, or a full year of 10 engineers time.

Even if we split the difference and called it 3-5 engineer years, it was absurd to expect anyone to want this as their full time job for even a month or two.

While it would be easy to declare half the codebase as abandonware that didn’t need migration, that was untenable. It wouldn’t solve the underlying problem that we’d have at least an order of magnitude more time required for our manual conversion plan than anyone actually wanted to spend on it. Furthermore, there was likely to be feature development needed in some of those parts.

As for that promised “20% time on foundational work” that we thought was going to be spent partly on manual conversions from CoffeeScript to TypeScript: the reality is that we hadn’t thought this through. We didn’t have a high level agreement on what was “foundational,” nor how this time would be budgeted. While half the organization understood this was for requests from the infrastructure org, some teams believed it included time for them to pay down their own technical debt.

Within infrastructure, no one was actually making sure that our asks for product teams actually only added up to 20%. We were competing with each other, but not accounting for that in planning. For example, one of the larger priorities that took up time in the first half of that year was the migration of our production systems to a newer Ubuntu distribution and Linux Kernel, as Ubuntu 12.04 had reached end of life.

While many teams have continued to do some percentage-based budgeting for foundational improvements vs. new feature work, since 2017, we have not repeated this concept of a blank check to spend on migrations.

A new plan with decaffeinate

Early testing with decaffeinate

Rewind back to January 2017, a few engineers had played with using decaffeinate to ease the conversion of code, and even started building some tooling around it to make it deal with AMD and clean up React style via some open-source codemods.

Unfortunately, our first attempt to use decaffeinate caused a major outage. We converted our i18n library, reviewed it, tested and shipped it to production, only to realize that decaffeinate had mis-converted our untested locale-aware sort function. This was only used by one page, but it completely broke that page in Safari. After this, we took a look at the decaffeinate bug backlog and were intimidated by the dozens of similar looking issues we saw. While some of us were interested in working on this, we could not definitively say whether it would take a few months of our time, or a few years, until the point we could trust decaffeinate to run on our codebase. Given this, our manager at the time did not want to invest in this approach.

Despite this, a few engineers decided to use it to aid with their manual conversions, and we documented it as one possible workflow. Our decaffeinate-based script often generated obviously invalid code, such as import statements that weren’t syntactically valid, or that involved invalidly re-declared variables. This was not a big deal, because TypeScript would complain about these at compile time. The real issue was subtly injected bugs—bugs that changed the semantics of the code, without making it obviously invalid to the compiler. So while we didn’t trust decaffeinate enough to run it on the whole codebase, some people used this tooling successfully, nevertheless.

Six months later

In summer 2017, a funny thing happened: Decaffeinate declared themselves bug free. As in, every difference between their converted code and the CoffeeScript test cases was a thing that should not occur in any self-respecting code, and wasn’t worth fixing.

So we asked ourselves: was decaffeinate finally good enough? When we started thinking about what we wanted to accomplish in Q4, we did a little digging and found that:

  • Decaffeinate looked like it could live up to this statement and
  • more convincingly, our internal developers reported that using our poorly-supported decaffeinate based script had been producing more reliable results than hand converting.

The latter is what really convinced us that decaffeinate was likely telling the truth about their status. We formed our new plan: Automate the rest of the migration.

Now decaffeinate couldn’t give us types, so we figured we could just add anys until TypeScript was happy, and while we would end up with ugly TypeScript, that would be preferable to having completely untyped CoffeeScript in our codebase. This approach had the following advantages:

  • Engineers—new hires especially—would not have to learn to read (or edit) CoffeeScript
  • Web Platform could drop support for CoffeeScript linting, internationalization, and the CoffeeScript compiler
    • improvements to tools like codemods or static analysis would only have a single language to deal with
  • Teams could fix up the types in their code at their own pace, after the migration was over; and could stop dealing with maintaining declaration files mirroring unconverted CoffeeScript.

At this point the writing was already on the wall that we were not going to get significant time from product engineering teams, so we had to complete this migration with minimal support from the teams who owned the code. We knew that if we were going to pull this ambitious idea off, we had to do it while minimizing the number of bugs introduced; we had over 2000 files to migrate, but if we created more than a dozen bugs we’d be at a high risk for management to delay or cancel the project. This meant we had to do the conversion while maintaining the semantics of the existing code.

A two phase plan

To properly migrate our codebase we needed a multistep pipeline approach for any given file.

First we ran decaffeinate to generate valid ES6. This code was untyped and even included pre-JSX React. Then we ran this ES6 through a custom ES6 to TypeScript Converter.

Full scale decaffeination

Decaffeinate has some options for generating nicer looking code, with the trade-off that the code may not always be correct. Those options start with --loose. We initially included the following:

  • --loose-for-expressions
  • --loose-for-includes
  • --loose-includes

These helped us avoid wrapping large portions of our code with Array.from(). But after testing these out by doing some trial conversions and running our test suites, we uncovered enough bugs to lose confidence with those options—they were too likely to introduce regressions for us.

The following options, however, were not found to create excess bugs, so we ended up using them:

  • --prefer-const
  • --loose-default-params
  • --disable-babel-constructor-workaround

Decaffeinate helpfully leaves a comment about potential style issues to clean up, e.g.


/*
 * decaffeinate suggestions:
 * DS102: Remove unnecessary code created because of implicit returns
 * DS207: Consider shorter variations of null checks
 * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
 */

After this, we used several codemods to clean up the resulting code. First we used JavaScript-codemod transform functions like function() { }.bind(this) into arrow functions: () => {}.

Next, for files that imported React, we used react-codemod to update old React.createElement calls to use JSX and convert instances of React.createClass to class MyComponent extends React.Component.

This process produced working Javascript, but it was still in the AMD module format.  Even with that fixed, it did not typecheck with our settings. We wanted our final TypeScript code to use the same flags as the rest of our codebase, notably noImplicitAny and strictNullChecks.

We had to write our own custom transformations to in order to make it typecheck.

Building an ES6 to TypeScript converter

Our custom converter had a ton of work to do. In our first pass we wanted to tackle issues that affected every file. We needed to write a tool that could automate the challenges described below, and more.

For developing these tools, we leaned heavily on https://astexplorer.net/ to explore the Abstract Syntax Trees we’d be working with as we prototyped the transforms.

Converting AMD to ES6 module format

First we needed to update AMD imports to ES6 imports. 

The following code:

define(['library1', 'library2'], function(lib1, lib2) {})

would become:

import * as lib1 from 'library1'; 
import * as lib2 from 'library2';

In our CoffeeScript, destructuring imports was a common pattern, which corresponded well to named imports.  So we converted:

define(['m1', 'm2'], function(M1, {somethingFromM2}) {
  var tmp = M1(somethingFromM2);
});

to:

import * as M1 from 'm1';
import {somethingFromM2} from 'm2';

var tmp = M1(somethingFromM2);

We also had to convert our exports. The following code:

define(function() {
  return {hello: 1}
}

became:

export {1 as hello}

When we couldn’t convert to named exports, we fell back to using export =.  For example:

define([], function() {
  let Something;
  return Something = (function() {
    Something = class Something {
    }
    return Something;
  })();
});

became:

 let Something;
 Something = (function() {
   Something = class Something {
   }
   return Something;
 })();
 export = Something;

Though this was clearly not idiomatic TypeScript, we figured it could be cleaned up later. We also did not remove unused imports, for fear that some of our modules had global side-effects; so we instead turned them into the import "x"; style, with a comment clarifying they might not be necessary, for anyone who wanted to revisit them later.

Type signatures

Next, we had to annotate every function parameter and var declaration as the any type. For example, the function(hello) {}  became function(hello: any) {}.

Classes

We also needed to add a class property for every property that's assigned to this inside of a class. For example:

class Hello {
  constructor() {
    this.hi = 1;
  }

  someFunc() {
    this.sup = 1;
  }
}

would get transformed to this:

class Hello {
  hi: any;
  sup: any;
  ...

Typing React

In addition, we needed to annotate React class components with the typed React.Component. Those changes alone eliminated a fair number of TypeScript errors.

Documenting the conversion

We didn’t want to lose version control history for any given file, so we automatically added a message to the top of every file explaining how to look up the original coffeescript version.


//
// NOTE This file was converted from a Coffeescript file.
// The original content is available through git with the command:
// git show 21e537318b56:metaserver/static/js/legacy_js/widgets/bubble.coffee
//

Fixing type errors

While we were ok adding anys to our codebase, we didn’t want to add more than necessary; yet with the above pipeline we still ended up with thousands of type errors—more than we could possible hope to fix by hand. So the last pass in our conversion pipeline was a script that ran typechecking, parsed the typecheck output, and then based on each error code, would try to insert the appropriate any usage at the associated AST node.

Initially we used node-falafel as one of the components of our scripts, but with this particular script we found that we needed to parse TypeScript, so we forked falafel to use tslint-eslint-parser instead; this lets us only rewrite the bits of the code we needed to change, while leaving the rest of the input alone.

Staying focused

The goal of our effort was not to build the best tool for converting CoffeeScript AMD codebases to TypeScript, but rather to get our codebase converted. Initially, we tested our tools by starting with small internal features. We used those to catch crashes in our conversion tools and obvious bugs found when reading the output. Once we were able to convert our files without crashing, we started looking at type errors in random sets of the codebase. This surfaced some very frequent issues, such as dead variables, and type errors in complex expressions. Both were easy problems. We were able to delete the dead variables—though by default left their initializers in place, in case the expressions had side effects—and wrap complex expressions like so: (this as any).foo. Eventually, though, this approach became less fruitful, and we started wondering when we’d finally get to the bottom of our issue list.

So we changed strategy. Once we had the whole codebase reliably converting to typescript, instead of checking one-off files and folders of code, we started doing trial runs of the tool on the whole codebase, and typechecking the results. We grouped the type errors by code (e.g. "TS7030") and tallied up the occurrences.  This let us focus on integrating fixes for the most common errors.

This was a major turning point. Before this, we were writing fixes for whatever errors we saw crop up in whichever file we had decided to hand test; but could not be confident how far we were from a production-ready tool. By grouping and counting the occurrences of each error code, we were able to get a sense of how much more work we had, and were able to focus our efforts on the type errors that happened more than a dozen times each.

The type errors that occurred very rarely, or at least rarely enough to not be worth the effort to make the tool do it right, we just planned to fix by hand later. One such memorable case, which we were looking at right before our strategy change, was the complaint that ES6 class constructors can’t do anything before calling super().  Calling super() at any time is legal in CoffeeScript class constructors, so when these were converted to ES6 classes, typescript complained. The most common case of this one was due to CoffeeScript like:

class Foo extends Bar
  constructor: (@bar, @baz) ->
    super()

which decaffeinated to:

class Foo extends Bar {
  constructor(bar, baz) {
    this.bar = bar;
    this.baz = baz;
    super(); // illegal: must come first
  }
}

And in almost every instance of this, it was valid to just hoist the super() call before the assignments—but it took a few minutes reading the superclass’ constructor to check this. Only a case or two of super() misuse we found were real head-scratchers. While this occurred just enough in our codebase to possibly warrant automation—something around two dozen times—we ultimately decided it would be far easier to fix them by hand.  The code to separate the easy cases, where reordering was safe, and the more complex cases that needed a human to double check, just was not worth the time to write.

When we did the final conversions, our type error rates were in the ballpark of 0.5–1 typecheck errors per file converted, which we had to fix by hand.

Gaining confidence in our tools

Towards the later stages of writing our tools, we became more concerned about how we could safely deploy the converted code. Just ensuring that our converted code typechecked would not be enough, especially given how many anys we were automatically adding.

So we started running all of our unit tests against our code before and after it went through our pipeline. This uncovered more bugs, mostly with things that were silently buggy CoffeeScript being translated to code that would throw in TypeScript. Whenever we found one of those bugs, we would search the whole codebase for similar patterns to fix. If that was impractical, we would add an assert to our conversion tools so they would fail quickly if they encountered suspicious code.

An aside on an interesting bug

One of the more interesting bugs we ran into was accidentally overwriting exported functions.

CoffeeScript is different from most languages in that it doesn't have a notion of variable shadowing. For example in Javascript, if you ran this:


let myVar = "top-level";
function testMyVar() {
  let myVar = "shadowed";
  console.log(myVar);
}

testMyVar();
console.log(myVar);

It would print out:

shadowed
top-level

The myVar that's created in testMyVar is different from the top-level myVar, even though they share the same name.

This isn't possible in CoffeeScript, however. The equivalent code would look like this:

myVar = "top-level"
testMyVar ->
  myVar = "shadowed"
  console.log(myVar)
    
testMyVar()
console.log(myVar)

but it would print out:

shadowed
shadowed

We found an instance of that in our code that looked like this:

define(() ->
  sortedEntries = (...) ->
    ...
    sortedEntries = entries.sortBy(getSortKey, cmpSortKey)
    ...

  return {
    sortedEntries
  }

sortedEntries was declared as a function, but its own function body gets overwritten as an array of entries. This would make any calls to sortedEntries inside of the module fail after the first time they're called, but we never caught this issue because the sortedEntries function gets exported as a copy.

That code would get translated to this:

let sortedEntries = function() {
  ...
  sortedEntries = entries.sortBy(getSortKey, cmpSortKey)
}

export { sortedEntries };

Because the TypeScript code uses ES6 modules instead of AMD modules, sortedEntries is exported as a reference, not a copy. That means when another module imports sortedEntries and calls it, sortedEntries becomes an array and any subsequent calls to it will be invalid.

After getting bitten by that error once, we added an assert to the translation code to bail out if they find that exported functions are re-assigned.

De-risking the conversion from sloppy to strict mode

Midway through building these tools, we realized that a side effect of converting from AMD to ES6 modules was that we’d be turning on strict mode for the vast majority of our code for the first time ever.

Initially, this sounded scary; so we read through the MDN docs on strict mode and made a checklist of the behavior changes we could expect. Then went through the list one by one and figured out how to mitigate them. 

For the majority of the changes, we found that the TypeScript parser or typechecker was going to handle them for us—TypeScript had no problem complaining about new syntax errors, usage of newly reserved identifiers, or catching dumb things like reassigning eval or arguments.

Some were trivially verifiable with our code search tools, for example octal literals (e.g. 043), with statements, or deletes of plain names.

A few strict mode changes we realized were non-issues because CoffeeScript actually didn’t use the problematic construct in its code generation.  For instance, we didn’t have to worry about nested function statements (function x(){…;function y(){…}), because CoffeeScript only ever declares anonymous functions as expressions, and assigns them to variables to give them names; and we didn’t have to worry about forgetting var and ending up with an accidental global variable, as CoffeeScript would implicitly declare the variable for us.

We also learned there were changes to eval, .caller, and .callee; however we were able to grep our codebase for these. Fortunately, we had very few usages of eval in our codebase, none of which were in CoffeeScript; and no usage of .caller and .callee, so we didn’t have to worry about these.

This left us with the last category: the changes we could only verify by running the code. Of these, eval-related changes were a non-concern, and arguments usage was sparse enough that it was easy to read the code and be reasonably confident it would work. This left only 3 behavioral changes we had to worry about:

  1. Assignments to non-writable properties, getter-only properties, and properties on non-extensible objects, that used to silently fail, would now throw 
    •    Writing to a property to an object frozen by Object.freeze was the most likely form of this we could encounter
  2. Deleting undeletable properties would now throw
  3. Changes to the behavior of this—no more boxing, and no more implicit this=window behavior.

We couldn’t realistically know ahead of time if these three changes would be problematic, however this was far more manageable of a risk than the original laundry-list of strict mode changes.

It’s also worth mentioning that the oldest part of our codebase, where we were most concerned that non-strict mode behavior could be necessary for the code to work, was the part which was written as non-modular code from before our introduction of AMD and RequireJS. 

We realized that we could convert this code to TypeScript without turning it into ES6 modules; and that by doing so it would be able to stay in sloppy mode. While this meant we essentially got no cross-module typechecking in that code, we decided this was an acceptable tradeoff for the reduction in risk to the rest of the migration.

The first converted features

We started the mass conversions first with our Jasmine test suite (we later migrated to Jest); this let us help ensure that later migrations were not changing tests and code at the same time, giving us higher confidence we weren’t introducing silent breakages.  After converting our Jasmine tests, we then looked for a candidate for the first conversion of production code.

At Dropbox, we have a culture of doing bug bashes before a feature release, where QA and a bunch of engineers for the team get in the room and try to manually find bugs with the feature. After talking to QA and a bunch of teams, we decided to target converting internal tools and the comments UI on our shared link pages first. For the comments UI, we bug bashed on this with the team who owned it.

While we stumbled over several bugs during the bash, we were able to verify that none of the bugs were due to the conversion to TypeScript, so got the green light to ship this conversion.

A side note: it was really easy to determine if a bug was caused by our change or not because of recent investment in adopting Bazel both as our build tool, and as the basis of our development and integration testing framework. As a result of using Bazel and our own itest tooling for services, we could simply check out the previous revision and run itest on it. This would rebuild and start a copy of our dev services at that exact version of code, which made it very easy to see if a bug was introduced by our changes or not. Dropbox engineer Benjamin Peterson talked about how itest works in his bazelcon 2017 talk about Integration Testing.

We proceeded from here with converting our internal crash reporting, feature gating, and email sending tools, followed by starting the conversion of the rest of the user-facing codebase in large batches.

The value of rigor

We learned that when writing code translators, you have to be rigorous, covering every corner case. Being explicit about which ones you don’t cover is very important, because any case you miss is likely to bite you. If you’re writing your own conversion tool here are some tips:

Whenever you add a transformation for a node type, make sure you look in the docs for all the cases you need to cover 

  • If you think a certain node type is unlikely to show up and isn't worth covering, throw an error so you aren't surprised if it actually shows up in your code. We relied heavily on the ESTree Spec and the ts-estree source code for this
  • Any time you uncover a bug, search your codebase to find other instances of that pattern of bug and fix them. Otherwise, you'll be stuck playing whack-a-mole with similar bugs in production.

The tail end

The last few weeks of the project we spent converting about 100-200 files at a time; this batch size was practical only because we had polished our tooling to the point that such conversions were doable in several hours of engineering time. This meant we could go from start to integrated on our master branch within a day or two, keeping rebasing pains to a minimum. Most of the time in these diffs was spent getting typecheck satisfied with our code, as we had fixed most issues with Jasmine and Selenium tests during our up front verification work.

One of the tricks we used to iterate faster was running tsc --noEmit --watch on our code base, so that we could get incremental typecheck results in about 10 seconds instead of the minute it required to typecheck from scratch. We were able to achieve this speed in part because we made the jump from TypeScript 2.5 to TypeScript 2.6 during this migration, which crucially included major improvements to --watch speed.

To stay focused, we also kept a count of the remaining CoffeeScript files on a whiteboard in our team’s area, and replaced this number each time our code was merged into the master branch. This helped remind us of how far we had come, and keep the end goal of zero CoffeeScript in sight.

After we finally converted the last CoffeeScript, we celebrated its sunset by drinking coffee with our internal customers in the Dropbox café, Little R.

Only two bugs

We set out from the beginning knowing that if we caused too many bugs, the whole project would end up derailed. In the end, I only recall two bugs that slipped into production; most potential bugs were introduced while manually fixing typecheck errors, and despite our test coverage not being great, did not get past our combination of automated Jasmine and Selenium tests.

The majority of teams thus didn’t notice anything other than that their code was now TypeScript, and they had to rebase a few in-progress diffs. While the rebases were painful for some people, they were rather happy to be working in TypeScript afterwards, so we didn’t get any large complaints about this.

We converted the code of the most vocal concerned teams last. When we got to the point we wanted to convert their code, it helped a lot to be able to say “well, we already converted over 150,000 lines of CoffeeScript, and haven’t had a bug yet in production.”

One team really was not sold by this argument though, so we clarified that if we caused a major bug for them, they could wake us in the middle of the night to fix it, if they gave us steps to reproduce it; and we promised to investigate less serious bugs within a business day, and fix them if they were our fault.

We made this promise because we were confident in our conversion scripts and willing to stand behind them. In the end, they did not need to wake us for a bug fix, and the only bug we caused for them, we noticed in our exception reporting and fixed before they came to work the next day.

The majority of bugs initially attributed to our conversion, we were able to show were from other sources—either by showing the bug did not reproduce on the revision of our change, or by showing the bug reproduced on the directly prior revision. Two cases we recall:

A team filed a bug in the web file browser page due to our migration; it turned out this was actually introduced by them doing post-conversion cleanup on their own code to make it more idiomatic and better typed TypeScript.

In another case, a bug in our admin console two factor auth UI was caused by a rewrite of our account page’s two factor auth UI. The two pages shared the code, but the code was not clearly marked as shared, nor was the integration on the admin console tested, so the rewrite did not consider the admin console use case and broke it as a result.

Reflecting

In the end, the auto-migration process took just about two months, with three engineers working on it and about 19 engineer-weeks spent; significantly better than the original 10 engineer-year estimate. Granted, the output was not idiomatic TypeScript, as most people had originally aimed for, but rather some very messy, any-laden TypeScript.

This trade-off was worth it. It let us rid of CoffeeScript much sooner, so that we neither needed to keep supporting CoffeeScript, nor did new hires need to learn the language to ship product code for our website. This has enabled us to use TypeScript everywhere, while making incremental improvements to our code style and type safety when it is useful.

While we learned plenty of technical lessons throughout this process, possibly the most important lesson our team learned was that we should save our political and organizational capital for jobs we can’t automate for everyone. While no one particularly liked maintaining CoffeeScript—and some teams may have converted code to TypeScript on their own accord—making the rest of engineering manually convert all their CoffeeScript in the span of under a year was never going to fly.

In hindsight, we’re better off automating repetitive tasks where possible, and only making such large asks when it really does require knowledge specific to the code in question and can’t be solved by automation.

Special thanks 

First of all, thanks to Samer Masterson and Nicklas Gummesson for powering through the auto-migration effort; Lennart Jannson, Christoffer Klang and Gloria Chou for earlier efforts integrating TypeScript into the codebase; Leo Franchi for quarterbacking the original research and decision to commit the team to TypeScript; Christoffer Klang and Linjie Ding for pioneering our use of decaffeinate; Stacey Sern for the very timely upgrade to Typescript 2.6 that propelled the auto-migration forward; and the countless engineers who pitched in to convert their own code to TypeScript, before the auto-migration, and who put up with waking up one day to see their treasured CoffeeScript in a totally new (and possibly foreign) language; and the decaffeinate open source project for making our complete migration possible.

Today

Editor’s postscript: Fast forward to 2020, we now have over two million lines of TypeScript at Dropbox. Our entire codebase is statically typed, and we have a thriving TypeScript community internally. TypeScript has allowed us to scale our engineering organization so teams can work independently while maintaining clear contracts across code.

TypeScript as a language has surged in popularity, and we were lucky enough to be one of the first big companies to migrate. As a result, we’ve been fortunate to develop expertise and share it externally. Our JS Guild regularly shares TypeScript tips and tricks, and our engineers love the language they work in. One of our engineers even wrote a case study of when TypeScript isn’t a strict superset of JavaScript.

There are still a handful of files with “this was migrated from coffeescript” comments, but those make up a small percentage of our codebase. Our latest code is well typed and we generally push back on anys. Most recently, we upgraded all of our codebases to TypeScript 3.8. —Matthew Gerstman


// Copy link