Writing Custom ESLint Rules for TypeScript Codebases
AI generated
type
TypeScript
Writing Custom ESLint Rules for TypeScript
From syntax-only checks to real TypeChecker access

Off-the-shelf rule sets catch generic problems, but every mature codebase accumulates conventions that only a custom rule can enforce mechanically. This article walks through the anatomy of an ESLint rule, from syntactic and typed linting through fixer functions, testing with RuleTester, and wiring everything into an ESLint 9 flat config.

12 min read TypeScript ESLint 9 AST & TypeChecker

1. Why write custom ESLint rules at all?

Off-the-shelf rule sets like eslint:recommended or @typescript-eslint/recommended catch generic problems, but every mature codebase accumulates its own conventions: forbidden imports across module boundaries, a mandatory wrapper around every fetch call, or a fixed naming scheme for feature flags. Recurring code review comments are usually a clear signal that a rule should take over that job instead.

A custom rule turns a convention into something machine enforced instead of merely documented. It runs in the editor while typing, in the CI pipeline on every pull request, and during a local --fix run, without anyone having to remember it during review.

ESLint draws a hard line between purely syntactic linting, which only works on the AST structure, and typed linting, which gets access to the real TypeScript compiler through @typescript-eslint/utils. This article walks through both, including a fixer function, tests with RuleTester, and wiring everything into an ESLint 9 flat config.

2. Rule anatomy: meta and create

Every ESLint rule exports an object with two main parts: meta describes the rule for tooling and documentation, and create(context) returns the actual visitor. With @typescript-eslint/utils, ESLintUtils.RuleCreator wraps this pattern, forcing a documentation URL template and correctly inferring the types for context.

meta.type is one of problem, suggestion, or layout, and affects how editors classify the reported issue. meta.schema defines allowed options as JSON Schema, so a misconfigured option surfaces the moment ESLint starts instead of failing silently mid run. meta.messages maps message IDs to text, so the rule body only ever references an ID while the wording stays centralized.

create(context) returns an object whose keys are either AST node types like CallExpression or esquery selectors. For every matching node, ESLint calls the corresponding function during the tree walk, and inside that function the rule checks conditions on the node and calls context.report() when something is off.


import { ESLintUtils } from '@typescript-eslint/utils';

const createRule = ESLintUtils.RuleCreator(
  (name) => `https://internal-docs.example.com/eslint-rules/${name}`,
);

// Disallows direct process.env access outside the config module.
export const noDirectEnvAccess = createRule({
  name: 'no-direct-env-access',
  meta: {
    type: 'problem',
    docs: {
      description: 'Disallows direct process.env access outside the config module.',
    },
    schema: [],
    messages: {
      noDirectEnvAccess:
        'Do not read process.env directly, use the typed config module instead.',
    },
  },
  defaultOptions: [],
  create(context) {
    return {
      MemberExpression(node) {
        if (
          node.object.type === 'MemberExpression' &&
          node.object.object.type === 'Identifier' &&
          node.object.object.name === 'process' &&
          node.object.property.type === 'Identifier' &&
          node.object.property.name === 'env'
        ) {
          context.report({ node, messageId: 'noDirectEnvAccess' });
        }
      },
    };
  },
});

3. Example: banning console.log outside allowed files

A classic candidate for a syntactic rule is banning console.log in production code, with an exception for a dedicated logging module. The rule needs no type information at all, it only inspects the shape of the call and the current file name.

Through context.options[0] the rule reads a list of allowed file patterns as regular expressions. If the current filename matches one of them, create simply returns an empty visitor object and ESLint skips checking any node in that file. Otherwise, the selector CallExpression[callee.object.name="console"] targets calls whose object is named console, regardless of whether the method is log, debug, or warn.

The selector approach tends to be more readable than nested if checks inside the handler, and it automatically covers every matching node type without treating MemberExpression and CallExpression separately.


// eslint-rules/no-console-outside-logger.js
/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
  meta: {
    type: 'problem',
    docs: {
      description: 'Disallows console.* calls outside files matching an allowlist pattern.',
    },
    schema: [
      {
        type: 'object',
        properties: {
          allow: {
            type: 'array',
            items: { type: 'string' },
          },
        },
        additionalProperties: false,
      },
    ],
    messages: {
      noConsole: 'Unexpected console.{{method}} call, use the logger module instead.',
    },
  },
  create(context) {
    const options = context.options[0] || {};
    const allowPatterns = (options.allow || []).map((pattern) => new RegExp(pattern));
    const filename = context.filename ?? context.getFilename();

    if (allowPatterns.some((pattern) => pattern.test(filename))) {
      return {};
    }

    return {
      'CallExpression[callee.object.name="console"]'(node) {
        const method = node.callee.property.name;
        context.report({
          node,
          messageId: 'noConsole',
          data: { method },
        });
      },
    };
  },
};

4. AST basics: ESTree, the visitor pattern, and AST Explorer

TypeScript source is turned into a tree of ESTree-compatible nodes by @typescript-eslint/typescript-estree, extended with TypeScript-specific node types such as TSInterfaceDeclaration, TSTypeAnnotation, or TSAsExpression. Every node carries a type field naming the node kind, plus child fields whose names differ by node type.

ESLint walks this tree depth first and calls the matching function for every node whose type or selector appears in the visitor object. A key prefixed with :exit, such as CallExpression:exit, fires when leaving the subtree instead of entering it, which matters for rules that need to know the full subtree before making a decision.

Before writing a rule, it pays off to paste the target snippet into AST Explorer with the @typescript-eslint/parser selected: the exact field names and values show up immediately instead of being guessed. That saves real time, especially for TypeScript-specific constructs like generic type parameters or satisfies expressions.


// AST excerpt for: throw "boom";
{
  "type": "ThrowStatement",
  "argument": {
    "type": "Literal",
    "value": "boom",
    "raw": "\"boom\""
  }
}

5. Typed linting: getting the TypeChecker

Some rules cannot be answered from syntax alone. Whether a thrown expression is really an Error instance, whether a function argument is guaranteed non-null, or whether two variables share compatible types, only the TypeScript compiler itself knows the answer.

@typescript-eslint/utils exposes ESLintUtils.getParserServices(context) for this, and newer ESLint versions expose the same service through context.sourceCode.parserServices. Both give access to parserServices.program, a full TypeScript program from which program.getTypeChecker() returns the real ts.TypeChecker.

For this service to exist at all, the flat config must set parserOptions.project or the newer parserOptions.projectService, so the parser knows which tsconfig.json to load. Without that setting, getParserServices throws a clear error at runtime instead of silently falling back to a syntax-only check.

To move from an ESTree node to a TypeScript node, use parserServices.esTreeNodeToTSNodeMap.get(node). Only that resulting ts.Node can be passed to checker.getTypeAtLocation() to retrieve the corresponding ts.Type.


import { ESLintUtils } from '@typescript-eslint/utils';
import type { TSESLint } from '@typescript-eslint/utils';

// Returns the real TypeScript TypeChecker for the current rule.
export function getTypeChecker(
  context: Readonly<TSESLint.RuleContext<string, unknown[]>>,
) {
  const parserServices = ESLintUtils.getParserServices(context);

  if (!parserServices.program) {
    throw new Error(
      'Type information is unavailable, set parserOptions.project in eslint.config.js.',
    );
  }

  return parserServices.program.getTypeChecker();
}

6. Example: only throwing Error instances

throw "a string" or throw { code: 1 } are valid JavaScript, but they break any catch (error) block that silently assumes error instanceof Error or error.message, and they discard the stack trace entirely. A typed rule can catch this reliably, a purely syntactic one cannot, because syntactically throw someVariable always looks the same regardless of the variable's actual type.

The rule visits every ThrowStatement, resolves the type of the thrown expression through the TypeChecker, and recursively checks whether that type, or every member of a union type, ultimately derives from Error. That means walking type.getSymbol() and the base types of its declaration until either Error turns up or the chain runs out.

For a union type like Error | string, checking only the first member is not enough, because at runtime the string branch could genuinely be thrown. The rule therefore calls type.isUnion() and requires every member of the union to satisfy the condition.


import { ESLintUtils } from '@typescript-eslint/utils';
import type { TSESTree } from '@typescript-eslint/utils';
import type { Type, TypeChecker } from 'typescript';

const createRule = ESLintUtils.RuleCreator(
  (name) => `https://internal-docs.example.com/eslint-rules/${name}`,
);

function isErrorLikeType(type: Type, checker: TypeChecker): boolean {
  if (type.isUnion()) {
    return type.types.every((part) => isErrorLikeType(part, checker));
  }
  const symbol = type.getSymbol() ?? type.aliasSymbol;
  if (!symbol) {
    return false;
  }
  if (symbol.getName() === 'Error') {
    return true;
  }
  const declarations = symbol.getDeclarations() ?? [];
  return declarations.some((declaration) => {
    const declaredType = checker.getTypeAtLocation(declaration);
    const baseTypes = declaredType.getBaseTypes() ?? [];
    return baseTypes.some((base) => base.getSymbol()?.getName() === 'Error');
  });
}

export const onlyThrowError = createRule({
  name: 'only-throw-error',
  meta: {
    type: 'problem',
    docs: { description: 'Disallows throwing values that are not Error instances.' },
    schema: [],
    messages: {
      onlyThrowError: 'Expected an Error instance to be thrown, not {{type}}.',
    },
  },
  defaultOptions: [],
  create(context) {
    const services = ESLintUtils.getParserServices(context);
    const checker = services.program.getTypeChecker();

    return {
      ThrowStatement(node: TSESTree.ThrowStatement) {
        const tsNode = services.esTreeNodeToTSNodeMap.get(node.argument);
        const type = checker.getTypeAtLocation(tsNode);

        if (!isErrorLikeType(type, checker)) {
          context.report({
            node: node.argument,
            messageId: 'onlyThrowError',
            data: { type: checker.typeToString(type) },
          });
        }
      },
    };
  },
});

7. Fixer functions for --fix

A fixer automatically turns a reported violation into correct code when eslint --fix runs. The fix function inside context.report() receives a fixer object with methods like replaceText, insertTextBefore, or remove, each returning a RuleFix describing a text range and its replacement.

A fixer should only return something when the correction is unambiguous and semantically safe. For throw "boom", wrapping the literal into new Error("boom") is safe, but for throw someUnknownExpression it is not, since it is unclear whether the expression already constructs an error object. In that case fix simply returns null and the rule stays without an autofix.

When an automatic correction would be too risky but guidance is still useful, suggest is the better tool instead of fix: the editor lists the suggestions individually for manual selection, while eslint --fix never applies them automatically.


context.report({
  node: node.argument,
  messageId: 'onlyThrowError',
  data: { type: checker.typeToString(type) },
  fix(fixer) {
    // Only wrap literals automatically, leave everything else untouched.
    if (
      node.argument.type === 'Literal' &&
      typeof node.argument.value === 'string'
    ) {
      const raw = context.sourceCode.getText(node.argument);
      return fixer.replaceText(node.argument, `new Error(${raw})`);
    }
    return null;
  },
});

8. Testing rules with RuleTester

For purely syntactic rules, the RuleTester built into eslint is enough. It accepts valid and invalid examples, checks the expected messageId values for invalid cases, and, when a fixer exists, can additionally verify the expected output after the fix runs.

Typed rules need real type information, meaning a loaded TypeScript program. @typescript-eslint/rule-tester provides an API-compatible extension of the standard RuleTester that additionally points languageOptions.parserOptions.project at a fixture tsconfig.json.

The fixture files referenced by that tsconfig.json must be syntactically valid TypeScript actually covered by the config, otherwise loading the program fails before the rule itself ever runs. It is worth maintaining a small, dedicated tsconfig.json for rule tests instead of reusing the project's main config, just to keep load times low.


import { RuleTester } from '@typescript-eslint/rule-tester';
import path from 'node:path';
import { onlyThrowError } from '../rules/only-throw-error';

const ruleTester = new RuleTester({
  languageOptions: {
    parserOptions: {
      project: path.join(__dirname, 'fixtures', 'tsconfig.json'),
      tsconfigRootDir: path.join(__dirname, 'fixtures'),
    },
  },
});

ruleTester.run('only-throw-error', onlyThrowError, {
  valid: [
    'throw new Error("boom");',
    'function fail(): never { throw new TypeError("nope"); }',
  ],
  invalid: [
    {
      code: 'throw "boom";',
      errors: [{ messageId: 'onlyThrowError' }],
      output: 'throw new Error("boom");',
    },
  ],
});

9. Wiring it up as a local plugin, and performance notes

In a flat config, a collection of custom rules can be registered as a plain plugin object with a rules map, no separate npm package required. In eslint.config.js that object is registered under a chosen namespace such as local, and individual rules are referenced through it.

If the rules need to be shared across multiple repositories, a dedicated npm package following the eslint-plugin-* naming convention is the better fit, versioned and with its own changelog.

TypeChecker access is noticeably more expensive than a pure AST check, because ESLint has to load a full TypeScript program and resolve types per file. In practice that means: run cheap syntactic pre-checks first and only consult the checker when needed, avoid running expensive rules against huge generated files, and prefer parserOptions.projectService over project in newer typescript-eslint versions, since the project service reuses programs across files instead of rebuilding them.

For measurement, TIMING=1 eslint . lists the most expensive rules for a given run, and the built-in --stats flag in newer ESLint versions breaks down parse and lint time per file.


// eslint-rules/index.js
module.exports = {
  rules: {
    'no-console-outside-logger': require('./no-console-outside-logger'),
    'only-throw-error': require('./only-throw-error'),
  },
};

// eslint.config.js
const tseslint = require('typescript-eslint');
const localPlugin = require('./eslint-rules');

module.exports = tseslint.config({
  files: ['src/**/*.ts'],
  languageOptions: {
    parserOptions: {
      projectService: true,
      tsconfigRootDir: __dirname,
    },
  },
  plugins: {
    local: localPlugin,
  },
  rules: {
    'local/no-console-outside-logger': ['error', { allow: ['/src/logging/'] }],
    'local/only-throw-error': 'error',
  },
});
Aspect Syntactic Linting Typed Linting
Data source Plain ESTree/AST AST plus TypeScript TypeChecker
Required config No parserOptions.project needed parserOptions.project or projectService required
Performance Fast, purely structural check per node More expensive, a TypeScript program must be loaded
Typical use case Banning console.log, enforcing naming conventions Detecting throws of non-Error objects
Behavior without type info Always works, even without a tsconfig.json Throws an error when no TypeScript program is available

Mironsoft

TypeScript migration, type safety, and team onboarding

A JavaScript codebase without type safety, but no time for a full migration?

We migrate existing JavaScript projects to TypeScript step by step, set up strict compiler settings cleanly, and bring teams to the same type-safety level with code reviews and style guides.

Migration Roadmap

Plan and execute a gradual JS-to-TS migration without big-bang risk.

Strict Mode Rollout

Set up tsconfig.json, ESLint rules, and CI checks for lasting type safety.

Team Onboarding

Bring developers up to speed on TypeScript best practices with workshops and reviews.

10. Summary

Custom ESLint Rules

Rule anatomy

meta describes the rule, create(context) returns the AST visitor

Two linting modes

Syntactic needs only the AST, typed needs the TypeChecker via parserServices

Testing

RuleTester, or @typescript-eslint/rule-tester for typed rules

Performance rule

TypeChecker access is expensive, filter syntactically first

11. FAQ: Custom ESLint Rules

1Do I need TypeChecker access for every custom ESLint rule?
No. Most project-specific conventions, such as forbidden imports, naming conventions, or banning specific function calls, can be checked purely from the AST structure. The TypeChecker only becomes necessary once the answer genuinely depends on an expression's type, like detecting non-Error objects being thrown.
2What is the difference between context.getFilename() and context.filename?
Both return the current file path, context.filename is the newer property form, context.getFilename() is the older method from earlier ESLint versions. New rules should prefer the property form as long as the supported minimum ESLint version already provides it, otherwise a fallback like the one shown in the example is a safe bet.
3Why does a typed rule need a tsconfig.json in its tests?
The TypeChecker can only work once a full TypeScript program has been loaded, and that program is built from a tsconfig.json together with its included files. Without that setting in parserOptions.project, @typescript-eslint/rule-tester finds no type information and the rule never gets a TypeChecker.
4Can a rule mix syntactic and typed checks?
Yes, a two-stage design is actually common: a cheap syntactic pre-check filters out most nodes quickly, and only the remaining candidates trigger an expensive call into the TypeChecker. That keeps the rule fast on average without giving up type awareness.
5What happens if I call context.report() without a fix?
The rule reports the violation normally in the editor, the terminal, and CI output, but eslint --fix leaves that spot untouched. That is entirely legitimate, many useful rules deliberately skip autofix because the correct fix requires human judgment.
6How do I figure out which selector syntax fits a given case?
The esquery selectors ESLint uses for visitor keys work much like CSS selectors applied to the AST: attribute values in square brackets, child relationships expressed through spaces or combinators. The fastest way to find the right syntax is opening the target snippet in AST Explorer, reading off the relevant field names, and assembling the selector from there.
7Does a custom rule have to be published as an npm package?
No, for a single repository a local directory with an index.js bundling all rules into a rules map, registered as a local plugin namespace in eslint.config.js, is enough. A dedicated npm package only becomes worthwhile once the rules need to be shared across multiple projects.
8How does parserOptions.projectService affect performance?
projectService, available in newer typescript-eslint versions, manages TypeScript programs internally through a language service that reuses programs across files instead of rebuilding one per file. That noticeably cuts load time compared to the older parserOptions.project approach, especially in large monorepos.
9Can a rule use more than one message ID?
Yes, meta.messages can hold any number of entries, and each context.report() call picks the matching messageId. That is common when a rule needs to emit different, precisely worded messages depending on the detected case instead of one generic error message.
10What is the difference between fix and suggest in a rule report?
fix gets applied automatically by eslint --fix and therefore must be absolutely safe. suggest instead offers several named options that only get applied manually through the editor, which fits situations with multiple plausible corrections or where an automatic change would be risky.