Creating a typescript-eslint Plugin
Passionate about frontend tooling, monorepos, and DX.
A long time ago in a galaxy far, far away...
I was working on an Angular modernization project for a client. During this modernization, we decided to start using Angular signals.
If you are not familiar with Angular signals, they are a basically containers for data which Angular can use to determine when to update certain parts the view.
Its basic building blocks are mySignal()
, mySignal.set(value)
, and effect(() => mySignal())
.
So, it includes a getter, a setter, and an effect basically. Of course, there is more to it, but this is the basic idea.
By calling the getter, the signal returns the current value of the signal.
So, if you call mySignal()
and the value is 5
, it will return 5
.
But if you forget to call the getter and just use mySignal
, it will return the signal itself, which is not what you want in most cases.
Yet, a lot of times it will go unnoticed, especially if you have weak typing, or you use it in a boolean condition.
In those cases, your code will compile and run, but it will not do what you expect it to do.
In fact, I have found myself repeating this one mistake over and over again and I spent too much time debugging it to find this missing ()
.
I was a bit frustrated and thought to myself: "There must be a way to prevent this from happening."
And then it hit me: "I can create a custom ESLint rule for this!" The great thing about ESLint is that it is highly customizable and you can create your own rules. So, I decided to create a rule that would warn me whenever I forget to call the getter of a signal.
In this article, I will show you how I created this rule and how you can create your own custom ESLint rule for your TypeScript project.
Repo with the final code
If you want to skip the article and just see the final code, you can find it here.
Setting up the project
First, we need to create a new typescript-eslint project. I would recommend starting with a template project, such this one: example ts-eslint project.
I do not want to go to deep into the setup, as it is not the focus of this article. Although, you should note that you should add eslint
, @typescript-eslint/utils
and @typescript-eslint
as peer dependencies in your package.json
.
You will also need some test runner, such as Jest or Vitest to test your custom typescript-eslint rules.
Creating the rule
Before we start diving into the code and the concepts of ASTs and eslint rules, I would like to give you a brief overview of what we want our plugin to do.
We want to create a rule that warns us whenever we forget to call the getter of a signal. Whenever we run linting, whether it be in the CLI, in the IDE through an LSP or in a CI/CD pipeline, we want to see a warning if we forget to call the getter of a signal.
const mySignal = signal(false);
console.log(mySignal); // ❌
console.log(mySignal()); // ✅
if (mySignal) { // ❌
console.log('mySignal is truthy');
}
if (mySignal()) { // ✅
console.log('mySignal() is truthy');
}
Now, that we know what we want to achieve, let's see how we can do this. First of all, typescript-eslint is the tool we want to use here, because we are working with TypeScript code. ESLint would not be able to pick up all the TypeScript specific syntax and types, so we need to use typescript-eslint. typescript-eslint is a wrapper around ESLint that allows ESLint to work with TypeScript code. It works really similar to ESLint, because both share the same concepts and the same API with some minor differences to traverse the AST.
What is an AST?
An Abstract Syntax Tree (AST) is a tree representation of the structure of your code. It is a way to represent the code in a way that is easier to analyze and manipulate programmatically. A compiler does not just "read" your code, but instead it parses it into an AST, which is a tree of statements and expressions. The compiler can then traverse the tree and analyze the code, optimize it, or transform it into something else.
ESLint uses the AST to analyze your code and apply rules to it. Some way or another ESLint must know how to read and understand your code to apply its rules. Regex would not be enough to analyze the code, because code is not just a string, but a structured set of statements and expressions. That is why ESLint uses an AST to analyze the code.
I know, this sounds really complicated, but it is not as hard as it sounds. Especially because ESLint is doing most of the heavy lifting for us.
In the end, we only have to write a few if/else statements to check if a certain pattern is matched in the AST.
If you are interested in learning more about ASTs, I would recommend reading a previous blog on this topic: Refactoring TypeScript at Scale.
Writing the rule
In order to create a custom rule, we need to create a new file in our project, e.g. src/rules/enforce-angular-signal-call.ts
.
This file will contain the implementation of our custom rule.
typescript-eslint provides a helper function to create a new rule, called RuleCreator
which is a ESLint concept also available in typescript-eslint.
We are basically create a rule declaratively and pass a lot of metadata to it, such as the name of the rule, a description, the type of the rule, the messages that should be displayed, the schema, and the default options.
But the most interesting part of the rule is the create
function, which is a factory function that returns an object with the rule implementation.
Inside the create
function, we get the source code from the context and we get the type checker from the parser services.
The type checker is a TypeScript concept that allows us to analyze the types of the code.
This is important, because we want to know if a certain identifier is a signal or not.
The create
function returns an object with keys that are the names of the ESLint nodes we want to analyze.
The values are functions that are called whenever the node is encountered in the AST.
In our case, we want to analyze Identifier
nodes, because we want to check if a certain identifier is a signal or not.
Therefore, we need to return an object with a key Identifier
and a function that takes a TSESTree.Identifier
as an argument.
import { ESLintUtils, TSESTree } from '@typescript-eslint/utils';
const createRule = ESLintUtils.RuleCreator((name) => `https://github.com/HaasStefan/eslint-plugin-enforce-angular-signal-call/tree/master/docs/rules/${name}.md`);
export const enforceAngularSignalCallRule = createRule({
name: 'enforce-angular-signal-call',
meta: {
type: 'suggestion',
docs: {
description: 'Enforce that Angular signals are called with the getter',
},
messages: {
enforceAngularSignalCall: 'An Angular signal should be called with its getter method',
},
schema: [], // No options
},
defaultOptions: [],
create(context) {
const { sourceCode } = context;
const services = sourceCode.parserServices;
if (!services || !services.program || !services.esTreeNodeToTSNodeMap) {
return {};
}
const checker = services.program.getTypeChecker();
return {
Identifier(node: TSESTree.Identifier) {
// TODO: Perform the checks and handle the node
}
};
},
});
Now that we have the basic structure of our rule, we can start implementing the logic. We have to get the node and then the type of the node from the type checker. Afterwards, we can check if the type is a signal and handle the node accordingly.
return {
Identifier(node: TSESTree.Identifier) {
const variableNode = services.esTreeNodeToTSNodeMap?.get(node);
if (variableNode) {
const type = checker.getTypeAtLocation(variableNode);
const typeName = checker.typeToString(type);
if (isSignal(typeName)) {
handleSignalNode(node, context, services, checker);
}
}
}
};
The isSignal
function is a helper function that checks if a certain type is a signal or not.
It is a simple function that checks if the type is a Signal
, WritableSignal
or InputSignal
. All of them would be considered to be a signal.
Something like this type predicate would work:
type Signal = `Signal<${string}>` | `WritableSignal<${string}>` | `InputSignal<${string}>`;
function isSignal(type: string): type is Signal {
const withoutGeneric = type.split('<')[0];
return !!withoutGeneric && ["Signal", "WritableSignal", "InputSignal"].includes(withoutGeneric);
}
The handleSignalNode
function is a helper function that checks if the node is called with the getter or not.
In my code, it checks a few edge cases and only reports an error in certain cases.
For simplicity, I will just show you the basic idea of how to handle the node.
function handleSignalNode(node: TSESTree.Identifier, context: Readonly<RuleContext>, services: ParserServices, checker: TypeChecker) {
const parent = node.parent;
if (parent && parent.type === AST_NODE_TYPES.CallExpression) {
const callee = parent.callee;
if (callee.type === AST_NODE_TYPES.Identifier && callee.name === node.name) {
context.report({
node,
messageId: 'enforceAngularSignalCall',
});
}
}
}
And if you want to 'break' early and leave the function, you can just return undefined and it will not report an error.
function handleSignalNode(node: TSESTree.Identifier, context: Readonly<RuleContext>, services: ParserServices, checker: TypeChecker) {
const parent = node.parent;
if (!parent || parent.type !== AST_NODE_TYPES.CallExpression) {
return;
}
const callee = parent.callee;
if (callee.type !== AST_NODE_TYPES.Identifier || callee.name !== node.name) {
return;
}
context.report({
node,
messageId: 'enforceAngularSignalCall',
});
}
Testing the rule
In a perfect world, we would write tests first and then implement the rule. I would definitely recommend writing tests for your custom rules, because it simplifies the development process and ensures that your rule works as expected.
I do not have the space to go into detail about how to write tests for your custom rules, but I would recommend looking at the tests here.
Piecing it all together
Now that we have implemented the rule and written tests for it, we can add it to our project. You could either directly consume it in your project, if you colocate the rule with your project, or you could publish it to npm and consume it as a package.
Both options are valid and it depends on your use case. Both of them require you to create a plugin that contains the rule and exports it. In the index file of the plugin, you would export a rules object containing the rule you have created.
import { enforceAngularSignalCallRule } from "./rules/enforce-angular-signal-call";
export const rules = {
"enforce-angular-signal-call": enforceAngularSignalCallRule,
};
Then you would add the plugin to your ESLint configuration and enable the rule in your project.
{
"plugins": ["enforce-angular-signal-call"],
"rules": {
"enforce-angular-signal-call/enforce-angular-signal-call": "warn"
}
}
And that is it! You have created a custom ESLint rule for your TypeScript project. Of course, I have skipped a few little steps here and their to keep the article in a readable length, but I hope you get the idea of how to create a custom rule. For the full code, publishing and consuming the rule, I would recommend looking at the repo.
Conclusion
Creating a custom ESLint rule is a great way to enforce certain patterns in your codebase. It can help you to prevent certain bugs, enforce best practices, or just make your code more readable. If you are working in a team and have a ton of nitpick comments in your PR's, you might be able to automate some of them with ESLint rules.
I hope this article was helpful and you have learned something new. I am aware that ASTs and ESLint rules can be a bit overwhelming at first, but I hope I could make it a bit more approachable for you by giving you this condensed overview.
If you are interested in these topics, I would recommend reading more from Josh Goldberg, full-time typescript-eslint maintainer.
"Opinions are my own and not the views of my employer."