Goldblog

TSLint to ESLint Part 2: tslint-to-eslint-config

November 19, 2019

Howdy! This blog post is a technical discussion on migrating how tslint-to-eslint-config migrates configurations from from TSLint to ESLint with @typescript-eslint. If you’re curious about TSLint’s history, go back to TSLint to ESLint Part 1: Historical Context.

tslint-to-eslint-config

Changing from any tool to another needs a good migration strategy. I wrote a nifty little utility appropriately named tslint-to-eslint-config that reads in your configuration and spits out an ESLint configuration file.

If you’re still using TSLint, you should try this thing out to get yourself onto ESLint! TSLint is in maintenance mode and, for the most part, is already deprecated. You want new features in your linter, right??

The rest of this post is a discussion on how that works. I hope you’ll read through, get a great understanding of the tool’s operations, and contribute to it on GitHub! 😊

Similarities

The similarities between ESLint and TSLint are convenient for direct comparisons. This basic TSLint tslint.json configuration file…

// tslint.json
{
    "rules": {
        "align": true,
        "array-type": true,
        "arrow-parens": true,
        // ...
    }
}

…is now represented by a structurally similar ESLint .eslintrc.json configuration file:

//eslintrc.json
{
    "rules": {
        "@typescript-eslint/indent": "error",
        "@typescript-eslint/array-type": "error",
        "arrow-parens": ["error", "as-needed"],
        // ...
    }
}

Simple! Internally, tslint-to-eslint-config keeps a mapping of rule converters, keyed by TSLint rule name. These converters take in the rule arguments for the TSLint rule and output the equivalent ESLint rule configuration(s):

/**
 * Keys TSLint rule names to their ESLint rule converters.
 */
const converters = new Map([
    ["align", convertAlign],
    ["array-type", convertArrayType],
    ["arrow-parens", convertArrowParens],
    // ...
]);

The no-construct rule, for example, doesn’t care what configuration the TSLint rule takes in. It always indicates to use the equivalent no-new-wrappers ESLint rule:

const convertNoConstruct = () => {
    return {
        rules: [
            {
                ruleName: "no-new-wrappers",
            },
        ],
    };
};

If you started with this TSLint rules configuration…

// tslint.json
{
    "no-construct": true
}

…then tslint-to-eslint-config would see the "no-construct" rule, map to the convertNoConstruct function, and output this rules object:

// .eslintrc.json
{
    "no-new-wrappers": "error"
}

Rule Options

Many lint rules take in some configuration settings, a.k.a. rule options, a.k.a. rule arguments, that modify the behavior of the rules in some known way. TSLint’s prefer-readonly rule is a rule that uses a single option:

  • When configured as prefer-readonly: true, it ensures all private class members whose values are never modified after initialization are declared with the private modifier
  • When configured as prefer-readonly: [true, "only-inline-lambdas"], it will only check members initialized as () => {} lambdas in-place

The ESLint equivalent of prefer-readonly is @typescript-eslint/prefer-readonly. It also takes in an optional rule argument - but as an object like { onlyInlineLambdas: true }. The prefer-readonly converter in tslint-to-eslint, therefore, needs to read in the original TSLint rule arguments and adjust its output accordingly:

const convertPreferReadonly = tslintRule => {
    return {
        rules: [
            {
                ...(tslintRule.ruleArguments.includes("only-inline-lambdas") && {
                    ruleArguments: [{ onlyInlineLambdas: true }],
                }),
                ruleName: "@typescript-eslint/prefer-readonly",
            },
        ],
    };
};

Running npx tslnit-to-eslint-config with a tslint.json file containing just the one rule will create the equivalent .eslintrc.json.

$ npx tslint-to-eslint-config
✨ 1 rule replaced with its ESLint equivalent. ✨
✅ All is well! ✅

Notices

Not all TSLint rules directly map to an ESLint equivalent. Plenty of ESLint rules that behave differently than their TSLint equivalents.

The TSLint rule one-variable-per-declaration maps directly to the ESLint one-var in its normal configuration…

// tslint.json
"rules": {
    "one-variable-per-declaration": true
}
// .eslintrc.json
"rules": {
    "one-var": ["error", "never"]
}

…but its "ignore-for-loop" option to skip checking multiple initializer variables in for loops has yet to be implemented in ESLint. Thus, we really can’t completely switch from TSLint to ESLint and keep our original linting behavior.

Rule converters in tslint-to-eslint-config are allowed to output a notices: string[] detailing any unavoidable behavior changes in the new rules:

const convertOneVariablePerDeclaration = tslintRule => {
    return {
        rules: [
            {
                ...(!tslintRule.ruleArguments.includes("ignore-for-loop") && {
                    notices: ["Variables declared in for loops will no longer be checked."],
                }),
                ruleArguments: ["never"],
                ruleName: "one-var",
            },
        ],
    };
};

Conversion runs will print any notices to the console:

$ npx tslint-to-eslint-config
✨ 1 rule replaced with its ESLint equivalent. ✨
📢 1 ESLint rule behaves differently from their TSLint counterparts: 📢
* one-var:
  - Variables declared in for loops will no longer be checked.
✅ All is well! ✅

Mergers

We’ve covered that some ESLint rules have different configuration styles than their TSLint equivalents. It’s reasonable that multiple TSLint rules may occasionally output uses of the same ESLint rule. Each of these cases is dealt with a “merge” on the tslint-to-eslint-config side, which takes in two rule arguments for the same ESLint rule and creates a single equivalent.

const mergers = new Map([
    ["@typescript-eslint/ban-types", mergeBanTypes],
    ["@typescript-eslint/indent", mergeIndent],
    ["@typescript-eslint/no-unnecessary-type-assertion", mergeNoUnnecessaryTypeAssertion],
    // ...
]);

For example: ESLint’s @typescript-eslint/ban-types is a fantastic, flexible rule that can ban a configurable list of generally unfavorable types. It’s normally used for odd built-ins such as Boolean and Number, but can also be configured to check for other type names.

On the off chance that multiple TSLint rule converters will eventually end up outputting @typescript-eslint/ban-types usage, the merger combines banned types from both original configurations into a single rule arguments structure:

const mergeBanTypes = (existingOptions, newOptions) => {
    if (existingOptions === undefined && newOptions === undefined) {
        return [];
    }

    return [
        {
            types: {
                ...(existingOptions && existingOptions[0].types),
                ...(newOptions && newOptions[0].types),
            },
        },
    ];
};

Yo Dawg

We’ve covered converting TSLint rules to their equivalent ESLint rules, converting TSLint rules to the closest possible ESLint rules, and multiple TSLint rules outputting the same ESLint rule. Here’s another case: what if there is no ESLint equivalent yet?

Suppose you’ve written a beautiful custom TSLint rule and haven’t yet converted it to ESLint, but you want to Do The Right Thing and switch your linter over. You could run both ESLint and TSLint as separate commands, and only use TSLint for your custom rule… or you could use typescript-eslint/eslint-plugin-tslint to run TSLint as an ESLint rule.

This package gives you an ESLint rule named @typescript-eslint/tslint/config that itself takes in a TSLint configuration and runs TSLint within your ESLint run.

// eslintrc.json
{
  "rules": {
    "great-converted-rule": "error",
    "@typescript-eslint/tslint/config": [
      "error",
      {
        "rules": {
          "fancy-schmancy-custom-rule": [true, "x", "y", "z"]
        }
      }
    ]
  }
}

Photo of Xzibit commonly used for the 'yo dawg' meme

We heard you like linters, so we put a linter in your linter...

Plugins

Some TSLint rule equivalents are only available in community-added plugins. Rules are allowed to add a plugins: string[] to their output to indicate you should also include and install them with your ESLint configuration.

TSLint’s deprecation rule is so far most closely represented by the import/no-deprecated rule in eslint-plugin-import. Its converter indicates to use the eslint-plugin-import package:

const convertDeprecation = () => {
    return {
        notices: ["Only import statements will be checked for deprecation."],
        plugins: ["eslint-plugin-import"],
        rules: [
            {
                ruleName: "import/no-deprecated",
            },
        ],
    };
};

As a result, tslint-to-eslint-config knows to tell the user to install the imported package…

$ npx tslint-to-eslint-config
✨ 1 rule replaced with its ESLint equivalent. ✨
⚡ 1 package is required for new ESLint rules. ⚡
        eslint-plugin-import
✅ All is well! ✅

…and add import to the list of plugins in your ESLint configuration.

module.exports = {
    // ...
    "plugins": [
        "@typescript-eslint",
        "import"
    ],
    "rules": {
        "import/no-deprecated": "error"
    }
};

Existing Files and Extended Rulesets

tslint-to-eslint-config is intended to be idempotent: meaning you should be able to run it as many times as you want in a folder, with the same or better results each time. More and more previously-unsupported TSLint rule features will be enabled in ESLint as typescript-eslint is developed. Re-running tslint-to-eslint-config every few version releases is likely to get you better and better results.

Adding to the complication: both ESLint and TSLint can extend from community rulesets such as tslint-microsoft-contrib and eslint-plugin-react.

This all means tslint-to-eslint-config needs to respect whatever ESLint configuration file exists on disk. There can now be at least four sources of information for what goes into your ESLint configuration:

  • ESLint rules enabled directly in an existing ESLint configuration
  • ESLint rules enabled via an extended ruleset
  • TSLint rules enabled directly in an existing TSLint configuration
  • TSLint rules enabled via an extended ruleset

(even worse, a few ESLint environment or parser settings are also read from your package.json and/or tsconfig.json if available… yikes!)

What a nightmare.

Extended Rulesets

Rules that would be printed in the output ESLint configuration are skipped if they directly match an existing ESLint plugin. As an example: if your ESLint configuration extends from, say, eslint-config-airbnb, and some of your stylistic TSLint rules happen to output ESLint rules that are already configured the same way in eslint-config-airbnb, your ESLint config won’t bother including them.

Eventually, I’d love to have tslint-to-eslint-config know to use contributed ESLint rulesets based on your TSLint extensions. That issue’s waiting on GitHub now. Accepting PRs! 😉

Configuration Trimming

ESLint and TSLint both support a --print-config flag to print the flattened configuration including extended rulesets. tslint-to-eslint-config uses that flag to split ESLint and TSLint configuration settings into two categories:

  • Full configurations: ones enabled in any way by the user or via an extended ruleset
  • Raw configurations: ones directly enabled in the user’s file(s) on disk

Full configurations are used to generate most output ESLint file contents, including rules. Raw configurations are used to populate the output ESLint extends (list of extended ESLint rulesets) and globals (list of globally available variables to never consider undeclared).

  • extends itself should respect the raw list from your configuration.
  • We have to respect the raw configuration for globals because ESLint resolves it to a massive list of global variables that you wouldn’t want to explicitly write out in your configuration.

These definitions break down somewhat for users who have multiple of their own ESLint and/or TSLint configuration files extending from each other… If you want to help build a better system, please do contribute on GitHub! 💖

Miscellaneous Pending Features

Open source is a beautiful thing. You can start a project with one singular focus and be told of feature suggestions you never would have thought of on your own.

It’d be swell if tslint-to-eslint-config could migrate your editor settings (e.g. .vscode/settings.json). Editor configurations are a supremely important part of how users interact with their linters. Some rules, such as around import ordering or stylistic preferences, would be practically unusable without editor auto-fixing.

Even better, how about migrating inline tslint:disable comments to eslint-disables? Manually changing those comments over is a pain in the butt.

Thanks for reading this far — happy linting!


Josh GoldbergHi! I'm a frontend developer from New York. This is my blog about JavaScript, TypeScript, and scaling web application development.
This site's open source on GitHub. Found a problem? File an issue!