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.
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",
});
}
},
};
}
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;
}
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",
});
}
},
};
},
};
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 aTSInterfaceDeclaration
(see below) - We have a create function that contains the transformer we made earlier
Why did I replace
InterfaceDeclaration
forTSInterfaceDeclaration
?
Thebabel-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; }",
},
],
});
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)];
},
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; }",
},
And that’s it. Now you are ready to push the plugin to npm or add it to your project locally.
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.
kwaimind / eslint-plugin-prefix-types
An ESLint plugin to enforce the prefixing of interfaces, types, and styled components.
!
Top comments (0)