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 allprivate
class members whose values are never modified after initialization are declared with theprivate
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"]
}
}
]
}
}
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-disable
s? Manually changing those comments over is a pain in the
butt.
Thanks for reading this far ā happy linting!