Coding 101

Custom ESLint Rules and Quality Gates: Static Analysis for TypeScript Teams

DD
Ankur Ishwar
7 min read Updated Sep 6, 2026
Writing Custom ESLint Rules and Quality Gates for TypeScript

Why Off-the-Shelf Linters Miss Architectural Bugs

Prettier formats indentation. Standard ESLint configs ensure you do not leave unused variables or shadow global identifiers. But standard rules know nothing about your company's domain architecture. They will happily let a developer query a multi-tenant database without passing an organization ID filter, fire an unawaited asynchronous tracking call inside a serverless handler, or instantiate raw Date objects instead of using your centralized time-mocking service.

When these bugs slip past pull request reviews, they cause production data leaks and sporadic runtime failures. The solution is not writing longer wiki guidelines that developers forget to read. The solution is encoding your team's architectural invariants into custom ESLint rules and enforcing them via automated SonarQube quality gates in your CI pipeline.

The Modern Toolchain: ESLint 9 and TypeScript ESLint Utils

With the release of ESLint 9, the legacy .eslintrc format has been replaced by the Flat Config format (eslint.config.js). Building rules with full TypeScript AST support requires @typescript-eslint/utils, which provides strongly typed node selectors and compiler helpers.

# Install ESLint 9 and TypeScript ESLint tooling
npm install -D eslint @typescript-eslint/utils @typescript-eslint/parser @typescript-eslint/rule-tester vitest

Step 1: Implementing a Custom Domain Rule

Let us author a rule called enforce-tenant-query-filter. In our SaaS application, any database call matching db.table.findMany({ where: { ... } }) must explicitly include a tenantId property inside the where clause to prevent cross-customer data leakage.

// eslint-rules/enforce-tenant-query-filter.ts
import { ESLintUtils, TSESTree } from '@typescript-eslint/utils';

export const createRule = ESLintUtils.RuleCreator(
  (name) => `https://internal-docs.mycompany.com/lint-rules/${name}`
);

export const enforceTenantQueryFilter = createRule({
  name: 'enforce-tenant-query-filter',
  meta: {
    type: 'problem',
    docs: {
      description: 'Enforce tenantId parameter in all database query where clauses to prevent cross-tenant data leaks.',
    },
    messages: {
      missingTenantId: 'Multi-tenant database query must specify tenantId inside the where clause.',
    },
    schema: [], // No configurable options required
    hasSuggestions: false,
  },
  defaultOptions: [],
  create(context) {
    return {
      // Use an AST selector matching method calls named findMany or findFirst
      'CallExpression[callee.property.name=/^(findMany|findFirst|updateMany|deleteMany)$/]'(node: TSESTree.CallExpression) {
        // Ensure the call has at least one argument
        const arg = node.arguments[0];
        if (!arg || arg.type !== TSESTree.AST_NODE_TYPES.ObjectExpression) {
          context.report({
            node,
            messageId: 'missingTenantId',
          });
          return;
        }

        // Locate the 'where' property
        const whereProp = arg.properties.find(
          (prop): prop is TSESTree.Property =>
            prop.type === TSESTree.AST_NODE_TYPES.Property &&
            prop.key.type === TSESTree.AST_NODE_TYPES.Identifier &&
            prop.key.name === 'where'
        );

        if (!whereProp || whereProp.value.type !== TSESTree.AST_NODE_TYPES.ObjectExpression) {
          context.report({
            node,
            messageId: 'missingTenantId',
          });
          return;
        }

        // Verify 'tenantId' is defined inside where
        const hasTenantId = whereProp.value.properties.some(
          (prop) =>
            prop.type === TSESTree.AST_NODE_TYPES.Property &&
            prop.key.type === TSESTree.AST_NODE_TYPES.Identifier &&
            prop.key.name === 'tenantId'
        );

        if (!hasTenantId) {
          context.report({
            node: whereProp,
            messageId: 'missingTenantId',
          });
        }
      },
    };
  },
});

Step 2: Writing Unit Tests with RuleTester

Never deploy a linter rule without unit testing both valid and invalid syntax trees. RuleTester validates error message IDs and node reporting locations.

// eslint-rules/enforce-tenant-query-filter.test.ts
import { RuleTester } from '@typescript-eslint/rule-tester';
import { enforceTenantQueryFilter } from './enforce-tenant-query-filter.js';
import { describe, it, afterAll } from 'vitest';

RuleTester.describe = describe;
RuleTester.it = it;
RuleTester.afterAll = afterAll;

const tester = new RuleTester({
  languageOptions: {
    parserOptions: {
      ecmaVersion: 2022,
      sourceType: 'module',
    },
  },
});

tester.run('enforce-tenant-query-filter', enforceTenantQueryFilter, {
  valid: [
    {
      code: `db.users.findMany({ where: { tenantId: ctx.tenantId, role: 'admin' } });`,
    },
    {
      code: `db.orders.findFirst({ where: { tenantId: session.orgId, status: 'PENDING' } });`,
    },
  ],
  invalid: [
    {
      code: `db.users.findMany({ where: { role: 'admin' } });`,
      errors: [{ messageId: 'missingTenantId' }],
    },
    {
      code: `db.orders.findMany({});`,
      errors: [{ messageId: 'missingTenantId' }],
    },
  ],
});

Step 3: Registering Custom Rules in ESLint 9 Flat Config

You do not need to publish an npm package to use custom rules. You can bundle them into a local plugin object inside your root eslint.config.js file:

// eslint.config.js
import tsParser from '@typescript-eslint/parser';
import tsPlugin from '@typescript-eslint/eslint-plugin';
import { enforceTenantQueryFilter } from './eslint-rules/enforce-tenant-query-filter.js';

export default [
  {
    files: ['src/**/*.ts'],
    languageOptions: {
      parser: tsParser,
      parserOptions: {
        ecmaVersion: 'latest',
        sourceType: 'module',
      },
    },
    plugins: {
      '@typescript-eslint': tsPlugin,
      'local-rules': {
        rules: {
          'enforce-tenant-query-filter': enforceTenantQueryFilter,
        },
      },
    },
    rules: {
      'local-rules/enforce-tenant-query-filter': 'error',
    },
  },
];

Step 4: Hardening CI with SonarQube Quality Gates

A custom ESLint rule stops developers on their local machines. To guarantee that regressions never reach the production branch, integrate SonarQube into your continuous integration pipeline.

Define your project configuration:

# sonar-project.properties
sonar.projectKey=company_core-api
sonar.organization=my-company
sonar.sources=src
sonar.tests=tests
sonar.typescript.lcov.reportPaths=coverage/lcov.info
sonar.eslint.reportPaths=eslint-report.json
sonar.qualitygate.wait=true

Next, configure the GitHub Actions step to fail if quality gates are breached:

# .github/workflows/ci.yml
name: Quality Gate & Static Analysis

on:
  pull_request:
    branches: [main]

jobs:
  audit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0 # Full history required for Sonar analysis

      - uses: actions/setup-node@v4
        with:
          node-version: 22

      - run: npm ci
      - run: npm run lint -- --format json -o eslint-report.json
      - run: npm test -- --coverage

      - name: SonarQube Scan
        uses: sonarsource/sonarqube-scan-action@v3
        env:
          SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
          SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}

Defining Enterprise Quality Gate Thresholds

Configure your SonarQube server with non-negotiable thresholds for pull requests:

  • New Blocker and Critical Issues: 0 allowed. If a developer introduces a new high-severity security issue or custom lint error, the pull request cannot be merged.
  • New Code Coverage: Minimum 85%. Newly touched code paths must be verified with automated regression tests.
  • Duplicated Code Density on New Code: Under 2.5%. Stops copy-pasted business logic across controllers.

When static analysis runs automatically on every commit, code review discussions shift away from defensive syntax verification and toward real architecture, user experience, and scalability.

Found this useful?
View all articles

Keep Reading

Related Articles

Learn with Dropout Developer

Build real software with AI

Step-by-step learning paths, vibe coding tutorials, and certified developer programs designed for the modern engineer.