DEV Community

Craig ☠️💀👻
Craig ☠️💀👻

Posted on

Custom TSLint rules with TSQuery 😍

(originally posted on Medium)

TL;DR

TSLint already has a fairly straightforward API for adding custom lint rules, but writing the actual logic of the rules can be quite tricky! We can make that a lot easier by using powerful AST node selectors with TSQuery:

If you want to learn more about the “how?” and “why?” of TSQuery, check out this introduction here!


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; or 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 fdescribe 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 TSLint Rule:

A TSLint rule has a Rule class, which extends Rules.AbstractRule from TSLint. It has a single public function called apply, which takes a SourceFile, and returns an array of RuleFailures.

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:

Using ASTExplorer we can see the Identifier node for the fdescribe function

We can see that when we write something like fdescribe(), TypeScript converts that for us into an AST, with three nodes:

  1. An Expression Statement, which represents a single expression of code.
  2. A Call Expression, which represents a call to a function
  3. 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)$/]
Enter fullscreen mode Exit fullscreen mode

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 .spec.ts 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:

We can use TSQuery playground to inspect the AST of our code and build up a query to select very specific structures.

We care about any CallExpression which has the name "catchError", but specifically only at the first level of an Effect chain. An Effect chain is created with a PropertyDeclaration that has a Decorator with the name Effect. We can describe that relationship with the following query:

ClassDeclaration 
> PropertyDeclaration:has(Decorator Identifier[name="Effect"]) 
> CallExpression 
> CallExpression
> Identifier[name="catchError"])
Enter fullscreen mode Exit fullscreen mode

Whew. That looks quite a bit more complex, but it fairly concisely describes the specific structure that we care about. We can now come up with a useful error message, and plug it into a new rule:

Et voilà! Our tests pass, and we have a brand new, super specific lint rule fresh out the oven 🥖! Our codebase is safer than ever before, and we don’t have to remember to look for this in code reviews! This rule could’ve been pretty tricky to write with an AST Walker, but with TSQuery it’s easy.

Wrapping up

There you have it!

TSQuery is particularly suited for this kind of code inspection, so why not have a go at writing your own rule using it. You can see some more examples of lint rules written using TSQuery here and here from Nicholas Jamieson. Please hit me up if you have any comments/questions, and thanks for reading 👋!

Top comments (0)