Using TypeScript to build custom ESLint rules faster

Vincent Francois
inato
Published in
6 min readApr 7, 2023
Photo by Thomas Couillard on Unsplash

ESLint is a powerful tool to quickly find issues on the code you are writing. A lot of rules were created by the community but you might have some use cases specific to your codebase for which no rule exists or you did not find it.

ESLint offers the possibility to write your own custom rules that fit your specific needs. Let’s see how TypeScript can help build them faster!

You can find here a github repository with the example rule I will talk about in this article used in a monorepo to lint some code. Don’t hesitate to refer to it if you face issues while trying to build your own rule.

Getting started

Here’s where the ESLint documentation tells us to start:

// customRule.js

module.exports = {
meta: {
type: "suggestion",
docs: {
description: "Description of the rule",
},
fixable: "code",
schema: [] // no options
},
create: function(context) {
return {
// callback functions
};
}
};

I’ll focus next on the create function which is where you’ll write the rule code that is executed by ESLint. Look at the doc to understand what to set in the meta part.

What should we write in the return of the create function? Here’s another example written in the doc:

  create: function(context) {
// declare the state of the rule
return {
ReturnStatement: function(node) {
// at a ReturnStatement node while going down
},
onCodePathStart: function (codePath, node) {
// at the start of analyzing a code path
},
};
}

We can see some specific keywords (ReturnStatement, onCodePathStart) that define when the provided code should be run. I’ve always had struggles finding which keywords are available. I’m used to having TypeScript auto-complete these sort of stuff for me so I’m a bit lost without it.

Also you might wonder what the node argument is. It represents the node in the Abstract syntax tree (AST) as a JavaScript object but what can we find in it? I used to write a lot of console.log when writing these rules but clearly TypeScript can help us here too.

Bringing TypeScript

Start by installing the node module @typescript-eslint/utils. Let’s then rewrite the rule in TypeScript with some small changes:

// myRule.ts
import { TSESLint} from '@typescript-eslint/utils';

type MessageIds = 'messageIdForSomeFailure' | 'messageIdForSomeOtherFailure';

const myRule: TSESLint.RuleModule<MessageIds> = {
defaultOptions: [],
meta: {
type: 'suggestion',
messages: {
messageIdForSomeFailure: 'Error message for some failure',
messageIdForSomeOtherFailure: 'Error message for some other failure',
},
fixable: 'code',
schema: [], // no options
},
create: context => ({})
}

export default myRule

Notice that we had to give a parameter to the type TSESLint.RuleModule. It defines the ids of the error messages that ESLint will display in case of errors. You define the message ids and write the messages for each id in the meta part of the rule. In the create function, you’ll then reference the errors by their message id.

Thanks to TypeScript, we now have the auto-completion in the create function. We can search for something that could match our need and search it on the web to make sure it’s what we want.

Building the rule

Let’s see the full power of TypeScript by starting to create a rule. Let’s say we want to forbid the use of the foo and bar functions. We’ll need to use the CallExpression keyword which will run our code when any function is called. We need to give it a function with a node parameter. Thanks to TypeScript, we can explore what exists in this parameter:

You’ll still probably need to console.log things but TypeScript really helps trying to figure out what we can do and what we want to check.

Here’s the full code for the rule (the enum AST_NODE_TYPES comes from the @typescript-eslint/utils library):

  create: context => ({
CallExpression: node => {
// we only care about the callees that have a name (see below)
if (node.callee.type !== AST_NODE_TYPES.Identifier) {
return;
}

if (node.callee.name === 'foo') {
return context.report({
node: node.callee,
messageId: 'messageIdForSomeFailure',
});
}
if (node.callee.name === 'bar') {
return context.report({
node: node.callee,
messageId: 'messageIdForSomeOtherFailure',
});
}

return;
},
}),

Without TypeScript, I never knew what could be optional or not so I usually had this kind of extra ugly checks:

if (!node || !node.callee || node.callee.type !== AST_NODE_TYPES.Identifier) {
return;
}

See above how the code is much cleaner without these useless checks.

Testing the rule

To help build the rule, make sure it really does what we want and avoid regression, we can add some unit tests for it. You’ll probably also want to create some tests to help you build the rule, in TDD style.

We’ll need to install the package @typescript-eslint/rule-tester to import the tester utility and we also need to install the @typescript-eslint/parser node module so ESLint is able to parse the TypeScript code we’ll write in the test cases.

Edit note: The rule tester utility was in the @typescript-eslint/utils package before version 6 so you might have issues if you migrate from older versions.

Here’s a test file for our rule:

// myRule.test.ts
import { RuleTester } from '@typescript-eslint/rule-tester';

import myRule from './myRule';

const ruleTester = new RuleTester({
parser: '@typescript-eslint/parser'
});

ruleTester.run('my-rule', myRule, {
valid: ['notFooBar()', 'const foo = 2', 'const bar = 2'],
invalid: [
{
code: 'foo()',
errors: [{ messageId: 'messageIdForSomeFailure' }],
},
{
code: 'bar()',
errors: [{ messageId: 'messageIdForSomeOtherFailure' }],
},
],
});

We can then run this test with our favorite test runner, like jest, vitest or mocha for example.

See that we re-used the messageIds defined in the rule for the invalid cases. This allows auto-completion and makes your test independent of the actual message that will be displayed to the users. You can focus on testing when each kind of failures should be displayed and later think about what error message will be displayed.

If you are using yarn’s Plug’n’Play, you’ll have an issue with the code above with the @typescript-eslint/parser package. Here’s a workaround I found for this issue:

// myRule.test.ts
import { RuleTester } from '@typescript-eslint/rule-tester';
const parserResolver = require.resolve('@typescript-eslint/parser');

const ruleTester = new RuleTester({
parser: parserResolver as any,
});

Using the rule

Now that we have a rule that works, it’s time to use it to actually lint some code.

Before anything, we need to “publish” the rule as an ESLint plugin. This can be done as a node module or as a package in a monorepo. Either way, we need to transpile the rule as ESLint needs JavaScript and we need to define the entrypoint of the module.

  • First, add an index.ts file which will export your rule with the name you want it to have:
import { TSESLint } from '@typescript-eslint/utils';
import myRule from './myRule';

export const rules = {
'my-rule-name': myRule,
} satisfies Record<string, TSESLint.RuleModule<string, Array<unknown>>>;
  • Run TypeScript's tsc -b to build your code.
  • Make sure the main property of your package.json file points to the built index.js file (in lib, cjs …)
  • Make sure your package name (defined in package.json) starts with eslint-plugin-. What’s after will be your plugin’s name. eslint-plugin-my-awesome-plugin will define a plugin my-awesome-plugin that you’ll be able to reference in your ESLint configuration files
  • If you are not in a monorepo, publish your package
  • In your application, add your plugin package as a dependency
  • In your application ESLint configuration file, add your plugin and your rule:
plugins: [..., 'my-awesome-plugin'],
rules: {
...
'my-awesome-plugin/my-rule-name': 'error',
}
  • Run ESLint and see the errors that your rule finds 🎉!

You can find all this done in a monorepo here

Going further

If you try to auto-complete the object passed to context.report, you can see that it can accept a fix prop. This is what will be run when you run eslint with the --fix parameter. See this other article I wrote on this topic if you want to learn more!

I hope you’ve learned a few things in this article and that you feel confident writing your own custom ESLint rules!
Feel free to ask your questions in the comments!

--

--