Skip to main content

Library design - Inversion of Control

· 5 min read
Andrea Pontrandolfo
Sheriff author, Tech Lead @ Velasca

I wanted to give a glimpse into the architectural design decisions going on in Sheriff behind the scenes, so I'm sharing a deep dive into the Inversion of Control principle and how it helped Sheriff achieve a more flexible and accessible options API.

info

I know, "Inversion of Control" is an expression that can have many different meanings in computer programming, depending on the context, but in this article I'm using it in loose terms to describe the process of giving the user more control over the library's behavior. Sorry, Java bros!

Why

One of the coolest things about Sheriff is the way it encapsulates the complexity of ESLint and its ecosystem, most of the times hiding the ugly details behind the curtains. The problem is that sometimes the level of abstraction is too intrusive and can obstacolate the user's ability to customize the configuration to their needs. It's a delicate balance to strike.

In this article we will explore why the Inversion of Control principle is often the key to unlock the best solution.

The noRestrictedSyntaxOverride option

The problem

ESLint offers a rule called no-restricted-syntax that allows you to disallow specific Javascript syntax features.

Sheriff comes with a preconfigured no-restricted-syntax rule that disallows some of the most common syntax features that are considered harmful or confusing.

The problem is that the rule accepts an array of features, meaning that if the user doesn't like just one of these features, they had only 2 options:

  • disable the rule entirely

    noRestrictedSyntax: 0
  • override the rule with a new one

    noRestrictedSyntax: [
    2,
    {
    selector: "...",
    message: "...",
    },
    {
    selector: "...",
    message: "...",
    },
    ]

So in Sheriff we came up with a solution that allows the user to disable just the specific feature they don't like or append new ones.

The API looked like this:

noRestrictedSyntaxOverride: {
adjuncts: [
{
selector: "LabeledStatement",
message: "...",
},
{
selector: "ForInStatement",
message: "...",
},
],
allows: [
"LabeledStatement",
"ForInStatement",
],
},

In the adjuncts array, the user can add new syntax features to disallow on-top of the default ones, while in the allows array they can disable some of the default ones.

As you can see, the offered API looked pretty complex, arbitrary, tangled and unintuitive. Both adjuncts and allows suggest at "adding" something, so it could be confusing to understand "what did what" for newcomers of the library. In short: it was not user-friendly at all.

The solution

Inversion of control to the rescue.

Instead of wrapping the no-restricted-syntax option into the Sheriff options, we let the users define and configure the no-restricted-syntax rule directly in their own config and offer them composables around it. This way the user is back in the driver seat and has full control again.

Sheriff now expose a variable called baseNoRestrictedSyntaxRules that contains the contents of the Sheriff-configured no-restricted-syntax rule. So adding new restricted features on top of the default ones is as simple as:

no-restricted-syntax: [
2,
...baseNoRestrictedSyntaxRules,
// your custom rules here...
],

While disabling some of the default ones is a little more involved.

Every entry prints the index of itself at the end of the message property, so users should use the index to identify which entry they want to remove.

In the docs we suggest using the native Javascript toSpliced() method to remove an entry from the baseNoRestrictedSyntaxRules array:

no-restricted-syntax: [
2,
...baseNoRestrictedSyntaxRules.toSpliced(2, 1)
// your custom rules here...
],

🠪 Learn more about this API in the docs.

Going forward

Recently an ESlint contributor released a very interesting alternative to the no-restricted-syntax rule called eslint-no-restricted.

Sheriff will most likely adopt this library in the future and abandon the current "homegrown" solution, as it seems to provide a superior DX.

Typescript-eslint project API

Another case where Sheriff was swallowing up complexity at the cost of giving up a level of control to the user was the parserOptions.project API.

The problem

Sheriff was passing the tsconfig.json to the ESLint config in a hard-coded way:

project: './tsconfig.json'

Naturally users with multiple tsconfig.json on the same level were feeling limited, because it wasn't possible to specify different paths for the tsconfig.json.

The solution

The solution was to let the user define the project path through the Sheriff options:

pathsOverrides: {
tsconfigLocation: "./tsconfig.sample.json",
},

and fallback to the default one if not specified:

parserOptions: {
project: userChosenTSConfig || true,
},

Going forward

Since then the typescript-eslint team moved to a new option called projectService.

This option is meant to definitely get rid of the need for the custom tsconfigs, like tsconfig.eslint.json etc, that some users had for some advanced use-cases. It also has other benefits. Learn more.

Sheriff is currently exploring on adopting this new API (Issue | PR), but it seems like the feature is still experimental and has some issues to iron out first.

FlatConfig VS ESLint wrappers

info

This topic is also briefly covered in the prior-art section.

While Sheriff has many unique features, it strives to still be just an ESLint config at its core.

This means, again, that the user has all the power in its hands over the linting experience. Sheriff takes nothing out of ESLint, it only enhances it.

While, on the contrary, ESLint wrappers hinder the power of ESLint, by removing control from the hands of the users.

Conclusion

By simplifying APIs and empowering advanced users with more granular controls, Sheriff strikes a balance between ease of use and developer freedom.