How to write a custom ESLint rule in 19 lines of code

Bharat Kalluri / 2023-05-23

I don't like it that JavaScript does not have named parameters. In python for example, I can do function_name(a=1, b=2), but in javascript this does not work. Hence what we do is pass in the parameters as an object and later de-structure it in the function. Which works great, since JS has a great de-structuring syntax.

But sometimes engineers write a function which will take in four strings as normal arguments. This can backfire. Since all arguments are strings, the caller can jumble arguments and there might be unexpected issues. Catching this stuff in code review is also very hard and the only way to find it out is when testing. We can do better.

Why not enforce that every function which has more than two arguments should always take in parameters as an object so that the caller can be explicit?

ESLint has a rule for this, It's called max-params, setting it to 2 ensures that we do not allow any function to have more than two arguments. So if the function needs to have three arguments, it means that it has to be converted to an object. And then it becomes a single argument. So switching it on should solve our problem, right?

All the typescript servers we write use NestJS as the framework. NestJS does dependency injection out of the box, which means that the dependencies are declared at a class constructor level and they are populated by NestJS during runtime. The max-params rule throws an error here as well, stating that the constructor has more than 2 parameters. That's a problem, since we cannot fix this as its not an issue in the first place. So we'll need to configure max-params to exclude constructors. I could not find one, so let's write it.

How does a linter work?

Linter reads the code and checks it against a set of rules. If any of these rules do not pass, it'll raise a alert stating that the rule. The way linters "read" code is by going through the Abstract Syntax Tree of the codebase. Or more colloquially known as AST.

What is an Abstract Syntax Tree?

Code is converted from plain text to a tree structure for by the parser. This tree is later parsed by the interpreter to run the code. Since this is a data structure to represent the abstract syntactic representation of the program, this can be parsed for patterns!

Here is the AST of function hello() {}

{
	"type": "Program",
	"start": 0,
	"end": 19,
	"loc": {
		"start": { "line": 1, "column": 0 },
		"end": { "line": 1, "column": 19 }
	},
	"comments": [],
	"range": [0, 19],
	"sourceType": "module",
	"body": [
		{
			"type": "FunctionDeclaration",
			"start": 0,
			"end": 19,
			"loc": {
				"start": { "line": 1, "column": 0 },
				"end": { "line": 1, "column": 19 }
			},
			"id": {
				"type": "Identifier",
				"start": 9,
				"end": 14,
				"loc": {
					"start": { "line": 1, "column": 9 },
					"end": { "line": 1, "column": 14 },
					"identifierName": "hello"
				},
				"name": "hello",
				"range": [9, 14],
				"_babelType": "Identifier"
			},
			"generator": false,
			"expression": false,
			"async": false,
			"params": [],
			"body": {
				"type": "BlockStatement",
				"start": 17,
				"end": 19,
				"loc": {
					"start": { "line": 1, "column": 17 },
					"end": { "line": 1, "column": 19 }
				},
				"body": [],
				"range": [17, 19],
				"_babelType": "BlockStatement"
			},
			"range": [0, 19],
			"_babelType": "FunctionDeclaration",
			"defaults": []
		}
	]
}

Spend some time analyzing the above AST, its pretty interesting. ESLint works off this AST to validate rules.

Open AST explorer. On the top right third menu bar element, select babel-eslint and write some code on the first pane. You'll see the tree changing on the side pane. The side pane is showcasing the AST of code you have written.

Explore the tree a bit, there is a clear notation in which code gets translated into the tree. For example, in the above example FunctionDeclaration has an id and a body as a BlockStatement.

Writing our own rule

Start by looking at max-params rule itself on github.

Let's briefly understand the structure of an ESLint rule. The js file is expected to export a function called create. this returns back an object of which the keys are the nodes they are targeting and values are the functions to run on that node.

In the max-params rule, the rule is run on three different types of nodes

  • FunctionDeclaration (const arrowFnExample = (a, b, c, d) => {})
  • ArrowFunctionExpression (const arrowFnExample = (a, b, c, d) => {})
  • FunctionExpression (functions inside classes)

This makes sense, and then the checkFunction is also very clean and simple. A more stripped version of the same code

function checkFunction(node) {
	if (node.params.length > numParams) {
		context.report({ node, message: 'y u do dis?' });
	}
}

At this point, the only change we'll need to make to this is to recognise if a method type is a constructor. If yes, then ignore that method. After a bit of console.log - ing of node contents, we can find that there is a pointer to the parent in node.parent. And a constructor has a special "kind" called "constructor".

Great! Plugging that in, the final rule becomes

let numParams = 2;
module.exports = {
	create(context) {
		function checkFunction(node) {
			if (node.parent.kind !== 'constructor' && node.params.length > numParams) {
				context.report({ node, message: 'y u do dis?' });
			}
		}

		return {
			FunctionDeclaration: checkFunction,
			ArrowFunctionExpression: checkFunction,
			FunctionExpression: checkFunction,
		};
	},
};

There you go, 19 lines of code and your first ESLint rule.

Running this on an existing code base

Create a folder in your existing code base, I called it eslint-rules. Create a file inside that and name it max-params-exclude-constructor.js.

Now change package.json 's lint command to run eslint with an extra option called --rulesdir and pass the folder as an argument. An updated command might look like

eslint \"{src,apps,libs,test}/**/*.ts\" --fix --rulesdir ./eslint-rules

And in the eslint config, add one more rule

"max-params-exclude-constructor": "error"

and now you should have a custom functional ESLint rule!

Making it better

The messaging around the rule can be much more eloquent. context.report is very powerful and can be used to share more context of the code.

Ideally the options should come from the user, context also has a key for this called options which can be parsed and used in the rule (example from max-params)

Some ESLint rules also have a --fix option. The way it works is by mutating the AST to a desired structure.

But you say, this is something others would have run into, right?

You're right.

Hand crafted by Bharat Kalluri