How to write an ESLint plugin in TypeScript

taylor-vick-M5tzZtFCOfs-unsplash

I use NestJS at my day job. It's a complicated framework sometimes and there are lots of things that devs "just have to remember" or there will be bugs in your application that you won't see until runtime.

I wanted to remove this cognitive load from NestJS engineers so that they can focus on valuable work instead. I wrote an ESLint plugin to alert developers directly in their IDE or editor when these common issues exist - (Available on NPM) https://www.npmjs.com/package/@darraghor/eslint-plugin-nestjs-typed

Here is what I learned about writing ESLint plugins in typescript for typescript while building the plugin.

What is Eslint?

ESlint is the default linting tool in the JavaScript (ECMA Script) ecosystem. It is a command line tool but there are integrations with all popular IDEs and text editors.

From a developer's perspective ESlint continuously runs a set of "rules" on code to detect common issues.

ESLint also allows automatic fixing of issues in code, if the rule describes to ESLint how to fix an issue.

Here is an example of an eslint rule being triggered in VSCode.

Eslint rule example in an IDE

How ESLint works

ESLint converts our code into a common format - an Abstract Syntax Tree or AST - more on this later.

With this common format in place ESLint rule developers can write rules that examine the code. E.g.

if (myCodePieceOfCode is a function AND functionName is "someBadFunctionName"){
  notifyTheDeveloper()
}

It all works because of the conventions that ESLint sets for us.

ESLint and plugins

Eslint offers a very flexible plugin-type architecture. The parser you use to read the code and the rules that act on the parsed code are all pluggable.

Pluggable parsers gives us the opportunity to support different flavours of ECMAScript, like TypeScript. Pluggable rules let us configure ESLint to suit our needs specifically and allows ESLint to support new features very easily.

The purpose of this post is to show you how to add rules that are specific to your business or use case. For example, with custom rules you can automate some of the things that are checklists in your PR templates!

By detecting these issues in a developer's IDE or local development environment you drastically reduce the feedback loop time compared to, say, getting a PR review.

So let's get started!

What is AST

AST stands for Abstract Syntax Tree. That sounds worse than it is! Let's break it down.

1. Syntax

If we start with "syntax" - this is the exact same meaning as when we're talking about human languages.

In English I can use the labels "Verb", "Subject" and "Object" to describe the parts of a sentence. Because we label them we can have a shared understanding about how to construct a sentence in English.

We can discuss how a verb is used in this language. We can ask "is the verb in the correct position?". This is using a "syntax".

Highlighting verb object and subject in an english sentence

But only 45% of languages follow this Subject-Verb-Object syntax. I can show you a different language that you've probably never seen before and because of the labels we have agreed to use you can understand what each part of the sentence is doing in comparison to english - verb-subject-object.

Highlighting verb object and subject in an Irish sentence

Having shared labels is what makes this possible.

So if we have this code

class MyClass {}

And we agree that this is called ClassDefinition we can start to work with our code and check something like "if the ClassDefinition is in the correct position".

2. Abstract

The "abstract" bit means that we only parse the bits of code that are meaningful. For example we wouldn't parse whitespace in a language where whitespace isn't important.

Here is an example in english. The fact that one of these sentences is centred doesn't change our understanding of who drives the car.

So if I was reading the right-hand centred text to you in a phone call, I wouldn't mention how it was formatted as I was speaking. I would abstract or summarize the meaning and just say "Sue drives the car!".

Abstract sentences

We do this with our code in an AST also. For example in JavaScript a trailing comma in a function declaration is not required. It's completely stylistic. So we might not need this in our AST. It doesn't tell us anything.

function f(p) {} // this is valid javascript
// prettier-ignore
function f(p,) {} // trailing comma - this is also valid.

3. Tree

The tree is just a representation that's used for the code. There is a root node, it's often File or Program. And has leaves from there describing the parts of the program.

Using trees makes sense because of program structure and it also means the parsers can use well known tree traversal patterns for efficient parsing.

// Simple tree in object
{
  Program: {
    Method1:{
      //...
      },
    Method2:
    {
      //...
      }
  }
}

In reality a tree looks something like this when using an ESLint TypeScript parser.

A simple tree

So that's all the parts of the "Abstract Syntax Tree" explained.

An AST is an efficient representation only of the parts of code that matters and it uses labels that are agreed on for that specific parser.

A note on different parsers

There is an awesome tool that you can use to see the output of various language parsers at https://astexplorer.net/

You can use this tool to observe a few interesting things about AST parsers.

1. Parsers are specific to a language

You have to use a parser that supports types in order to parse typescript. Typescript is a superset of JavaScript and supports some syntax that is not in JavaScript like the types, enum's and decorators.

if you enter this code into AST Explorer you can see how some common parsers handle it.

@ApiTags("Recipes")
@ApiBearerAuth()
@UseGuards(DefaultAuthGuard)
@Controller("recipes")
export class RecipeController {
    constructor() {
    @Get(":uuid")
    @ApiOkResponse({ type: Recipe })
    findOne(
        @Param() uuid: string,
        @Request() request: RequestWithUser
    ): Promise<CustomBot> {
        return this.recipeService.findOne(uuid, request.user.uuid);
    }
}

First change to @typescript-eslint/parser. There should be no errors and everything is read OK.

Now change to @babel/eslint-parser parser. There is an error about the decorators because this parser doesn't support typescript.

So, you have to use a supported parser with your language - this is one reason why @typescript-eslint/parser exists!

2. Each parser creates different ASTs

Now change to the typescript parser. There is a lot of information in the AST on the right hand side but if you dig into the tree you can see that there is a "statements" node with an element, that element has "members" with 2 more elements that specifically describe the constructor and method in our code.

This is AST from the tsc command we use to build our typescript applications.

typescript parser body

Now change back to @typescript-eslint/parser. You can see that the AST is quite different! There is a "ClassBody" instead of "ClassDeclaration". There is a "body" property that has some "MethodDefinitions". There is nothing to indicate that the first one is specifically a constructor.

typescript-eslint/parser body

So when you write code to work with an AST you have to understand what the output will be. The labels, what conventions that the AST uses, will be specific to the parser.

The AST parser for Typescript in ESLint

I mentioned already that ESLint plugins need to follow a set of conventions. And this is the reason that the @typescript-eslint set of tools exist.

The AST used in ESLint needs to conform to expectations or ESLint can't understand it.

The AST that ESLint understands is called "estree". The @typescript-eslint/typescript-estree package creates an estree compatible AST that can be used in tools like ESLint but it is enriched to include useful type information.

The @typescript-eslint/parser package wraps a bunch of useful tooling to hook into ESLint. This will call the typescript-estree package when needed.

The important thing to note here is that ESLint requires a specific AST so that it can work.

Typescript is different to javascript. The @typescript-eslint/parser will convert your typescript into a suitable AST for ESLint.

This is why we set the parser in our .eslintrc.js file when we use ESLint in a typescript project.

// example of setting a parser for eslint in .eslintrc.js
module.exports = {
  parser: "@typescript-eslint/parser",
  parserOptions: {
    project: "tsconfig.json",
    sourceType: "module",
    ecmaVersion: "es2019",
  },
  plugins: [], //... and so on
};

Where to find my plugin to follow along with code

I'm going to describe the parts that go into building an ESLint plugin.

There will be lots of individual code examples in this article but I'll just be copying and pasting from the eslint-plugin-nestjs-typed project.

It might be easier to see it all in a project. If you want to follow along in the full plugin project you can find that on github.

https://github.com/darraghoriordan/eslint-plugin-nestjs-typed

An outline of an ESLint plugin

An eslint plugin is a package that exports an object on the default export that has rules and configurations.

In code this looks like

import rules from "./rules";
import configs from "./configs";

const configuration = {
  rules,
  configs,
};

export = configuration;

If you're following along in the github project you can see that the rules and configurations are arrays of objects that follow conventions. It's all about conventions in an ESLint plugin.

In the package configuration this is set as the entrypoint

{
  "name": "@darraghor/eslint-plugin-nestjs-typed",
  "version": "1.0.0",
  "description": "Eslint rules for nestjs projects",
  "main": "dist/index.js"
  // all the rest of the package.json
}

Naming an ESLint plugin package

There is another convention here around package name. ESLint will strip the "eslint-plugin" portion of your package name when creating a list of imported rules.

For example if I had to turn off a rule in my plugin this is the format

"@darraghor/nestjs-typed/my-rule": "off"

You can see that I'm referencing "@darraghor/nestjs-typed", not "@darraghor/eslint-plugin-nestjs-typed".

That was a tricky convention to figure out!

Outline of a rule

ESLint rules follow a strict pattern for initialisation. Typescript ESLint provides a helper "RuleCreator" to do this for us. We just pass in some configuration. I have commented the code below describing the parts of the configuration that might not be obvious in the code block below.

I'll add links to ESLint documentation that will better describe each property if you want to read more.

// We use the helper here to create a rule
const rule = ESLintUtils.RuleCreator({
  name: "param-decorator-name-matches-route-param",
  meta: {
    /* This docs meta is used to create docs in a build step for typescript-eslint rules.
     I haven't implemented this in my plugin but I wanted to follow the pattern so I can
     create better docs later if needed. */
    docs: {
      description:
        'Param decorators with a name parameter e.g. Param("myvar") should match a specified route parameter - e.g. Get(":myvar")',
      recommended: false,
      requiresTypeChecking: false,
    },
    /* These messages can be referenced in rule checking code.
    This text is displayed in IDE or CLI when ESLint rules are triggered.
     The rules can take dynamic properties.
     The format for a variable is double handlebars. e.g.
     "Number must be greater than 0 but found {{value}}". */
    messages: {
      paramIdentifierDoesntNeedColon:
        "You don't need to specify the colon (:) in a Param decorator",
      paramIdentifierShouldMatch:
        'Param decorators with identifiers e.g. Param("myvar") should match a specified route parameter - e.g. Get(":myvar")',
    },
    /*  ESLint rules can be passed configuration options
     in the eslintrc configuration file.
     The schema option is used to define
     what the options for your rule should look like.
     Eslint will alert the consumer of your rule
      that their configuration isn't valid at configuration time.
     if you wish to receive settings
     you would add the "options" property here.
     see https://eslint.org/docs/developer-guide/working-with-rules#contextoptions */
    schema: [],
    /*  This is used for providing suggestions
    see https://eslint.org/docs/developer-guide/working-with-rules#providing-suggestions */
    hasSuggestions: false,
    type: "suggestion",
  },
  defaultOptions: [],
  /* This is the method that contains our rule checking code.
  See below for more info. The available context is object
   is described here https://eslint.org/docs/developer-guide/working-with-rules#the-context-object */
  create(context) {
    return {
      Decorator(node: TSESTree.Node): void {
        /* see below for what to retrun from here*/
      },
      ClassDeclaration(node: TSESTree.Node): void {
        /* see below for what to retrun from here*/
      },
    };
  },
});

export default rule;

You can automatically apply fixes using eslint but I don't have that in any rules in my plugin. There's more details in the ESLint docs about adding fixers here: https://eslint.org/docs/developer-guide/working-with-rules#contextoptions

ESLint rules follow a visitor pattern. So you provide code that should be called when a node of a specific type is visited.

  create(context) {
    return {
      // here we're saying "Everytime ESLint visits a Decorator node, run this code"
        Decorator(node: TSESTree.Decorator): void {
          /* Here we check a property on the node for its name.
          if the decorator is not a "Param" decorator we return early.
           You'll notice that in these checks we use null/undefined safe calls ALL THE TIME.
           There is no guarantee really about
           what the tree looks like e.g. `expression?.expression?.expression`. */
            if (
                (
                    (node.expression as TSESTree.CallExpression)
                        ?.callee as TSESTree.Identifier
                )?.name !== "Param"
            ) {
                return;
            }
            /*  This shouldTrigger() method isn't in this document but you can see it in the full rule in the github repo.
            It returns a simple
             context object that looks like this
             {paramNameNotMatchedInPath: boolean, hasColonInName: boolean}*/
            const result = shouldTrigger(node)
            /*  To tell ESLint that a rule has been triggered
            we set a report on the context. The report has a convention and
             that is described in depth here: https://eslint.org/docs/developer-guide/working-with-rules#contextreport*/
            if (result.paramNameNotMatchedInPath) {
                context.report({
                    node: node,
                    messageId: "paramIdentifierShouldMatch",
                });
            }
            /*  You can see that we reference the name of the message we want to use
             we also pass in the node here to let ESLint know where the error is occurring*/
            if (result.hasColonInName) {
                context.report({
                    node: node,
                    messageId: "paramIdentifierDoesntNeedColon",
                });
            }
        },
    };
    },

So now to write a rule you can use AST Explorer to understand the AST you can expect to be passed in.

Then in the rule you can interrogate that AST as you need. If your rule should be triggered you return a message in a context report.

That is a basic ESLint rule!

There are many other helpers available in typescript-eslint. You can see how I use some of them in the plugin on GitHub.

Adding an ESLint configuration to a plugin

The second part of an ESLint plugin is the configurations. You will have used these before if you have configured an ESLint plugin and set the "extends" property.

{
  extends: [
        "eslint:recommended",
        "plugin:@typescript-eslint/recommended",
        "plugin:@typescript-eslint/recommended-requiring-type-checking",
        "plugin:@darraghor/nestjs-typed/recommended",
    ],
    }

ESLint will look in the list of configurations exported from your plugin for a rule set of that name.

To create a configuration with a rule set in your plugin you use the format below. You can provide any configuration that your plugin needs here.

export = {
    parser: "@typescript-eslint/parser",
    parserOptions: {sourceType: "module"},
    rules: {
        "@darraghor/nestjs-typed/param-decorator-name-matches-route-param":
            "error",
            "@darraghor/nestjs-typed/api-enum-property-best-practices": "error",
    },
};

See src/configs/recommended.ts in the github project to see how these can be exported for use in the main plugin export.

Unit testing an ESLint plugin rule

You can easily add tests for a rule by using a test helper provided by typescript-eslint.

The test helper actually creates an instance of a parser so it's almost like an integration test.

A test suite follows a convention where you provide valid test cases and invalid test cases. Valid cases should trigger no reports. Invalid cases should only trigger the reports listed in the test case.

const tsRootDirectory = getFixturesRootDirectory();

// create a new tester with a typescript parser
const ruleTester = new RuleTester({
    parser: "@typescript-eslint/parser",
    parserOptions: {
        ecmaVersion: 2015,
        tsconfigRootDir: tsRootDirectory,
        project: "./tsconfig.json",
    },
});

// pass in test cases
ruleTester.run("api-enum-property-best-practices", rule, {
  // valid case has no errors
    valid: [
        {
            code: `enum MyEnum{
                ValA,
                ValB
            }

            class MyClass {
                @ApiProperty({
                    enumName: "MyEnum",
                    enum: MyEnum,
                })
                public myProperty!:MyEnum
            }`,
        },
         ],
    invalid: [
        {
            code: `enum MyEnum{
                ValA,
                ValB
            }

            class MyClass {
                @ApiProperty({
                    type: MyEnum,
                    enum: MyEnum,
                })
                public myProperty!:MyEnum
            }`,
            // for an invalid case we list which messageIds (or any other reported data) should be present
            errors: [
                {
                    messageId: "needsEnumNameAdded",
                },
                {messageId: "needsTypeRemoved"},
            ],
        },

You can of course export smaller pieces of your rules as functions and test those using jest directly if you want to. This is also useful for tricky parts of a rule.

Integration testing an ESLint plugin rule

To test your whole plugin in a project locally you can add a local reference to it in the package.json of the project.

See below for a local npm package reference example

{
  "name": "my-project",
  "version": "0.0.1",
  "description": "",
  "author": "",
  "private": true,
  "license": "UNLICENSED",
  "scripts": {},
  "dependencies": {},
  "devDependencies": {
    "@darraghor/eslint-plugin-nestjs-typed": "file:../../eslint-plugin-nestjs-typed"
  }
}

You will also need to configure eslint to use your rule set.

now run eslint as usual and it should pick up any rules you export from the plugin. If you can't trigger the rule make sure your either setting it to "error" explicitly or you import a rule configuration that sets the rule to "error".

Performance testing an ESLint plugin rule

You can have ESLint run a performance report by running ESLint with an environment variable TIMING set to 1.

# like this in a shell
TIMING=1 npm run lint

that prints out a nice report showing the slowest eslint rules

Rule                                    | Time (ms) | Relative
:---------------------------------------|----------:|--------:
import/default                          |  8786.474 |    32.8%
import/no-named-as-default              |  8591.760 |    32.1%
import/no-named-as-default-member       |  7708.225 |    28.8%
@typescript-eslint/naming-convention    |  1303.439 |     4.9%
@typescript-eslint/no-unsafe-argument   |    81.141 |     0.3%
@typescript-eslint/no-floating-promises |    61.780 |     0.2%
unicorn/template-indent                 |    43.054 |     0.2%

Conclusion

That's it for ESLint rules in typescript.

Feel free to use the NestJS plugin as a template for your custom ESLint plugin.

If you have any questions hit me up on Twitter!