This week, I dove into using ESLint custom rules for the first time. Due to the fact that I’m working with a serverless deployment, I need to ensure that there’s an active database connection every time a serverless function is called. With the way serverless instances work, it’s not guaranteed that a database is connected when a cold start is initiated.
My solution was to create a custom ESLint rule, that detects when you’re calling functions on a mongoose model, and ensures some logic that connects the DB precedes the action, within the code block’s scope.
However, I will focus more in detailing how I set up the custom rule with typescript-ESLint here rather than my specific solution.
I will not be creating a plugin for ESLint. Instead, I made use of the virtual plugin feature.
Start off by following the installation steps for typescript-ESLint
Then create a folder called “eslint-rules” to store the custom rules.
Create a new typescript file within that same eslint-rules folder, and give it a useful name.
Here’s mine:
// eslint-rules/require-db-connection.ts
import { ESLintUtils, AST_NODE_TYPES } from '@typescript-eslint/utils';
import { Scope } from '@typescript-eslint/utils/ts-eslint';
export const rule = ESLintUtils.RuleCreator.withoutDocs({
meta: {
type: 'problem',
docs: {
description: 'Ensure connectDB() is called before any Mongoose model operation.',
},
schema: [],
messages: {
connectDb: 'Mongoose operation "{{ calleeName }}" must be preceded by a call to connectDB() in the same scope.',
},
},
defaultOptions: [],
create(context) {
const scopesWithConnectDB = new Set();
const parserServices = ESLintUtils.getParserServices(context);
const checker = parserServices.program.getTypeChecker();
return {
CallExpression(node) {
const callee = node.callee;
//Tracks scopes where connectDB is used
if (callee.type === AST_NODE_TYPES.Identifier && callee.name === 'ConnectDB') {
scopesWithConnectDB.add(context.sourceCode.getScope(node));
}
else if (callee.type === AST_NODE_TYPES.MemberExpression && callee.property.type === AST_NODE_TYPES.Identifier) {
const tsNode = parserServices.esTreeNodeToTSNodeMap.get(callee.object);
const type = checker.getTypeAtLocation(tsNode);
// Check if the type extends Mongoose.Model
const typeSymbol = type.getSymbol();
if (!typeSymbol) return;
const typeName = typeSymbol.getName();
if (typeName !== 'Model') return;
const scope = context.sourceCode.getScope(node);
if (!ScopesConnected(scope, scopesWithConnectDB)) {
context.report({
node,
messageId: 'connectDb',
data: { calleeName: callee.property.name },
});
}
}
}
};
}
});
//Traverses up the scope chain to find the nearest scope that matches
function ScopesConnected(target: Scope.Scope, scopes: Set) {
let currentScope: Scope.Scope | null = target;
while (currentScope) {
if (scopes.has(currentScope)) {
return true;
}
if (currentScope.type === Scope.ScopeType.function) {
break;
}
currentScope = currentScope.upper;
}
return false;
}
export default rule;
The important thing to note here is; in order to get some type safe properties when creating a custom rule, import the ESLintUtils and use it’s RuleCreator.
Within the same ESLint rules folder, create a “tsconfig.rules.json” file.
// eslint-rules/tsconfig.rules.ts
{
"extends": "../tsconfig.json",
"compilerOptions": {
"module": "Node16",
"moduleResolution": "node16",
"outDir": "../dist",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"noEmit": false,
"strict": true,
},
"include": [
"**/*.ts"
]
}
This config file is needed in order to transpile the typescript rule to JavaScript. The “outDir” property sets the directory for the transpiled JavaScript.
You should already be using typescript in your project, and therefore have a tsconfig file already in the root of your project. This new “tsconfig-rules.json” file should then extend the root “tsconfig.json”.
The includes uses a glob-like pattern match to match all typescript files within the eslint-rules directory. Essentially telling the transpiler which files to transpile.
Use the following command to transpile the custom rule.
npx tsc --project eslint-rules/tsconfig.rules.json
If successful, you’ll see the converted JavaScript file within the /dist folder.
Now to get the rule actually working it needs to be setup within the eslint.config.js
If you don’t already have this file create it in the root of your project.
// eslint.config.js
import rule from './dist/require-db-connect.js';
import tseslint from 'typescript-eslint';
import tsParser from '@typescript-eslint/parser';
export default tseslint.config(
{
//Globally ignore these files and directories to stop applying default es lint rules
//Ts files are already ignored by default
ignores: [
".output/**",
"dist/**",
".nuxt/**",
"eslint.config.js",
'/**/*.js',
],
},
{
//Choose the files the custom rule should run on
files: ['server/**/*.ts'],
//Creates a virtual plugin with the imported custom rule
plugins:
{
local: {
rules: {
'require-db-connect': rule,
}
}
},
languageOptions: {
ecmaVersion: 2021,
sourceType: "module",
parser: tsParser,
parserOptions: {
project: "./tsconfig.eslint.json",
},
},
rules: {
"local/require-db-connect": "error"
}
});
You’ll notice there are two separate JavaScript objects within the config. The first object is for setting a global ignores pattern. This global ignores only works when there are no other keys within the object.
* The reason I used a global ignores is that I didn’t want to run the default ESLint rules. If you want these default rules, don’t use this global ignores.
The second object within the config is for setting up the custom rule. Import the custom rule and insert it into the virtual plugin.
The “Files” property is important as it defines which files or directories should be running the custom rule. In my case, I only need it to run on the “server” directory. You should change this pattern for your own projects needs.
Under “parserOptions” the project property is a path, to another specific tsconfig file specifically for eslint, also located in the root of the project. Here’s how it should look.
// tsconfig.eslint.ts
{
"compilerOptions": {
"noEmit": true,
"module": "node16",
"moduleResolution": "bundler"
},
"extends": "./tsconfig.json"
}
Again, within this config file extend your original tsconfig file. Also set “noEmit” property to true, to avoid any files being output.
You can now run the command below to test out the rule. If you’re running into issues when running the linter, add the –debug flag to get some details on what went wrong.
npx eslint
For real time errors in your IDE, install the ESLint extension.