Custom TSLint rules with TSQuery đ â Craig Spence â Medium
What is a lint rule?
A lint rule is a little piece of code that uses static analysis to automatically detect things that may be wrong with other bits of code. They can be as simple as checking for the presence (or lack of) whitespace, or as complex as avoiding certain functions or patterns in specific circumstances. Lint rules can be used to enforce coding standards throughout a codebase, and to prevent bad code from being released to the world, such as a stray debugger;
console.log();
.There are a number of existing tools for linting your code, such as ESLint, TSLint, HTMLLint, and SASSLint. These tools are each designed for a specific language, come with their own rules, and can be configured to fit your specific needs. ESLint and TSLint even allow you to create custom rules, giving you fine-grained control over the standards of your codebase. Weâre going to focus specifically on writing custom rules with TSLint.
If you want to read a bit more about linting (specifically ESLint), check this awesome post by Sam Roberts.
Building a custom lint rule
TSLint comes with a huge set of lint rules out of the box, but sometimes you may want to write a custom lint rule that is specific to your codebase. If youâre frequently seeing the same issue in code reviews, or youâd like to pre-emptively discourage something that could cause future issues, writing a custom lint rule is a great option.
As an example, letâs consider and fit
, both of which are common utility functions in JavaScript testing libraries. fdescribe
gives us a way to run specific groups of tests, and fit
gives us a way to run individual testsâ very useful! Thereâs certainly nothing wrong with using either of these functions as part of normal development. However, if they get committed to source control, you could unintentionally end up running only a few tests in your test suite! That wouldnât be good, so we should write a custom lint rule to prevent it from happening. Weâre going to start with a ânormalâ TSLint rule and then modify it to use TSQuery. To get started, we need to talk about ASTs.
Getting an AST
An Abstract Syntax Tree (AST) is a data structure that contains all the structural meaning of the source code, without using any formal syntax. That means it is perfect for static analysis like linting. Within the TypeScript parser and compiler, the AST is described by an object called a SourceFile
. If we want to do any serious analysis of TypeScript code, we need to get our hands on one of them. Thankfully, TSLint makes it very easy! Letâs look at the basic shell of a new TSLintRule
:
The apply
function is the link between the TSLint runner and our custom rule. It is where we get access to the AST via the SourceFile
. The apply
function is where you set up any configuration that your rule has, and then (typically) call through to applyWithWalker
like this:
If youâre still not sure what an AST is, or just want to brush up a bit, check out this âĄmagical⥠talk I gave at JS Conf AU 2018 for a refresher!
Walking an AST
TSLintâs Walker APIs allow you to walk through each node in the TypeScript AST and define rules that inspect the properties of individual nodes, and the relationships between them.
We start with the SourceFile
(the root of the tree) and recursively visit each child of each node that we come across. As we walk through the tree, we can inspect each node and check for structures that break our rule.
In this case weâre looking for any occurrences of code like fdescribe()
or fit()
. We donât want our rule to fire if we see a comment (e.g.// fdescribe();
) or a standalone variable (e.g.fit
). This is why AST traversal is preferred over Regular Expressionsâââwe can be very specific about what constitutes a failure.
Exploring an AST
To help understand the structure of our code, we can use something like ASTExplorer to see the different nodes of the AST. Check out this example, and click on the functions in the code. The individual AST nodes should be highlighted:
We can see that when we write something like fdescribe()
, TypeScript converts that for us into an AST, with three nodes:
- An Expression Statement, which represents a single expression of code.
- A Call Expression, which represents a call to a function
- And an Identifier, which in this case represents the name of the function that is being called.
ASTExplorer shows you a very rich, detailed representation of the structure of the code, which is very useful but can also be overwhelming. As an alternative, you may want to also check out Uri Shakedâs TSQuery Playground.
Inspecting an AST
Just looking for an Identifier with the value fdescribe
on its own would mean we may get â false positives â. We specifically care about the case where we have an Identifier inside a Call Expression. In code, that looks like something like this:
With the correct structure in place, we now need to test what the text
of the Identifier is, and if it matches 'fdescribe'
, or 'fitâ
. If we get the right structure and the right Identifier name, we should raise a lint failure:
And boom đ„, we have a working lint rule!
Querying an AST
Our lint rule is pretty cool, and it works great! But the code for the rule isnât particularly readable, and it could get a lot worse if we had to write a more complex rule. Thankfully, we can use TSQuery, which gives us a powerful way to express these AST traversals, using something like CSS selectors.
We know that we care about a Call Expression if it contains an Identifier, and that Identifierâs name is either fdescribe
or fit
. This can be expressed with TSQuery with the following query:
CallExpression > Identifier[name=/^f(describe|it)$/]
This query is a bit like CSS with some extra tricks. We can use a child combinator ( >
) to check for the Identifier inside the Call Expression, and then use an attribute selector ([]
) to check for the specific name values with a Regular Expression. We can hook it into our rule like so:
By adding in our TSQuery selector, and running that over the SourceFile
, we can just map from our matches to an array of RuleFailures
đ! We no longer need to worry about using applyWithWalker
or traversing the tree đ!
We can finish off our rule by adding an automatic fix, and an option to only run this rule on files that match specific extensions (no point running the rule on a non-spec file!), and we end up with something like this:
Pretty neat eh?! đ
Testing a custom lint rule
While weâre at it, letâs add some tests for our rule. We start by defining what a âfailingâ bit of code looks like, parsing that code into AST, and then passing the AST to our rule. TSQuery is again useful, as it provides a helper function to turn a string of code into an AST:
We can do the same thing for a âpassingâ bit of code:
We can even add a test to make sure our âfixâ works:
Check out the full file here. Thereâs no reason why we couldnât have written these tests first and had a great spec against which to write our custom rule. Letâs write another, more complex rule, and do just that!
Building another custom lint rule
This time, weâre going to kick things up a notch and write a pretty niche rule, based on this great post by Paul Lessing. It turns out that error-handling with @ngrx/effects can be a bit tricky, and thereâs a particular case where it can break your whole app:
If you use catchError
in an observable chain in an effect, one error can stop the whole chain from running, which is almost definitely what you donât want. We use @ngrx/effects in our Angular app, and we donât want this to happen by accident! This basic example becomes the âfailingâ test case.
Our âpassingâ test is just some code that doesnât use catchError
:
Creating our query
We need to write a query that only selects calls to catchError()
when they occur on the outermost chain in an @Effect
. We can start with our failing code and build up from there. Using TSQuery playground to see the structure, we get something like this: