DEV Community πŸ‘©β€πŸ’»πŸ‘¨β€πŸ’»

DEV Community πŸ‘©β€πŸ’»πŸ‘¨β€πŸ’» is a community of 963,673 amazing developers

We're a place where coders share, stay up-to-date and grow their careers.

Create account Log in
Cover image for How to write custom ESLint plugins
Daniel Reed
Daniel Reed

Posted on

How to write custom ESLint plugins

Until recently, there were two technologies that I didn’t understand. Crypto and ESLint plugins. Today I finally understood ESLint plugins.

I've been wanting to make a custom ESLint plugin for a few months to see how I could customize my developer experience. I want to share my experience in learning about them, and give a guide on how you can build your own plugins in the future.

Background

My team and I have been working on a client project, and a few months back we set some TypeScript code style conventions that we felt would help us manage some of our interfaces, types, and styled components:

  • Interfaces should start with the letter I
  • Types should start with the letter T
  • Styled components should start with the letter S

Our belief is that this will help us and other developers know exactly what type a type is when using it throughout a codebase. Deciding this is one thing. Maintaining it is another and this left us with 2 options:

  • Remember this rule and fix it in code reviews when we see mistakes
  • Set up an ESLint rule to check this for us automatically

So, I took this as an opportunity to finally learn how to build custom ESLint plugins, and deliver a solution to our dev team.

My brief

My plugin idea was simple. It should analyze TypeScript interfaces and types, and ensure they start with a capital I or capital T. It should also analyze styled components and ensure they start with a capital S. Not only should it warn users when it finds a mistake, it should offer code solutions to fix these tedious tasks.

ESLint + Abstract Syntax Trees (ASTs)

To understand ESLint, we need to take a step back and understand a bit more about how ESLint works. The basics are that ESLint needs to parse your code into something called an Abstract Syntax Tree, which is a representation of your code, it’s definitions, and values. If you want to learn more about how compilers and ESLint break code down into understandable chunks, Twillio has a great guide on the computer science behind that.

Building your plugin

To keep things simple, this will be a guide on building a ESLint plugin that targets TypeScript interfaces.

Step 1: Understanding our code

First up is finding a way to capture all interfaces in our code and finding their name (or Identifier). This will allow us to then verify that the interface name follows our convention of starting with a capital I.

To visualize an abstract syntax tree, we can use a tool called AST explorer. Here is an example link to get you started. You will see an AST generated on the right, and although this looks like madness, it’s actually pretty understandable. Click around in the right hand "tree" window, and open the body data block.

ast explorer

Basically what we have now is some data on how a compiler might understand the code you have written. We have:

  • InterfaceDeclaration: the type of the interface
  • Identifier: the identity of the interface (AppProps in this case)
  • ObjectTypeAnnotation: the content of the interface
  • And more data on where the code is in the editor

This is great. Now we can understand how to catch all interfaces and then check their identifier names.

Step 2: Build our transform rule

Now we can start to build a solution. When thinking about ESLint plugins, you can think of it in 2 parts. A β€œlistener” that checks for a match, and a β€œresponder” that sends an error/warning and (maybe) offers a code solution. AST explorer has an editor that allows you to write these β€œlisteners” and β€œresponders”, and see how ESLint might use them.

First of all, in the menu at the top of the page, make sure the button next to β€œJavaScript” is set to babel-eslint. Then click on the β€œTransform” button and pick ESLint v4.

In the transform window, you should see some sample code. Read through it and it should explain most of how ESLint transforms work:

  • A rule is an object with a series of β€œlistener” keys to match (in this example a TemplateLiteral)
  • When a node is matched, a function is fired and returns a context report with a message and (optional) code fix. This is sent back to the user

Using this knowledge, we can build a solution for our plugin. Replace TemplateLiteral with the type of an interface (InterfaceDeclaration) and you should now see a warning thrown in the console on the right. That’s the basics and now we have a demo transformer working.

Now we need to write a real solution. Let’s add some basic logic that checks if the first letter of the interface id is the letter I:

export default function (context) {
  return {
    InterfaceDeclaration(node) {
      if (node.id.name[0] !== "I") {
        context.report({
          node,
          message: "Interfaces must start with a capital I",
        });
      }
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

We should still see the error message. Add the letter I before AppProps and the error should disappear. Great. Now we have a working rule. Test it with some valid and invalid examples to verify things are working as expected. It might be easier to test these examples one at a time:

interface Card {
  preview: boolean;
}

interface Card extends Props {
  preview: boolean;
}
Enter fullscreen mode Exit fullscreen mode

We now have everything we need to build a plugin for our team and the open source community to use.

Step 3: Build our project

Building a plugin package is straightforward, using the Yeoman ESLint generator: https://github.com/eslint/generator-eslint#readme

Install the package:
npm i -g generator-eslint

Run the CLI and follow the instructions:
yo eslint:plugin

You will also need to install the TypeScript parser:
npm i @typescript-eslint/parser --dev

Make a new file in the lib/rules directory called interfaces.js and add this boilerplate:

module.exports = {
  meta: {
    type: "suggestion",
    schema: [],
    docs: {
      description: "Enforcing the prefixing of interfaces",
    },
  },
  create: (context) => {
    return {
      TSInterfaceDeclaration(node) {
        if (node.id.name[0] !== "I") {
          context.report({
            node: node.id,
            message: "Interfaces must start with a capital I",
          });
        }
      },
    };
  },
};
Enter fullscreen mode Exit fullscreen mode

Few things to note:

  • We have a meta object with some details about the rule, useful for documentation
  • We’ve replaced our InterfaceDeclaration β€œlistener” for a TSInterfaceDeclaration (see below)
  • We have a create function that contains the transformer we made earlier

Why did I replace InterfaceDeclaration for TSInterfaceDeclaration?
The babel-eslint plugin doesn’t parse TypeScript but does work in the AST explorer. Using the @typescript-eslint/parser ensures we are parsing correctly, and we will add that as our parser in the next steps. This took me 2 hours to debug when writing unit tests.

Finally, let’s add a unit test. Inside the tests/lib/rules directory, add a file called interfaces.test.js and add this boilerplate:

const rule = require("../../../lib/rules/interfaces");

const RuleTester = require("eslint").RuleTester;

RuleTester.setDefaultConfig({
  parserOptions: { ecmaVersion: 6, sourceType: "module" },
  // eslint-disable-next-line node/no-unpublished-require
  parser: require.resolve("@typescript-eslint/parser"),
});

const tester = new RuleTester();

tester.run("rule: interfaces", rule, {
  valid: ["interface IAnotherInterface { preview: boolean; }"],
  invalid: [
    {
      code: "interface AnotherInterface { preview: boolean; }",
      errors: [{ message: "Interfaces must start with a capital I" }],
      output: "interface IAnotherInterface { preview: boolean; }",
    },
  ],
});

Enter fullscreen mode Exit fullscreen mode

Most of this is the testing format recommended by the ESLint team. The main part here is adding the TypeScript parser, and adding a range of valid and invalid tests to assert. You can read more about unit testing in the ESLint docs.

Step 5: Adding code fixing

Almost done. To add code fixing, simple add a fix function inside the content.report object:

fix: (fixer) => {
    return [fixer.replaceText(node.id, "I" + node.id.name)];
},
Enter fullscreen mode Exit fullscreen mode

Finally, make sure to write a unit test and asserts the output:

{
  code: "interface CustomProps extends AppProps { preview: boolean; }",
  errors: [{ message: "Interfaces must start with a capital I" }],
  output: "interface ICustomProps extends AppProps { preview: boolean; }",
},
Enter fullscreen mode Exit fullscreen mode

And that’s it. Now you are ready to push the plugin to npm or add it to your project locally.

ESLint plugin fixing an interface

Next steps

If you're interested, you should look into how to catch interfaces where the keyword already starts with the letter I, like InfoBoxProps. Also, this plugin needs better support for edge cases with odd interface names like infobox or idProps since our matching won’t catch those right now.

Wait, why isn’t the plugin written in TypeScript too
Good question. It can be, but to keep things simple I opted to just build it in vanilla JS. If I’m adding a lot more rules or working on this project with others, then switching to a TypeScript toolchain would be a great idea

This might be the 4th time I’ve tried to build an ESLint plugin. When doing my research, I found the API docs and most of the guides and tutorials written by others really hard to read and understand, even for the most simple plugin ideas. Hopefully this guide will help you get started.

You can see my repo with my interface examples, and 2 more I made (one for types and one for styled components) here.

GitHub logo kwaimind / eslint-plugin-prefix-types

An ESLint plugin to enforce the prefixing of interfaces, types, and styled components.

!

Latest comments (0)

Visualizing Promises and Async/Await 🀯

async await

☝️ Check out this all-time classic DEV post