Writing a Custom ESLint Rule in TypeScript

12 March 2024

Have you ever found a small (yet annoying), recurring issue in your PR reviews that is super formulaic and could probably be removed by a linter, but the issue is so specific to your project that there’s no hope of ever finding a pre-built ESLint rule that could do it for you? Why not make your own?

Our Problem

The testing framework Jest allows you to “focus” a specific test with the use of .only. If focused tests are present, Jest will skip unfocused tests and only run the focused ones. This can be dangerous in CI land as it might skip failing tests and provide false hope.

The Jest ESLint plugin exports a rule called no-focused-tests that will show warnings when you accidentally forget about these.

However, to aid readability in our acceptance tests, we use custom Jest wrappers for the Gherkin story syntax (given, when and then). These support the use of .only but are not caught by the standard ESLint rule.

given("a user wants to create a new item", () => {
  when("they click the + button on the item container", () => {
    let item: Item;

    beforeEach(async () => {
      item = await DSL.items.create();
    });

    // Will only run the blocks required to run this test!
    then.only("an item will be added to the bottom of the item list", async () => {
      const result = await DSL.items.all();

      expect(result).toHaveLength(1);
    });
  });
}):

I thought it would be a good idea to write a custom rule which replicates the functionality of the Jest rule for our custom wrappers.

Where do you even start?

Google, apparently. I discovered @typescript-eslint/typescript-estree, a library used to generate an Abstract Syntax Tree (AST) for your code, and later found AST Explorer, a website that allows you to view AST generation in real time. All programming languages and code analysis tools use ASTs to understand the code written by developers, and ESLint is no different.

What does it look like?

In essence an AST is a representation of your code as a large object (or tree, I suppose) which contains information about your code. Let’s look at the most basic form of our problem under the AST microscope.

given.only("", () => {

})

The AST view of the code above

The AST view of the code above

When using AST Explorer, set the language to JavaScript and the parser to @typescript-eslint/parser from the top bar

What does this mean?

Being a tree, the AST starts with a single top level object of type “Program”. The body array contains selectors for all the code contained within the program.

Some of the selectors encountered when writing our rule were:

  • CallExpression - Any function call such as the only("", () => ...) above

  • MemberExpression - Accessing any member of an object. In our case this is accessing the function only, a member of given

  • Identifier - Object containing a name; this is what allows us to filter for items named given or only

Let’s get building!

To start, we need to initialise a new TypeScript project with its own package.json.

This can be done as normal with npm/pnpm/yarn init

{
  "name": "eslint-plugin-blogpost",
  "main": "index.js",
  "dependencies": ...
}

The “name” field of the package.json is important.

ESLint will only import plugins named “eslint-plugin-(name)” or “@(name)/eslint-plugin“

If you’re using a monorepo setup, make sure you add the name of the project’s folder to the top-level package.json. The folder name does not have to be the same as the name in its package.json.

{
  "name": "my-big-huge-project",
  "workspaces": [
    "custom-eslint-rules",
    "acceptance-tests"
    "// Other workspaces go here",
  ]
  ...
}

In this case, our workspace eslint-plugin-blogpost lives within the folder custom-eslint-rules.

Required Packages

Install these with your package manager as usual:

Make sure that your project’s tsconfig.json has both its "module" and "moduleResolution" fields set to "Node16" or higher or else you won’t be able to import any of the @typescript-eslint modules

Let’s get testing!

It helps to write your tests before you write the actual rule. Even if you don’t catch every edge case, in many cases it can make it easier to see that your rule is working without having to add it to project.

typescript-eslint makes our life easier by providing a RuleTester. This automatically wraps around any installed testing framework (like Jest or Mocha) and runs tests against your rule.

import {RuleTester} from "@typescript-eslint/rule-tester";

// Import our rule file
import * as target from "./no-focused-gherkin";

// Creation of a RuleTester
const ruleTester = new RuleTester({
  // Resolves the direct path to the package in node_modules
  /* RuleTester must use the @typescript-eslint parser to be able to
   * cope with TypeScript features like type annotations etc */
  parser: require.resolve("@typescript-eslint/parser")
});

Make sure your test framework is capable of running TypeScript code

Next, pass in some test cases for your rule.

  • Valid test cases can be as simple as strings containing code or as complex as objects containing file paths, environment variables or even version constraints.

  • Invalid test cases must be objects containing some code and the errors expected from said code

ruleTester.run("no-focused-gherkin", target.noFocusedGherkin, {
  valid: [
    'given("", () => {})',
    "console.only('hello')"
  ],
  invalid: [
    {
      code: 'given.only("", () => {})',
      errors: [{messageId: "focused"}]
    },
    {
      code: `
        given("", () => {
          when.only()
        })
      `,
      errors: [{messageId: "focused"}]
    }
  ]
});

Run the tests

Running the tests is as simple as running your test framework as normal. Your framework of choice will automatically recognise the tests from the RuleTester.

Of course, the tests won’t work until we write the actual rule, so how do we do that?

Writing the actual rule code

@typescript-eslint once again has our back. It provides us with a RuleCreator utility function for creating fully type-safe ESLint rules. We used RuleCreator.withoutDocs as we don’t have a website like publicly available ESLint plugins do.

The RuleCreator takes an object containing a few required fields:

  • meta - Details about your rule including a description and any error messages it can return to the user

  • defaultOptions - Default options for your rule if you choose to allow user configuration

The final required field is a function called create. ESLint calls this function to set up your rule. create must return an object containing functions which will be called on a specific type of ESTree selector.

export const noFocusedGherkin = ESLintUtils.RuleCreator.withoutDocs({
  meta: {
    docs: {
      description: "Prevent commits that accidentally focus acceptance tests."
    },
    messages: {
      focused: "This test is focused with '.only'. This must be removed before commit"
    },
    type: "suggestion",
    schema: []
  },
  defaultOptions: [],
... (continued below)

Make sure you export the RuleCreator so you can access it in other files!

Matching some errors in the code

This is as simple as filtering down the AST until you reach a point where you’re sure that you can throw an error. AST Explorer is your friend for this.

create(context) {
    return {
      ExpressionStatement(node) {
        if (node.expression.type !== AST_NODE_TYPES.CallExpression) return;
        if (node.expression.callee.type !== AST_NODE_TYPES.MemberExpression) return;
        if (node.expression.callee.object.type !== AST_NODE_TYPES.Identifier) return;
        if (!["given", "when", "then"].includes(node.expression.callee.object.name)) return;
        if (node.expression.callee.property.type !== AST_NODE_TYPES.Identifier) return;
        if (node.expression.callee.property.name !== "only") return;

        context.report({
          messageId: "focused",
          node: node.expression.callee
        });
      }
    };
  }

When it’s finally time to throw an error, the context object passed into create is your friend.

context.report({
  messageId: "focused",
  node: node.expression.callee
});
  • messageId comes from the meta object defined earlier in the RuleCreator.

  • The node you provide determines what part of the code gets highlighted as invalid. In our case we passed the expression callee

image 20231213 102240

Building the index

Your code must contain an index.ts file which exports an object called rules. This will define the names of your rules and what code they map to.

import {noFocusedGherkin} from "./rules/no-focused-gherkin";
import type {AnyRuleModule} from "@typescript-eslint/utils/ts-eslint";

export const rules = {
  "no-focused-gherkin": noFocusedGherkin
} satisfies Record<string, AnyRuleModule>; 

Building the project

ESLint doesn’t natively support TypeScript. As such, you will need to compile your project to JavaScript using tsc.

Create a file named index.js at the root of your ESLint plugin workspace. This file should just export your compiled TypeScript code.

module.exports = require("./build/index.js")

Make sure the “main” entry in your package.json points to this index.js file.

ESLint uses the “main” field to figure out which file to load when importing your plugin

And that’s it!

The rule is now ready to import in another project.

Using the rule in other packages or projects

In the workspace in which you want to use the rule, add a reference to the rule in its package.json

{
  "name": "acceptance-tests",
  "devDependencies": { // Or standard dependencies, ESLint doesn't care
    "eslint-plugin-blogpost": "workspace:custom-eslint-rules",
  }
}

Ask your package manager of choice to install dependencies and we’re finally ready to add the rule to the ESLint config!

Adding the rule to your ESLint Config

The home stretch! All you have to do now is tell ESLint to use your plugin and configure it to use the rule.

module.exports = {
  ...the rest of your ESLint config
  "plugins": [
    "@typescript-eslint",
    "blogpost" // Note that we omit the eslint-plugin- prefix
  ],
  "rules": {
    "blogpost/no-focused-gherkin": "error"
  }
}

A full sample monorepo ESLint rule project can be found on my GitHub

Article By
blog author

Rhys O'Kane

Software Engineer