Josh Goldberg

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

Nov 19, 201915 minute read

Now that TSLint is being deprecated, let's look at how tslint-to-eslint-config converts your TSLint configuration to ESLint.

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/array-type": "error",
		"@typescript-eslint/indent": "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:

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 tslint-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": {
		"@typescript-eslint/tslint/config": [
			"error",
			{
				"rules": {
					"fancy-schmancy-custom-rule": [true, "x", "y", "z"]
				}
			}
		],
		"great-converted-rule": "error"
	}
}

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:

(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 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).

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!