An overview of ts-migrate - a tool for translating large-scale projects to TypeScript

Airbnb officially uses TypeScript (TS) for front-end development. But the process of implementing TypeScript and translating a mature codebase of thousands of JavaScript files into the language is not a day's work. Namely, TS implementation took place in several stages. At first it was a proposal, after a while the language began to be used in many teams, then the introduction of TS entered the beta phase. As a result, TypeScript became Airbnb's official front-end development language. Learn more about the Airbnb TS implementation process here . This article is devoted to describing the processes of translating large projects into TypeScript and a story about a specialized tool, ts-migrate, developed at Airbnb.











Migration strategies



Translating a large-scale project from JavaScript to TypeScript is challenging. Before we started solving it, we studied two strategies for switching from JS to TS.



▍1. Hybrid migration strategy



With this approach, a gradual, file-by-file translation of the project into TypeScript is carried out. During this process, files are edited, typing errors are corrected, and they work this way until the entire project is translated to TS. The allowJS parameter allows you to have both TypeScript files and JavaScript files in your project. Thanks to this, this approach to translating JS projects to TS is quite viable.



With a hybrid migration strategy, you don't have to pause the development process, you can gradually, file by file, translate the project to TypeScript. But, if we talk about a large-scale project, this process can take a long time. It also requires training for programmers across the organization. Programmers will need to be introduced to the specifics of the project.



▍2. Comprehensive migration strategy



This approach takes a project written entirely in JavaScript, or one part of which is written in TypeScript, and completely transforms it into a TypeScript project. In this case, you will need to use the type anyand comments @ts-ignore, which will allow the project to compile without errors. But over time, the code can be edited and move on to use more suitable types.



The overarching TypeScript migration strategy has several significant advantages over the hybrid strategy:



  • . , , . , TypeScript, , .
  • , . , , , . .


Considering the above, it would seem that pervasive migration is superior to hybrid migration in all respects. But translating a mature codebase to TypeScript in an all-encompassing manner is a very difficult task. To solve it, we decided to resort to scripts to modify the code, to the so-called "codemods" ( codemods ). When we first started translating a project to TypeScript, doing it manually, we noticed repetitive operations that could be automated. We wrote code mods for each of these operations and combined them into a single migration pipeline.



Experience tells us that we cannot be 100% sure that after the automatic translation of a project to TypeScript, there will be no errors in it. But we found out that the combination of steps described below gave us the best results and, in the end, got a TypeScript project without errors. Using code mods, we were able to translate into TypeScript a project containing more than 50,000 lines of code and represented by more than 1,000 files. It took us one day to do this.



Based on the pipeline shown in the following figure, we have created the ts-migrate tool.





Ts-migrate codemods



In Airbnb, much of the front-end is written using React . This is why some parts of the code mod are related to React-specific concepts. The ts-migrate tool can be used with other libraries or frameworks, but this will require additional configuration and testing.



Migration Process Overview



Let's walk through the basic steps you need to follow to translate a project from JavaScript to TypeScript. Let's talk about how these steps are implemented.



▍Step 1



The first thing that every TypeScript project creates is a tsconfig.json. Ts-migrate can do it on its own if needed. There is a standard template for this file. In addition, a verification system is in place to ensure that all projects are configured consistently. Here's an example of a basic configuration:



{
  "extends": "../typescript/tsconfig.base.json",
  "include": [".", "../typescript/types"]
}


▍Step 2



Once the file tsconfig.jsonis where it should be, the source files are renamed. Namely, .js / .jsx extensions change to .ts / .tsx. This step is very easy to automate. This allows you to get rid of a large amount of manual labor.



▍Step 3



And now it's time to start code mods! We call them plugins. Plugins for ts-migrate are code mods that have access to additional information through the TypeScript language server. Plugins accept strings as input and return modified strings. The jscodeshift toolbox , TypeScript API, string processing tools, or other AST modification tools can be used to perform code transformations .



After completing each of the steps above, we check to see if there are any pending changes in the Git history and include them in the project. This allows you to split migration PRs into commits, which makes it easier to understand what is happening and helps to track changes in filenames.



Overview of packages that make up ts-migrate



We split ts-migrate into 3 packages:





By doing this, we were able to separate the code transformation logic from the core of the system and were able to create many configurations designed to solve different problems. We now have two main configurations: migration and reignore .



The purpose of applying the configuration migrationis to translate the project from JavaScript to TypeScript. And the configuration reignoreis used to make it possible to compile the project by simply ignoring any errors. This configuration is useful when you have a large codebase and do various things with it, like the following:



  • TypeScript version update.
  • Making major changes to the code or refactoring the codebase.
  • Improving the types of some commonly used libraries.


With this approach, we can translate the project to TypeScript even if, when compiling, errors are generated that we do not plan to deal with immediately. This also makes it easier to update TypeScript or the libraries used in your code.



Both configurations run on a server ts-migrate-serverthat has two parts:



  • TSServer : This part of the server is very similar to what VSCode uses to communicate between the editor and the language server. The new instance of the TypeScript language server starts in a separate process. Development tools interact with it using a language protocol .
  • Migration tool : This is the code that performs the migration process and coordinates this process. This tool accepts the following parameters:


interface MigrateParams {
  rootDir: string;          //    .
  config: MigrateConfig;    //  ,   
                            // .
  server: TSServer;         //   TSServer.
}


This tool does the following:



  1. Parsing the file tsconfig.json.
  2. Generating .ts-files with source code.
  3. Send each file to the TypeScript language server to diagnose this file. There are three types of diagnostics, which gives us the compiler: semanticDiagnostics, syntacticDiagnosticsand suggestionDiagnostics. We use these checks to find problem areas in the source code. Based on the unique diagnostic code and the line number in the file, we can identify the possible type of problem and apply the necessary code modifications.
  4. Processing each file by all plugins. If the text in the file has changed at the initiative of the plugin, we update the contents of the original file and notify the language server that the file has been changed.


Usage examples ts-migrate-servercan be found in the examples package or in the main package . It ts-migrate-examplealso contains basic plugin examples . They fall into 3 main categories:



  • Plugins based on jscodeshift.
  • Plugins based on the Abstract Syntax Tree (AST) TypeScript.
  • Text-processing plugins .


The repository contains a set of examples aimed at demonstrating the process of creating simple plugins of all these kinds. It also shows their use in combination c ts-migrate-server. Here's an example of a migration pipeline that transforms code. The following code is received at its input:



function mult(first, second) {
  return first * second;
}


And he gives the following:



function tlum(tsrif: number, dnoces: number): number {
  console.log(`args: ${arguments}`);
  return tsrif * dnoces;
}


In this example, ts-migrate has performed 3 transformations:



  1. It reverses the order of characters in all the identifiers: first -> tsrif.
  2. Added information about the types in the function declaration: function tlum(tsrif, dnoces) -> function tlum(tsrif: number, dnoces: number): number.
  3. Added the line to the code console.log(‘args:${arguments}’);


General purpose plugins



The real plugins are located in a separate package - ts-migrate-plugins . Let's take a look at some of them. We have two plugins based on jscodeshift: explicitAnyPluginand declareMissingClassPropertiesPlugin. The jscodeshift toolbox allows you to convert ASTs to regular code using the recast package . We can use the function toSource()to directly update the source code contained in our files.



The explicitAnyPlugin plugin retrieves information from the TypeScript language server about all errors semanticDiagnosticsand the lines in which those errors were detected. Then the type annotation is added to these lines any. This approach allows you to fix errors, since using the typeanyallows you to get rid of compilation errors.



Here's some sample code before processing:



const fn2 = function(p3, p4) {}
const var1 = [];


Here is the same code processed by the plugin:



const fn2 = function(p3: any, p4: any) {}
const var1: any = [];


The declareMissingClassPropertiesPlugin takes all diagnostic messages with an error code 2339(can you guess what this code means ?) And, if it can find class declarations with missing identifiers, adds them to the body of the annotated class any. From the name of the plugin, we can conclude that it is applicable only to ES6 classes .



The next category of plugins is based on AST TypeScript. By processing the AST, we can generate an array of updates to be made to the source file. Descriptions of these updates look like this:



type Insert = { kind: 'insert'; index: number; text: string };
type Replace = { kind: 'replace'; index: number; length: number; text: string };
type Delete = { kind: 'delete'; index: number; length: number };


After generating information about the necessary updates, it remains only to enter them into the file in the reverse order. If, after performing this operation, we receive new program code, we will update the source code file accordingly.



Let's take a look at the next couple of AST-based plugins. This is stripTSIgnorePluginand hoistClassStaticsPlugin.



The stripTSIgnorePlugin plugin is the first plugin used in the migration pipeline. It removes all comments from the file.@ts-ignore(these comments allow us to tell the compiler to ignore errors that occur on the next line). If we are translating a project written in JavaScript into TypeScript, then this plugin will not perform any action. But if we are talking about a project that is partly written in JS, and partly in TS (several of our projects were in a similar state), then this is the first migration step that cannot be dispensed with. Only after the comments are removed @ts-ignore, the TypeScript compiler will be able to issue diagnostic error messages that need to be fixed.



Here is the code that goes into the input of this plugin:



const str3 = foo
  ? // @ts-ignore
    // @ts-ignore comment
    bar
  : baz;


Here's the output:



const str3 = foo
  ? bar
  : baz;


After getting rid of the comments, @ts-ignorewe run the hoistClassStaticsPlugin plugin . It goes through all class declarations. The plugin detects the possibility of raising identifiers or expressions and finds out if a certain assignment operation has already been raised to the class level.



In order to ensure high development speed and avoid forced downgrades to previous versions of the project, we provided each plugin and ts-migrate with a set of unit tests.



React-related plugins



The reactPropsPlugin , which builds on this awesome tool, converts type information from PropTypes to TypeScript type declarations. With this plugin, you only need to process .tsx files that contain at least one React component. This plugin looks for all PropTypes declarations and tries to parse them using ASTs and simple regular expressions like /number/, or using more complex regular expressions like / objectOf $ / . When it is detected React-component (function or based on class), it is transformed into a component in which a new type is used to input parameters (props): type Props = {…};. ReactDefaultPropsPlugin



pluginis responsible for implementing the defaultProps pattern in React components . We use a special type to represent input parameters that are given default values:



type Defined<T> = T extends undefined ? never : T;
type WithDefaultProps<P, DP extends Partial<P>> = Omit<P, keyof DP> & {
  [K in Extract<keyof DP, keyof P>]:
    DP[K] extends Defined<P[K]>
      ? Defined<P[K]>
      : Defined<P[K]> | DP[K];
};


We try to find the props that have been assigned default values, and then combine them with the type that describes the props for the component we created in the previous step.



The React ecosystem makes extensive use of the concepts of state and component lifecycle. We tackle the challenges related to these concepts in the next couple of plugins. So, if the component has a state, then the reactClassStatePlugin plugin generates a new type ( type State = any;), and the reactClassLifecycleMethodsPlugin plugin annotates the component lifecycle methods with the corresponding types. The functionality of these plugins can be expanded, including by equipping them with the ability to replace them with anymore precise types.



These plugins can be improved, in particular, by extending type support for state and properties. But their existing capabilities, as it turned out, are a good starting point for implementing the functionality we need. In addition, we do not work with React hooks here , since at the beginning of the migration, our codebase used an old version of React that does not support hooks.



Checking that the project is compiled correctly



Our goal is to compile a TypeScript project equipped with base types without changing the behavior of the program.



After all the transformations and modifications, our code may turn out to be non-uniformly formatted, which can lead to the fact that some code checks with a linter reveal errors. Our frontend codebase uses a system based on Prettier and ESLint. Namely, Prettier is used for automatic code formatting, and ESLint helps to check the code for compliance with the recommended development approaches. All this allows us to quickly deal with code formatting problems arising from previous actions, simply by using the appropriate plugin.- eslintFixPlugin.



The final step in the migration pipeline is to verify that all TypeScript compilation issues have been resolved. In order to find and fix potential errors, the tsIgnorePlugin plugin takes information from the semantic diagnostics of the code and line numbers, and then adds comments to the code @ts-ignorewith explanations of the errors. For example, it might look like this:



// @ts-ignore ts-migrate(7053) FIXME: No index signature with a parameter of type 'string...
const { field1, field2, field3 } = DATA[prop];
// @ts-ignore ts-migrate(2532) FIXME: Object is possibly 'undefined'.
const field2 = object.some_property;


We've equipped the system with JSX syntax support:



{*
// @ts-ignore ts-migrate(2339) FIXME: Property 'NORMAL' does not exist on type 'typeof W... */}
<Text weight={WEIGHT.NORMAL}>
  some text
</Text>
<input
  id="input"
  // @ts-ignore ts-migrate(2322) FIXME: Type 'Element' is not assignable to type 'string'.
  name={getName()}
/>


Having meaningful error messages at our disposal makes it easier to fix errors and find code snippets to look out for. Relevant comments, in combination with $TSFixMe, allow us to collect valuable data about the quality of the code and find potentially problematic code fragments. $TSFixMeIs the type alias we created any. And for functions, this is $TSFixMeFunction = (…args: any[]) => any;. It is recommended to avoid using a type any, but using it helped us simplify the migration process. Using this type helped us to know exactly which code fragments needed to be improved.



It is worth noting that the plugin eslintFixPluginruns twice. First time before usetsIgnorePluginsince formatting can affect messages about where compilation errors occur. The second time is after the application tsIgnorePlugin, as adding comments to the code @ts-ignorecan lead to formatting errors.



Additional Notes



We would like to draw your attention to a couple of migration features that we noticed during our work. Perhaps knowing about these features will come in handy when working with your projects.



  • TypeScript 3.7 @ts-nocheck, TypeScript- . , .js-, .ts/.tsx-. , .
  • TypeScript 3.9 introduces support for @ ts-expect-error comments . If a line of code is prefixed with such a comment, TypeScript will not report the corresponding error. If there is no error in such a line, TypeScript will inform @ts-expect-erroryou that there is no need for a comment . The Airbnb codebase has moved from comments @ts-ignoreto comments @ts-expect-error.


Outcome



The migration of Airbnb's codebase from JavaScript to TypeScript is still ongoing. We have some old projects that are still represented by JavaScript code. $TSFixMeComments are still common in our codebase @ts-ignore.





JavaScript and TypeScript in Airbnb



But it should be noted that using ts-migrate greatly accelerated the process of translating our projects from JS to TS and greatly improved the productivity of our work. With ts-migrate, programmers were able to focus on improving typing rather than manually processing each file. Currently, approximately 86% of our front-end mono-repository, which has about 6 million lines of code, is translated to TypeScript. We expect to reach 95% by the end of this year.



Here on the project repository home page you can learn how to install and run ts-migrate. If you find any problems in ts-migrate, or if you have ideas for improving this tool, we invite you to join.to work on it!



Have you ever translated large projects from JavaScript to TypeScript?






All Articles