The Road to TypeScript: How Dropbox Migrated 329,000 Lines of CoffeeScript
Dropbox’s frontend has gone through two major language migrations: from JavaScript to CoffeeScript in 2012, and from CoffeeScript to TypeScript in 2017. The second migration, which took place during a period of rapid team growth, was a far larger challenge than the first. Here’s how the web platform team approached it, and what went wrong along the way.
Why CoffeeScript Became the Default
In 2012, Dropbox was a startup of roughly 150 employees. JavaScript was still in its ES5 era, and the language felt stagnant. CoffeeScript offered arrow functions, smart this binding, and optional chaining—features that vanilla JavaScript wouldn’t see for years. Two engineers spent their Hack Week migrating the entire dropbox.com web app from JavaScript to CoffeeScript, a process that was feasible because fewer than 10 engineers worked on the web full-time.
CoffeeScript's syntax was permissive: curly braces, parentheses, and commas were often optional. Multi-line arrays could omit commas entirely:
// CoffeeScript
[
"foo"
"bar"
]
// JavaScript
["foo", "bar"]
This style became the norm, and the team adopted coffeelint to enforce community style recommendations. Two years later, Dropbox adopted RequireJS with AMD modules, a choice driven by the browser-focused tooling of the time. CommonJS was considered but set aside because npm and the Node ecosystem were not yet mature in 2013.
Internal Pressure for Change
By late 2015, product engineers’ patience with CoffeeScript was wearing thin. ES6 had shipped with object and array destructuring, class syntax, and arrow functions—many of the features that had made CoffeeScript attractive. Some teams began using ES6 on isolated projects.
The pain of maintaining the CoffeeScript codebase grew as well. Because both CoffeeScript and JavaScript were untyped, defensive coding was rampant. One particularly problematic case required a workaround to make a constructor safe to call without the new keyword:
class URI
constructor: (x) ->
# enable URI as a global function that returns a new URI instance
unless @ instanceof URI
return new URI(x)
...
CoffeeScript's whitespace-based syntax was another source of trouble. While reminiscent of Python—the language Dropbox itself was built on—CoffeeScript was far less strict about formatting. A single misplaced space in Python would prevent compilation; in CoffeeScript, it could silently produce incorrect behavior. A major production bug in fall 2013 was caused by exactly this: a misaligned space that compiled to the wrong code. Some developers resorted to keeping the compiled JavaScript output open beside their CoffeeScript source to verify correctness.
In November 2015, Dropbox surveyed its frontend engineers. Only 15% wanted to stay with CoffeeScript; 62% wanted to move away:
Survey respondents voiced several common complaints:
- Lack of delimiters making code ambiguous
- Overly opinionated syntactic sugar
- Weak community support for CoffeeScript
- Dense, hard-to-read syntax
- Syntactic ambiguities prone to causing subtle errors
With this feedback, Dropbox ran experiments with both TypeScript and vanilla ES6, integrated into production builds. Flow was evaluated too, but deemed less suitable due to weaker tooling support. In mid-2016, engineers integrated Babel and TypeScript into the build scripts to test both languages on the main site. TypeScript won out because it was effectively ES6 with types—and the team preferred having types.
There was a catch: the codebase had grown from 100,000 lines of JavaScript in 2012 to 329,000 lines of CoffeeScript in 2016. The engineering team was far larger too, and no single team owned the whole website. Migrating would require coordination across many product teams.
The Five-Milestone Plan
The migration plan sketched out five major phases:
M1: Basic Support
- Add the TypeScript compiler
- Allow TypeScript and CoffeeScript files to interoperate
- Set up testing, internationalization, and linting on TypeScript
M2: TypeScript First for New Code
- Optimize the developer experience
- Migrate core libraries
- Write and document best practices and migration guides
M3: First-Class Citizen
- Expand education and tooling support; convert the rest of the important libraries
M4: Migrate Most-Edited Files (target: April 2017)
- Manually convert roughly 100 commonly edited files from CoffeeScript to TypeScript
M5: Remove the CoffeeScript Compiler (target: July 2017)
- Compile remaining CoffeeScript to JavaScript, merging the output into the codebase; CoffeeScript sources would remain in git history
- Any edits to that compiled JavaScript code would require migrating the whole file to TypeScript first
M1 through M3 went smoothly in the second half of 2016. CoffeeScript/TypeScript interop proved robust, and testing was handled by extending the existing Jasmine infrastructure—languages didn't matter for test execution. The team integrated TSLint and adopted a style guide based on the Airbnb style guide with tweaks.
Then came M4 and M5. These milestones required product teams to actively port existing CoffeeScript. Dropbox had reserved 20% of product team time for foundational work that year, and the migration plan depended on exactly that resource. Whether it would actually materialize was another question.
Making CoffeeScript and TypeScript Interoperate
Dropbox achieved interop by generating a .d.ts declaration file for every CoffeeScript module in the codebase. Most of those stubs followed the same pattern:
declare module "foo/bar" {
const exports: any;
export = exports;
}
That approach worked because internal modules were typed as any by default. Well-known external libraries like jQuery and React had decent typings available on DefinitelyTyped. For less common libraries, the same stub approach was used: everything became any unless it was upgraded later.
TypeScript and CoffeeScript files lived side-by-side in the same folders, so module IDs remained consistent regardless of language. The mapping between AMD imports/exports and TypeScript's module syntax was mostly straightforward. One noteworthy detail: TypeScript's export default translated to an object of the form {default: ...} when imported into an AMD module. Named exports worked as expected: export const foo was readable from CoffeeScript by destructuring {foo} from the imported module.
A handful of modules dynamically determined their export shape. These had to be refactored so that all possible exports existed on the exported object; in cases where an export wasn't meant to exist, it was simply set to undefined:
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}
This compromise allowed all modules to work without fundamentally changing how dynamic exports could be consumed from both languages.
The net result: Dropbox completed a Code-to-CoffeeScript migration in 2012 with a small team, and the far larger CoffeeScript-to-TypeScript migration spanned the better part of a year with several engineering teams involved. Along the way, Dropbox became one of the first companies to adopt TypeScript at scale—complete with tooling and experience that was far from common at the time.
Enforcing a CoffeeScript freeze
For the second milestone, we introduced a hard restriction: no new CoffeeScript files could be added to the codebase. A test walked the repository, collected every .coffee file, and verified that the result was a subset of a hardcoded whitelist containing all pre-existing CoffeeScript paths. Code review tooling required Web Platform engineer sign-off on any change to that test file.
During the concurrent rollout of Bazel as our build system, a bug temporarily broke the file discovery, causing the test to receive an empty list—which trivially passed the subset check. Before we caught the issue, two new CoffeeScript files had slipped in. The lesson was straightforward: a test that assumes something about its environment should assert that assumption explicitly. Had we checked that the discovered file list was non-empty, the breakage would have been immediately visible.
We fixed the test by switching to a strict equality check against the whitelist. This forced the whitelist to shrink whenever a CoffeeScript file was deleted, preventing silent reintroduction of old files. This pattern has since become our standard for all whitelisting efforts. The main cost is that whitelist-shrinking changes require the same blocking review, but those are uncontroversial and we aim to approve them within a business day.
Life without syntactic sugar
Before picking TypeScript, we worried about losing CoffeeScript conveniences like the existential operator ? and optional chaining ?.. ES6 and TypeScript matched CoffeeScript on arrow functions, destructuring, and classes, but those null-handling operators were genuinely useful.
With TypeScript 2.0's --strictNullChecks, that concern evaporated. Most uses of those operators existed only to guard against ambiguity about undefined and null; type checking removed the ambiguity, and with it the need for the operators. Notably, both optional chaining and nullish coalescing eventually made their way back into JavaScript and TypeScript, albeit with slightly different syntax.
Priorities shift
In late 2016, a parallel team began a full redesign and rearchitecture of the website using React. Dubbed "Maestro," the new site carried performance targets and a ship date near the end of Q1 2017—right when we had planned our M4 milestone. The redesign took scheduling precedence. Many teams with website presence joined the effort, updating pages to match new layouts, colors, and design language.
The Maestro team committed to migrating their code to TypeScript in Q2 rather than Q1, and they followed through. The new site shipped with much of its functionality rewritten in React and TypeScript. Remaining pieces that appeared on our list of frequently edited CoffeeScript files were converted in Q2 as promised.
That "highly edited" list was one of our primary migration tools—we strongly encouraged teams to convert files on it. The problem: the list contained around 100 files, and pushing conversions through via community encouragement alone was slow. M4 did not ship on time.
The bigger picture was sobering. Even if we cleared those 100 hotspot files, over 2,000 CoffeeScript files remained. Any one of them was a single feature request away from becoming a team's active concern.
Reevaluating M5
The M5 milestone—"get rid of the CoffeeScript compiler"—was widely misunderstood. Some product teams believed the deadline meant they could leave CoffeeScript code as-is, edit it when needed, and simply check in the newly compiled JavaScript output.
From a platform perspective, that idea was untenable. Checking in compiled code would break i18n and linting on a large portion of the codebase, unless we made major additional investments we had no intention of making. The only way compiled code could work within existing tooling was if that code never changed.
Even setting that aside, M5 didn't make sense as a platform goal. The point of removing the compiler was to consolidate on a single language and dedicate all tooling effort to TypeScript. "Read-only JavaScript" would have achieved neither objective. And with Bazel migration nearing completion, we had already paid for build support for both compilers; turning one off gained little.
In June, we postponed the TypeScript migration indefinitely—no ETA, though still planned. In retrospect, the decision was inevitable given our manual conversion strategy. At an optimistic rate of 1,000 lines converted per engineering day, the codebase was still a year of one engineer's full-time work. The actual rate was closer to 100 lines per day, which made the full job roughly ten engineer-years—or a year of ten engineers, assuming anyone wanted that assignment.
Declaring half the codebase abandonware wasn't viable either. Feature development would inevitably touch those areas, and even then, the time math didn't work: the remaining work was at least an order of magnitude larger than what anyone could dedicate to it. Changes that shrink the whitelist also constantly reminded us that manual conversion was routine work that no one claimed as their favorite task.
The earlier promise of "20% time for foundational work"—which assumed some manual conversions would happen organically—evaporated under scrutiny. There was never a clear organizational definition of which infrastructure requests counted against that budget, how it would be tracked, or whether teams could use it for their own technical debt. Within the infrastructure org, no one accounted for competing asks; a significant chunk of the first half of the year went to migrating production systems off Ubuntu 12.04 after its end-of-life date. Since 2017, we've steered clear of percentage-based open budgets for migrations of this sort.
A different path: decaffeinate
Early experiments and an outage
Back in January 2017, a few engineers tested decaffeinate as a way to speed up conversions, even building tooling around it to handle AMD module format and React style cleanup with open-source codemods.
That early experiment ended badly. Converting our i18n library, reviewing, testing, and shipping it went smoothly—until we realized decaffeinate had mis-converted an untested locale-aware sort function. Only one page used it, but that page broke completely in Safari. The decaffeinate bug tracker contained dozens of similar issues, and we couldn't estimate whether fixing them all would take months or years. Our manager decided against investment in that approach.
Still, some engineers adopted decaffeinate as a manual conversion aid, and we documented it as one possible workflow. The tool often produced obviously invalid code—malformed import statements, duplicate variable declarations—which TypeScript caught at compile time. The real danger was semantic drift: subtle bugs that changed behavior without being flagged by the compiler. That made us wary, though several successful conversions still came out of using it.
Rethinking the tool six months later
By summer 2017, decaffeinate declared itself bug-free: every remaining difference between its output and the original CoffeeScript test cases was considered not worth fixing.
We were skeptical, but two things gave us pause. The claim itself looked credible upon inspection. More convincingly, engineers inside our company reported that conversions produced via our poorly supported decaffeinate script were more reliable than hand-written conversions.
That feedback changed our minds. We formed a new plan: automate the rest of the migration. Decaffeinate wouldn't generate type annotations, so we'd insert any liberally until TypeScript compiled. The result would be ugly TypeScript, but preferable to untyped CoffeeScript. The approach offered several benefits:
- New hires would never need to learn to read or edit CoffeeScript.
- Web Platform could drop CoffeeScript linting, i18n support, and the compiler itself—enabling future codemods and static analysis to focus on a single language.
- Product teams could improve types on their own schedules after the migration, without also maintaining declaration files for unconverted CoffeeScript.
Given that product teams were clearly not going to contribute significant manual effort, the conversion had to succeed with minimal owner involvement. With over 2,000 files to migrate, we had a tight bug budget: more than a dozen introduced bugs risked canceling the entire project. The conversion had to preserve semantics exactly.
From CoffeeScript to TypeScript in Two Passes
Migrating a large CoffeeScript codebase to strict TypeScript meant treating every file like a mini project. Our pipeline ran in two stages: first, decaffeinate turned CoffeeScript into valid ES6; second, a custom converter we built turned that ES6 into something that would satisfy our TypeScript compiler settings, including noImplicitAny and strictNullChecks.
Decaffeinating With Care
Decaffeinate ships with --loose flags that produce cleaner output by skipping certain safe wrappers. We tried three of them—--loose-for-expressions, --loose-for-includes, and --loose-includes—to avoid wrapping large portions of code in Array.from(). Trial conversions and test runs revealed too many regressions, so we dropped all three.
Three other options proved reliable and stayed in our configuration:
--prefer-const--loose-default-params--disable-babel-constructor-workaround
Decaffeinate leaves comments flagging potential style issues, which helped us spot cleanup opportunities as we reviewed output.
/*
* 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 decaffeination, we applied codemods. The javascript-codemod transforms converted function() { }.bind(this) patterns into arrow functions. For files importing React, react-codemod rewrote React.createElement calls into JSX and converted React.createClass usage into ES6 class components extending React.Component.
That produced working JavaScript, but the code was still in AMD module format and—more importantly—would not typecheck under our strict settings. We needed another round of automation.
Writing an ES6-to-TypeScript Converter
We built a custom transform tool to handle the repetitive, mechanical work across every file. For prototyping, we relied on astexplorer.net to inspect and experiment with the Abstract Syntax Trees involved.
Module Format Conversion
AMD imports and exports had to become ES6 syntax. We wrote transforms for each pattern we encountered.
define(['library1', 'library2'], function(lib1, lib2) {})
became
import * as lib1 from 'library1';
import * as lib2 from 'library2';
Destructuring imports in CoffeeScript mapped neatly onto named imports:
define(['m1', 'm2'], function(M1, {somethingFromM2}) {
var tmp = M1(somethingFromM2);
});
became
import * as M1 from 'm1';
import {somethingFromM2} from 'm2';
var tmp = M1(somethingFromM2);
Exports received similar treatment:
define(function() {
return {hello: 1}
}
became
export {1 as hello}
When named exports weren't feasible, we fell back to export =. It was not idiomatic TypeScript, but it was a safe mechanical transformation we could clean up later.
define([], function() {
let Something;
return Something = (function() {
Something = class Something {
}
return Something;
})();
});
became
let Something;
Something = (function() {
Something = class Something {
}
return Something;
})();
export = Something;
We deliberately avoided removing unused imports. Some modules had global side effects we couldn't safely rule out, so we converted uncertain imports to import "x"; and added a comment in case someone wanted to revisit them later.
Mechanical Typing
Every function parameter and variable declaration without an explicit type got annotated as any. A function like function(hello) {} became function(hello: any) {}.
For classes, we added explicit class property declarations for every this assignment detected inside methods.
class Hello {
constructor() {
this.hi = 1;
}
someFunc() {
this.sup = 1;
}
}
transformed to
class Hello {
hi: any;
sup: any;
...
React class components got typed as React.Component subclasses, which resolved a large number of errors right away.
We also wanted to preserve git history for every converted file, so each one received a header comment explaining how to find 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
//
Automated Error-Driven Fixes
Even with all the any types added, we still faced thousands of typecheck errors—too many for manual review. Our solution was a final pass that ran the typechecker, parsed its output, and used the error codes to determine where to insert additional any usages in the AST.
Building this script required parsing TypeScript. We forked node-falafel to use tslint-eslint-parser instead of its default parser, letting us surgically rewrite only the specific nodes involved in errors while leaving surrounding code untouched.
Debugging at Scale
Our objective was conversion, not building an elegant tool. We started by converting small internal features, using the outputs to catch crashes and obvious bugs. Once conversion ran without crashing, we audited random files and fixed frequent recurring problems—dead variables and complex expressions like (this as any).foo. Dead variables were safe to delete, though we left their initializers in place to avoid removing potential side effects.
This one-off approach had diminishing returns, so we switched tactics: run the tool on the entire codebase, typecheck everything, then group errors by code (e.g., TS7030) and count occurrences. That gave us a clear view of our remaining work and let us prioritize fixes by frequency. Errors appearing fewer than a dozen times we planned to fix by hand.
One manually resolved case was the ES6 rule preventing statements before super() in constructors. CoffeeScript allows calls to super() anywhere, so converted code often assigned properties first.
class Foo extends Bar
constructor: (@bar, @baz) ->
super()
became
class Foo extends Bar {
constructor(bar, baz) {
this.bar = bar;
this.baz = baz;
super(); // illegal: must come first
}
}
In nearly all instances—about two dozen in our codebase—hoisting super() above the assignments was valid, but verifying that required reading the superclass constructor. Writing automation to distinguish safe reorderings from cases needing human judgment wasn't worth the effort.
By the time we ran the final conversions across all files, the rate of remaining type errors was roughly 0.5–1 per converted file—most of which we corrected manually.
Testing the conversion pipeline
Typechecking alone wasn't enough to give us confidence in the converted code — especially since we were inserting any liberally. So we ran our full unit test suite against both the original CoffeeScript and the TypeScript output of our tools. This surfaced a number of subtle issues, most often cases where silently buggy CoffeeScript became code that threw an exception in TypeScript. Whenever we found such a bug, we searched the codebase for similar patterns and fixed them all; when that wasn't practical, we added assertions to the conversion tools so they'd fail fast on suspicious constructs.
A notable bug: exported function overwrites
CoffeeScript differs from most languages in that it has no variable shadowing. In JavaScript, this code prints outer then inner:
let myVar = "top-level";
function testMyVar() {
let myVar = "shadowed";
console.log(myVar);
}
testMyVar();
console.log(myVar);
Output:
shadowed
top-level
The myVar inside testMyVar is separate from the top-level one. CoffeeScript doesn't allow this — the equivalent code behaves differently:
myVar = "top-level"
testMyVar ->
myVar = "shadowed"
console.log(myVar)
testMyVar()
console.log(myVar)
Output:
shadowed
shadowed
We hit a real instance of this where a function was being overwritten by its own result:
define(() ->
sortedEntries = (...) ->
...
sortedEntries = entries.sortBy(getSortKey, cmpSortKey)
...
return {
sortedEntries
}
sortedEntries was a function, but its body reassigned it to an array of entries. Any calls within the module would fail after the first invocation, but we never caught it because the function was exported as a copy under AMD modules. Translated to TypeScript with ES6 modules:
let sortedEntries = function() {
...
sortedEntries = entries.sortBy(getSortKey, cmpSortKey)
}
export { sortedEntries };
...the export becomes a reference, not a copy, so any module importing and calling sortedEntries would find it already converted to an array, breaking all subsequent calls. After this bit us once, we added an assertion in the translator to bail out whenever an exported function gets reassigned.
De-risking the sloppy-to-strict transition
Converting from AMD to ES6 modules meant most of our codebase would be running in strict mode for the first time. We worked through the MDN strict mode documentation and categorized each behavior change by how we'd handle it.
Most changes the TypeScript parser or typechecker caught for us — syntax errors, reserved identifiers, reassigning eval or arguments. Others we verified by searching the codebase: no octal literals, with statements, or deletions of plain names. Some concerns were moot because CoffeeScript never generated the problematic constructs: it always declares functions as expressions assigned to variables, and it implicitly declares variables, so we never had accidental globals. We grepped for eval, .caller, and .callee and found almost none.
The remaining changes we could only verify by running code. eval was a non-issue, and arguments usage was rare enough to read manually. That left only three behaviors to worry about:
- Assignments to non-writable, getter-only, or non-extensible properties would now throw instead of silently failing — most likely with objects frozen via
Object.freeze. - Deleting undeletable properties would now throw.
- No more
thisboxing, and no implicitthis === window.
This was a far smaller risk than the original strict mode laundry list.
The oldest code — written before we adopted AMD and RequireJS — was where we most feared non-strict behavior was load-bearing. We realized we could convert that code to TypeScript without making it ES6 modules, which let it stay in sloppy mode. We gave up cross-module typechecking there, but accepted it as a fair trade for reducing the migration's risk.
The first conversions
We began mass conversion with our Jasmine test suite (later moving to Jest). That way we never changed tests and code simultaneously, making it easier to attribute failures. After tests, we picked production targets carefully.
Dropbox culture includes bug bashes before releases, and those sessions helped us choose the first targets: internal tools and the comments UI on shared link pages. Bug bashes with the owning team found several issues, but none traced back to the TypeScript conversion, so we got the green light to ship.
An important debugging aid was our recent adoption of Bazel as the build tool and basis for integration testing. The itest tooling let us check out the previous revision and run the exact dev services at that version, making it straightforward to determine whether a regression came from the migration or was preexisting. (Dropbox engineer Benjamin Peterson discusses itest in his BazelCon 2017 talk.)
After the first successes, we converted internal crash reporting, feature gating, and email sending tools, then moved to the rest of the user-facing code in large batches.
Lessons for building code translators
Writing a converter demands rigor and an explicit accounting of what you don't cover. Any missed case is likely to surface later as a bug. Useful practices from our experience:
- When adding a transformation for a node type, check the spec for every case you need to handle. We relied on the ESTree spec and the ts-estree source.
- If you think a node type won't appear and isn't worth supporting, make the tool throw an error — that way you'll never be silently surprised by the unexpected case.
- Any time you fix a conversion-caused bug, search the codebase for similar patterns and handle them too. Fixing only what you see turns the migration into an ongoing game of whack-a-mole.
Converting at Scale
In the closing stretch of the migration, the team was converting batches of roughly 100–200 files at a time. That batch size only became feasible after the tooling had matured to the point where a full conversion could be done and merged to master within a day or two, keeping rebase conflicts minimal. Most of the remaining effort in each diff went toward satisfying the typechecker; issues with Jasmine and Selenium tests had largely been resolved during the earlier verification phase.
A key enabler for faster iteration was running tsc --noEmit --watch, which cut incremental typecheck times from about a minute down to around 10 seconds. That improvement was partly thanks to moving from TypeScript 2.5 to TypeScript 2.6 during the project, a release that brought substantial --watch mode performance gains. To keep the team motivated, the remaining count of CoffeeScript files was tracked on a whiteboard and updated after each merge into master.
The Bug Count: Two
Keeping bugs low was a stated goal from the start, and the final tally was just two that reached production. The risks mostly came from manually fixing typecheck errors, but the combination of automated Jasmine and Selenium tests caught the vast majority of potential issues before they shipped.
Most teams noticed little beyond the fact that their code was now TypeScript and that in-progress diffs needed a rebase. Those rebases were painful for some, but the switch itself was generally welcomed. One strategy that smoothed things over was converting the most skeptical teams' code last. By then, the team could point to over 150,000 lines of converted CoffeeScript with no production bugs. For one team that still wasn't convinced, an explicit guarantee helped: a major bug would get a middle-of-the-night fix if steps to reproduce were provided, and less serious issues would be investigated within a business day and fixed if they were the migration's fault. That promise was backed by confidence in the conversion scripts, and in the end, the only bug that team hit was caught in exception reporting and fixed before they came to work the next day.
Several bugs initially blamed on the migration turned out to have other causes. In one case, a bug in the web file browser was traced back to post-conversion cleanup by the team itself, who had rewritten their own code to be more idiomatic and better typed. In another, a problem in the admin console's two-factor auth UI stemmed from a rewrite of the shared account page's two-factor auth UI. The shared code wasn't clearly marked as such, and the admin console integration wasn't tested, so the rewrite broke it.
What Two Months of Automation Bought
The auto-migration effort ran for about two months with three engineers, totaling roughly 19 engineer-weeks—a far cry from the original estimate of 10 engineer-years. The output wasn't idiomatic TypeScript; it was messy and laden with any types, but that trade-off was deliberate. It meant CoffeeScript support could be dropped much sooner, and new hires no longer needed to learn the language just to ship website code.
The bigger lesson the team took away was about organizational capital. Automating the repetitive parts of the migration made it possible to ask a large engineering organization to accept a sweeping change. Asking every team to manually convert their own CoffeeScript in under a year would never have worked. The experience reinforced the value of automating what can be automated and reserving big manual asks for work that genuinely requires code-specific knowledge.
The migration ultimately enabled TypeScript to be used across the entire Dropbox codebase, with incremental improvements to style and type safety continuing since. As of 2020, Dropbox has over two million lines of TypeScript, the codebase is fully statically typed, and the engineering organization has scaled with teams working independently behind clear contracts. TypeScript upgrades have continued, with the codebase most recently moving to TypeScript 3.8.



