ts-migrate: A Tool for Migrating to TypeScript at Scale

Sergii Rudenko
The Airbnb Tech Blog
10 min readAug 18, 2020

--

TypeScript is the official language of frontend web development at Airbnb. Yet, the process of adopting TypeScript and migrating a mature codebase containing thousands of JavaScript files didn’t happen in one day. TypeScript adoption went through the process of an initial proposal, adoption by multiple teams, a beta phase and, finally, landing as the official language of frontend development at Airbnb. You can learn more about how we adopted TypeScript at scale in this talk by Brie Bunge.

Migration strategies

Migration at scale is a complex task, and we explored a couple of options for moving from JavaScript to TypeScript:

1) Hybrid migration strategy. Partially migrate file by file, fix type errors, and repeat until the full project is migrated. The allowJS config option allows us to have TypeScript and JavaScript files coexist in the project side by side, which makes this approach possible!

In the hybrid migration strategy, we don’t have to pause development and can gradually migrate file by file. Though, on a large scale, this process might take a long time. Additionally, there will be a need to educate and onboard engineers from different parts of the organization.

2) All-in migration! Take a JavaScript or partial TypeScript project and convert it completely. We’ll need to add some any types and @ts-ignorecomments so the project compiles without errors, but over time we can replace them with more descriptive types.

There are several significant advantages to choosing the all-in migration strategy:

  • Consistency across a project: An all-in migration will guarantee that the state of every file is the same, and engineers won’t need to remember where they can use TypeScript features and where the compiler will prevent basic errors.
  • Fixing just one type is much easier than fixing the file: Fixing an entire file can be very complex as files can have multiple dependencies. With a hybrid migration, it’s harder to track the real progress of the migration and the status of files.

Looks like all-in migration is the clear winner here! But the process of performing an all-in migration of a large and mature codebase is a weighty and complex problem. To solve this problem, we decided to use code modification scripts — codemods! Through our initial process of manual migration to TypeScript, we recognized repeated operations that could be automated. We made codemods for each of these steps and combined them into the overarching migration pipeline.

In our experience, there isn’t a 100% guarantee that an automated migration will result in a completely error-free project, but we found that the combination of steps outlined below gave us the best results in ultimately migrating to an error-free TypeScript project. We were able to convert projects with more than 50,000 lines of code and 1,000+ files from JavaScript to TypeScript in one day with the use of codemods!

Based on this pipeline, we created a tool called “ts-migrate”:

At Airbnb, we use React for a significant part of our frontend codebase. That’s why some parts of codemods are related to React-based concepts. ts-migrate can potentially be used with other frameworks or libraries with additional configuration and testing.

Steps of the migration process

Let’s walk through the main steps needed to migrate a project from JavaScript to TypeScript and how those steps are implemented:

1) The first part in every TypeScript project is the creation of a tsconfig.json file, and ts-migrate can do this if required. There’s a default config file template and a validation check that helps us ensure all projects are consistently configured. Here is an example of the base-level config:

2) Once the tsconfig.json file is in place, the next step is changing the file extensions of the source code files from .js/.jsx to .ts/.tsx. Automation of this step is pretty easy, and it also removes a good chunk of manual work.

3) The next step is running codemods! We call them “plugins”. Plugins for ts-migrate are codemods that have access to additional information via the TypeScript language server. Plugins take a string as an input and produce an updated string as an output. jscodeshift, the TypeScript API, a string replace, or other AST modification tools can be used to power the code transformation.

After each of these steps, we check if there are any pending changes in Git history and commit them. This helps split migration pull requests into commits that are easier to understand and also tracks file renames.

An overview of ts-migrate packages

We split ts-migrate into 3 packages:

By doing so, we were able to separate the transformation logic from the core runner and create multiple configs for different purposes. Currently, we have two main configs: migration and reignore.

While the goal of the migration config is to migrate from JavaScript to TypeScript, the purpose of reignore is to make the project compilable by simply ignoring all the errors. Reignore is useful when one has a large codebase and is performing tasks like:

  • upgrading the TypeScript version
  • making major changes or refactorings to the codebase
  • improving types of some commonly used libraries

This way, we can migrate a project even if there are some errors we don’t want to deal with immediately. It makes the update of TypeScript or libraries a lot easier.

Both configs run on the ts-migrate-server, which consists of two parts:

  • TSServer: This part is very similar to what VSCode editor does for the communication between the editor and a language server. A new instance of the TypeScript language server runs as a separate process, and development tools communicate with the server using the language protocol.
  • Migration runner: This piece runs and coordinates the migration process. It expects the following parameters:

And it performs the following actions:

  1. Parse tsconfig.json.
  2. Create .ts source files.
  3. Send each file to the TypeScript language server for diagnostics. There are three types of diagnostics that the compiler provides for us: semanticDiagnostics, syntacticDiagnostics, and suggestionDiagnostics. We use these diagnostics to find problematic places in the source code. Based on the unique diagnostic code and the line number, we can identify the potential type of the problem and apply necessary code modifications.
  4. Run all plugins on each file. If the text changes due to the plugin execution, we update the contents of the original file and notify the TypeScript language server that the file has changed.

You can find examples of the ts-migrate-server usage in the examples package or the main package. ts-migrate-example also contains basic examples of plugins. They fit into 3 main categories:

There is a set of examples in the repository to demonstrate how to build simple plugins of all kinds and use them in combination with the ts-migrate-server. Here is an example migration pipeline that transforms the following code:

into:

ts-migrate did 3 transformations in the example above:

  1. reversed all identifiers first -> tsrif
  2. added types to the function declaration function tlum(tsrif, dnoces) -> function tlum(tsrif: number, dnoces: number): number
  3. inserted console.log(‘args:${arguments}’);

Generic plugins

The real-world plugins are located in a separate package — ts-migrate-plugins. Let’s take a look at some of them. We have two jscodeshift-based plugins: explicitAnyPlugin and declareMissingClassPropertiesPlugin. jscodeshift is a tool that can convert the AST back to string using the recast packageBy using the function toSource(), we can directly update source code for our files.

The main idea behind explicitAnyPlugin lies in extracting all semanticDiagnostics errors from the TypeScript language server along with line numbers. We will then need to add any type on the lines specified in the diagnostics. This approach allows us to resolve errors, since adding an any type fixes compilation errors.

Before:

After:

declareMissingClassPropertiesPlugin takes all diagnostics with code 2339 (can you guess what this code means?) and if it can find class declaration with missing identifiers, the plugin will add them to the class body with any type annotation. As one may be able to tell by the name, this codemod is only applicable for ES6 classes.

The next category of plugins is TypeScript AST-based plugins. By parsing the AST, we can generate an array of updates in the source file with the following types:

After the updates are generated, the only thing left is to apply the changes in reverse order. If, through the result of these operations, we receive new text, we update the source file. Let’s take a look at a couple of these AST-based plugins: stripTSIgnorePlugin and hoistClassStaticsPlugin.

stripTSIgnorePlugin is the first plugin in the migration pipeline. It removes all @ts-ignore¹ instances from the file. If we are converting a JavaScript project to TypeScript, this plugin won’t do anything. However, if it is a partial TypeScript project (at Airbnb, we had several projects in this state), this is an essential first step. Only after removing @ts-ignore comments will the TypeScript compiler emit all diagnostic errors that need to be addressed.

transforms into:

After removing @ts-ignore comments we run the hoistClassStaticsPlugin. This plugin goes through all class declarations in the file. It determines whether we can hoist identifiers or expressions and determines whether an assignment has already been hoisted to a class.

To be able to iterate quickly and prevent regressions, we added a series of unit tests for each plugin and ts-migrate.

React-related plugins

reactPropsPlugin converts the type information from PropTypes to a TypeScript props type definition. It’s based on the awesome tool written by Mohsen Azimi. We need to run this plugin only on .tsx files that contain at least one React component. reactPropsPlugin looks for all PropTypes declarations and tries to parse them by using AST and simple regular expressions like /number/ or more complex cases like /objectOf$/. When a React component (either functional or class) is detected, it gets transformed into a component with a new type for props: type Props = {…};.

reactDefaultPropsPlugin covers the defaultProps pattern for React components. We use a special type that represents the props with default values:

We attempt to find default props declarations and merge them with the component props type, which was generated by the previous step.

Concepts of state and lifecycle are pretty common in the React ecosystem. We addressed them in two plugins. If a component is stateful, the reactClassStatePlugin generates a new type State = any; and the reactClassLifecycleMethodsPlugin annotates component lifecycle methods with proper types. The functionality of these plugins can be extended including the ability to replace any with more descriptive types.

There is room for more improvements and better type support for state and props. However, as a starting point, this functionality proved to be sufficient. We also don’t cover hooks, since at the beginning of the migration our codebase used an older version of React.

Ensuring successful project compilation

Our goal is to get a compiling TypeScript project with basic type coverage that does not result in an application runtime behavior change.

Following all transformations and code modifications, our code may have inconsistent formatting and some lint checks may fail. Our frontend codebase relies on a prettier-eslint setup — Prettier is used to autoformat code and ESLint ensures that the code follows best practices. So we can quickly fix any formatting issues the previous steps may have introduced by running eslint-prettier from our plugin.

The last piece of the migration pipeline ensures that all TypeScript compilation violations are addressed. To detect and fix potential errors, tsIgnorePlugin takes semantic diagnostics with line numbers and inserts @ts-ignore comments with a useful explanation, such as:

We added support for JSX syntax as well:

Having meaningful error messages in comments makes it easier to fix issues and revisit code that needs attention. These comments, in combination with $TSFixMe², allow us to collect useful data about code quality and identify potentially problematic areas of code.

Last but not least, we need to run the eslint-fix plugin twice. Once before the tsIgnorePlugin given formatting may affect where we will get compiler errors. And again after the tsIgnorePlugin, since inserting @ts-ignore comments may introduce new formatting errors.

Summary

Our migration story is a work in progress: we have some legacy projects that are still in JavaScript and we still have a good number of $TSFixMe and @ts-ignore comments in our codebase.

Migration progress at Airbnb

However using ts-migrate dramatically accelerated our migration process and productivity. Engineers were able to focus on typing improvements instead of doing manual file-by-file migration. At this time, ~86% of our 6M-line frontend monorepo has been converted to TypeScript and we’re on track for 95% by the end of the year.

You can check out ts-migrate and find instructions on how to install and run ts-migrate in the main package on the Github repository. If you find any issues or have ideas for improvements, we welcome your contributions!

Huge shout out to Brie Bunge, who was the driving force behind TypeScript at Airbnb and the creator of ts-migrate. Thanks to Joe Lencioni for helping us adopt TypeScript at Airbnb and improving our TypeScript infrastructure and tooling. Special thanks to Elliot Sachs and John Haytko for contributing to ts-migrate. And thanks to everyone who provided feedback and help along the way!

Footnote

We want to note a couple of things about migration that we discovered in the process, which might be useful:

  • The 3.7 release of TypeScript introduced @ts-nocheck comments that can be added at the top of TypeScript files to disable semantic checks. We didn’t use this comment as it didn’t support .ts/.tsx files earlier, but it can also be a great middle stage helper during migration.
  • The 3.9 release of TypeScript introduced @ts-expect-error comments. When a line is prefixed with a @ts-expect-error comment, TypeScript will suppress that error from being reported. If there’s no error, TypeScript will report that @ts-expect-error wasn’t necessary. In the Airbnb codebase we switched to using the @ts-expect-error comment instead of @ts-ignore.

[1]: @ts-ignore comments allow us to tell the compiler to ignore errors on the next line.

[2]: We introduced custom aliases for any type — $TSFixMe and for the function type — $TSFixMeFunction = (…args: any[]) => any;. Even though the best practice is to avoid any type, using it helped us simplify the migration process and made it clear which types should be revisited.

--

--