Library design - Inversion of Control
I wanted to give a glimpse into the architectural design decisions going on behind the scenes in Sheriff, so I'm sharing a deep dive into the Inversion of Control principle and how it helps Sheriff achieve a more flexible and accessible API.
I know that Inversion of Control has many meanings in computer programming, but I'm using here it 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, mostly hiding the ugly details behind a curtain. The problem is, sometimes the level of abstraction is too intrusive and can interfere with users' ability to customize the configuration to suit their needs. It's a delicate balance to strike.
In this article, we will explore how Sheriff uses the Inversion of Control principle as the key to unlocking the best APIs.
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 the most harmful and confusing syntax features.
The problem here is that the rule accepts an array of features, meaning that if the user dislike any one of these lints, they had only 2 options:
- disable the rule entirely
noRestrictedSyntax: 0
- override the rule with a new one
noRestrictedSyntax: [
2,
{
selector: "...",
message: "...",
},
{
selector: "...",
message: "...",
},
]
In Sheriff, we came up with a solution that allows users to both disable specific restrictions they disliked and append new ones.
The API looked like this:
noRestrictedSyntaxOverride: {
adjuncts: [
{
selector: "LabeledStatement",
message: "...",
},
{
selector: "ForInStatement",
message: "...",
},
],
allows: [
"LabeledStatement",
"ForInStatement",
],
},
In the adjuncts
array, users can add syntax features to disallow on top of the defaults, while they can use the allows
array to disable specific defaults.
As you can see, the offered API looked rather complex and unintuitive. Both adjuncts
and allows
suggest "adding" something, so newcomers found understanding "what did what" difficult. In short: it was not user-friendly at all.
The solution
Inversion of control to the rescue!
Instead of wrapping no-restricted-syntax
options into Sheriff’s options, we let users define and configure the no-restricted-syntax
rule directly in their own config and then offer composables around it. This way, our users are back in the driver’s seat and have full control once more.
Sheriff now exposes a variable called baseNoRestrictedSyntaxRules
that contains the contents of the Sheriff-configured no-restricted-syntax
rule. With this simple API, adding new restrictions on top of the defaults is as simple as:
no-restricted-syntax: [
2,
...baseNoRestrictedSyntaxRules,
// your custom rules here...
],
Admittedly, disabling the defaults is a little more involved.
Each entry prints its index at the end of the message, so users can use the indices to identify which entries they wish to remove.
In the docs we suggest using the native JavaScript Array#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, Brad Zacher released an interesting alternative to no-restricted-syntax
called eslint-no-restricted.
Sheriff will likely adopt this library in the future and abandon the current, homegrown solution, as Brad’s solution seems to provide superior DX.
To keep up with the latest developments, check out issue #375.
Typescript-eslint project
API
Another case where Sheriff swallowed up complexity at the cost of taking away control from users was with typescript-eslint’s parserOptions.project
API.
The problem
Sheriff used to hardcode the path to the tsconfig.json
it passed to typescript-eslint.
project: './tsconfig.json'
Naturally, users with multiple tsconfig.json
s felt limited because it wasn't possible to specify different paths for tsconfig.json
s.
The solution
The solution was to let the user define the project
path through the Sheriff options:
pathsOverrides: {
tsconfigLocation: "./tsconfig.sample.json",
},
and fall back to the default if not specified:
parserOptions: {
project: userChosenTSConfig || true,
},
Going forward
Since then, the typescript-eslint team has moved to a new system called the project service.
This option is meant to get rid of the need for custom tsconfig.json
s like tsconfig.eslint.json
, etc., that some users had made for advanced use cases. The project service also has other benefits, and is often more performant. Learn more.
Sheriff is currently exploring on adopting this new API (issue | PR).
FlatConfig VS ESLint wrappers
This topic is also briefly covered in the prior-art section.
While Sheriff has many unique features, it strives to remain nothing more than a standard ESLint config.
This means, again, that the user has all the power in their hands over the linting experience. Sheriff takes nothing out of ESLint, it only enhances it.
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 users with more granular controls, Sheriff seeks to strike a balance between ease of use and developer freedom. As the library evolves, we remain committed to enhancing developer experience with modern, user-focused solutions.