Eigene ESLint-Regeln für TypeScript-Projekte schreiben
AI generated
type
TypeScript
Eigene ESLint-Regeln für TypeScript-Codebasen
Von der syntaktischen Prüfung bis zum echten TypeChecker-Zugriff

Fertige Regelsets decken generische Probleme ab, aber jedes gewachsene Projekt hat eigene Konventionen, die sich nur durch eigene Regeln maschinell durchsetzen lassen. Dieser Artikel zeigt den Aufbau einer ESLint-Regel von der Grundstruktur über syntaktisches und typisiertes Linting bis zu Fixer-Funktionen, Tests mit dem RuleTester und der Einbindung in eine ESLint-9-Flat-Config.

12 Min. Lesezeit TypeScript ESLint 9 AST & TypeChecker

1. Warum überhaupt eigene ESLint-Regeln?

Fertige Regelsets wie eslint:recommended oder @typescript-eslint/recommended decken generische Probleme ab, aber jedes gewachsene Projekt hat eigene Konventionen: verbotene Imports zwischen Modulgrenzen, ein Pflicht-Wrapper um jeden Fetch-Aufruf oder eine feste Namenskonvention für Feature-Flags. Wiederkehrende Code-Review-Kommentare sind meist ein klares Signal dafür, dass eine eigene Regel diese Aufgabe übernehmen sollte.

Eine eigene Regel macht eine Konvention maschinell durchsetzbar statt nur dokumentiert. Sie läuft in der IDE während der Eingabe, in der CI-Pipeline beim Pull Request und beim lokalen --fix-Lauf, ohne dass im Review jemand manuell daran denken muss.

ESLint unterscheidet grundsätzlich zwischen rein syntaktischem Linting, das ausschließlich auf der AST-Struktur arbeitet, und typisiertem Linting, das über @typescript-eslint/utils Zugriff auf den echten TypeScript-Compiler bekommt. Dieser Artikel zeigt beide Varianten an konkreten Beispielen, inklusive Fixer-Funktion, Tests mit dem RuleTester und Einbindung in eine ESLint-9-Flat-Config.

2. Grundstruktur einer Regel: meta und create

Jede ESLint-Regel exportiert ein Objekt mit zwei Hauptbestandteilen: meta beschreibt die Regel für Tooling und Dokumentation, create(context) liefert den eigentlichen Visitor zurück. Bei @typescript-eslint/utils nutzt man dafür ESLintUtils.RuleCreator, das zusätzlich eine URL-Vorlage für die Dokumentation erzwingt und die Typen für context korrekt inferiert.

meta.type ist entweder problem, suggestion oder layout und beeinflusst unter anderem, wie Editoren die Meldung einordnen. meta.schema definiert die erlaubten Optionen als JSON-Schema, sodass falsch konfigurierte Optionen schon beim Start von ESLint auffallen. meta.messages bildet Message-IDs auf Texte ab, sodass im Code nur die ID referenziert wird und Texte zentral gepflegt werden können.

create(context) gibt ein Objekt zurück, dessen Schlüssel entweder AST-Knotentypen wie CallExpression oder esquery-Selektoren sind. Für jeden passenden Knoten ruft ESLint die zugehörige Funktion während des Baum-Durchlaufs auf. Innerhalb dieser Funktion prüft man Bedingungen am Knoten und ruft bei Verstoß context.report() auf.


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

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

// Verbietet direkten Zugriff auf process.env ausserhalb des Config-Moduls.
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. Beispiel: console.log außerhalb erlaubter Dateien verbieten

Ein klassischer Kandidat für eine syntaktische Regel ist das Verbot von console.log im Produktionscode, mit einer Ausnahme für ein dediziertes Logging-Modul. Die Regel braucht dafür kein Typwissen, sie prüft nur die Struktur des Aufrufs und den Dateinamen.

Über context.options[0] liest die Regel eine Liste erlaubter Dateimuster als reguläre Ausdrücke. Passt der aktuelle Dateiname auf eines der Muster, gibt create einfach ein leeres Visitor-Objekt zurück, dann prüft ESLint gar keine Knoten in dieser Datei. Andernfalls sucht der Selektor CallExpression[callee.object.name="console"] gezielt nach Aufrufen, deren Objektteil console heißt, unabhängig davon ob es sich um log, debug oder warn handelt.

Der Selektor-Ansatz ist oft lesbarer als verschachtelte if-Abfragen im Handler und deckt automatisch alle passenden Knotentypen ab, ohne dass man MemberExpression und CallExpression separat behandeln muss.


// 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-Grundlagen: ESTree, Visitor-Pattern und AST Explorer

TypeScript-Quelltext wird über @typescript-eslint/typescript-estree in einen Baum aus ESTree-kompatiblen Knoten überführt, ergänzt um TypeScript-spezifische Knotentypen wie TSInterfaceDeclaration, TSTypeAnnotation oder TSAsExpression. Jeder Knoten hat ein type-Feld, das den Knotentyp benennt, sowie Kindfelder, die je nach Typ unterschiedlich heißen.

ESLint durchläuft diesen Baum per Tiefensuche und ruft für jeden Knoten, dessen Typ oder Selektor im Visitor-Objekt vorkommt, die passende Funktion auf. Wird ein Schlüssel mit vorangestelltem :exit angegeben, etwa CallExpression:exit, feuert die Funktion beim Verlassen des Teilbaums statt beim Betreten, was für Regeln nützlich ist, die erst den kompletten Unterbaum kennen müssen.

Bevor man eine Regel schreibt, lohnt sich ein Blick in AST Explorer mit dem @typescript-eslint/parser: Man fügt den Beispielcode ein und sieht sofort die exakten Feldnamen und Werte, statt sie zu erraten. Gerade bei TypeScript-spezifischen Konstrukten wie generischen Typparametern oder satisfies-Ausdrücken spart das viel Zeit.


// Ausschnitt der AST für: throw "boom";
{
  "type": "ThrowStatement",
  "argument": {
    "type": "Literal",
    "value": "boom",
    "raw": "\"boom\""
  }
}

5. Typisiertes Linting: Zugriff auf den TypeChecker

Manche Regeln lassen sich nicht allein aus der Syntax beantworten. Ob ein geworfener Ausdruck tatsächlich ein Error-Objekt ist, ob ein Funktionsargument garantiert nicht null sein kann oder ob zwei Variablen kompatible Typen haben, das weiß nur der TypeScript-Compiler selbst.

@typescript-eslint/utils stellt dafür ESLintUtils.getParserServices(context) bereit, alternativ liefert neueres ESLint den gleichen Dienst über context.sourceCode.parserServices. Beide geben Zugriff auf parserServices.program, ein vollständiges TypeScript-Programm, aus dem sich per program.getTypeChecker() der echte ts.TypeChecker holen lässt.

Damit dieser Dienst überhaupt existiert, muss die Flat Config parserOptions.project oder das neuere parserOptions.projectService setzen, damit der Parser weiß, welches tsconfig.json geladen werden soll. Fehlt diese Angabe, wirft getParserServices zur Laufzeit eine klare Fehlermeldung, statt stillschweigend nur die AST-Prüfung durchzuführen.

Um vom ESTree-Knoten zum TypeScript-Knoten zu wechseln, dient parserServices.esTreeNodeToTSNodeMap.get(node). Erst dieser ts.Node lässt sich an checker.getTypeAtLocation() übergeben, um den zugehörigen ts.Type zu bekommen.


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

// Liefert den echten TypeScript-TypeChecker für die aktuelle Regel.
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. Beispiel: Nur Error-Objekte werfen dürfen

throw "ein String" oder throw { code: 1 } sind gültiges JavaScript, brechen aber jeden catch (error)-Block, der stillschweigend error instanceof Error oder error.message annimmt, und verlieren zudem den Stacktrace. Eine typisierte Regel kann das zuverlässig erkennen, eine rein syntaktische nicht, denn syntaktisch sieht throw irgendeineVariable immer gleich aus, egal welchen Typ die Variable hat.

Die Regel besucht jede ThrowStatement, ermittelt über den TypeChecker den Typ des geworfenen Ausdrucks und prüft rekursiv, ob dieser Typ, oder bei Union-Typen jeder Bestandteil, letztlich von Error abstammt. Dazu läuft man über type.getSymbol() und die Basistypen der zugehörigen Deklaration, bis entweder Error gefunden wird oder die Kette endet.

Bei Union-Typen wie Error | string reicht es nicht, nur den ersten Teiltyp zu prüfen, denn zur Laufzeit könnte tatsächlich der string-Zweig geworfen werden. Die Regel muss deshalb type.isUnion() abfragen und verlangen, dass wirklich jeder Bestandteil des Union-Typs die Bedingung erfüllt.


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-Funktionen für --fix

Ein Fixer wandelt eine gemeldete Regelverletzung automatisch in korrekten Code um, wenn eslint --fix läuft. Dazu bekommt die fix-Funktion im context.report()-Aufruf ein fixer-Objekt mit Methoden wie replaceText, insertTextBefore oder remove, die einen RuleFix mit Textbereich und Ersatztext zurückgeben.

Ein Fixer sollte nur dann etwas zurückgeben, wenn die Korrektur eindeutig und semantisch sicher ist. Für throw "boom" lässt sich das Literal gefahrlos in new Error("boom") einwickeln, für throw someUnknownExpression dagegen nicht, weil unklar ist, ob der Ausdruck bereits ein Fehlerobjekt konstruiert. In solchen Fällen liefert fix einfach null zurück, und die Regel bleibt ohne Autofix.

Wenn eine automatische Korrektur zu riskant ist, aber trotzdem eine Hilfestellung sinnvoll wäre, bietet sich suggest statt fix an: Die IDE zeigt die Vorschläge einzeln zur manuellen Auswahl an, eslint --fix wendet sie aber nicht automatisch an.


context.report({
  node: node.argument,
  messageId: 'onlyThrowError',
  data: { type: checker.typeToString(type) },
  fix(fixer) {
    // Nur Literale sicher automatisch einwickeln, alles andere unangetastet lassen.
    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. Regeln testen mit RuleTester

Für rein syntaktische Regeln reicht der in eslint eingebaute RuleTester. Er nimmt valid- und invalid-Beispiele entgegen, prüft bei invalid-Fällen die erwarteten messageId-Werte und kann bei vorhandenem Fixer zusätzlich das erwartete output nach der Korrektur verifizieren.

Für typisierte Regeln braucht der Tester echte Typinformationen, also ein geladenes TypeScript-Programm. Dafür gibt es @typescript-eslint/rule-tester, eine API-kompatible Erweiterung des Standard-RuleTesters, die zusätzlich languageOptions.parserOptions.project auf eine Fixture-tsconfig.json zeigen lässt.

Wichtig ist, dass die Testdateien im Fixture-Verzeichnis syntaktisch gültiges, vom angegebenen tsconfig.json erfasstes TypeScript sind, sonst schlägt schon das Laden des Programms fehl, bevor die eigentliche Regel überhaupt läuft. Es lohnt sich, für Regel-Tests ein minimales, eigenes tsconfig.json zu pflegen statt das Haupt-Tsconfig des Projekts wiederzuverwenden, um Ladezeiten klein zu halten.


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. Einbindung als lokales Plugin und Performance-Hinweise

In einer Flat Config lässt sich eine Sammlung eigener Regeln als einfaches Plugin-Objekt mit einer rules-Map einbinden, ganz ohne separates npm-Paket. In eslint.config.js wird dieses Objekt unter einem frei wählbaren Namensraum wie local registriert und die einzelnen Regeln darüber referenziert.

Sollen die Regeln über mehrere Repositories hinweg wiederverwendet werden, lohnt sich stattdessen ein eigenes npm-Paket nach dem eslint-plugin-*-Namensschema, versioniert und mit eigenem Änderungsprotokoll.

TypeChecker-Zugriffe sind spürbar teurer als reine AST-Prüfungen, weil ESLint dafür ein vollständiges TypeScript-Programm laden und pro Datei Typen auflösen muss. Praktisch bedeutet das: syntaktische Vorprüfungen zuerst durchführen und nur bei Bedarf den Checker befragen, teure Regeln nicht auf riesige generierte Dateien anwenden, und in neueren typescript-eslint-Versionen parserOptions.projectService statt project nutzen, weil der Projekt-Service Programme über mehrere Dateien hinweg wiederverwendet statt sie neu zu erzeugen.

Zur Messung eignet sich TIMING=1 eslint ., das die teuersten Regeln pro Lauf auflistet, sowie das eingebaute --stats-Flag neuerer ESLint-Versionen, das unter anderem Parse- und Lint-Zeiten pro Datei aufschlüsselt.


// 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',
  },
});
Merkmal Syntaktisches Linting Typisiertes Linting
Datenquelle Reiner ESTree/AST AST plus TypeScript-TypeChecker
Benötigte Config Kein parserOptions.project nötig parserOptions.project bzw. projectService erforderlich
Performance Schnell, rein strukturelle Prüfung pro Knoten Teurer, TypeScript-Programm muss geladen werden
Beispiel-Anwendungsfall console.log verbieten, Namenskonventionen erzwingen Werfen von Nicht-Error-Objekten erkennen
Verhalten ohne Typinfo Funktioniert immer, auch ohne tsconfig.json Wirft einen Fehler, wenn kein TypeScript-Programm verfügbar ist

Mironsoft

TypeScript-Migration, Typsicherheit und Team-Onboarding

JavaScript-Codebasis ohne Typsicherheit, aber keine Zeit für eine Rundum-Migration?

Wir migrieren bestehende JavaScript-Projekte schrittweise zu TypeScript, richten strikte Compiler-Einstellungen sauber ein und bringen Teams mit Code-Reviews und Style-Guides auf denselben Typsicherheits-Stand.

Migrations-Fahrplan

Schrittweise JS-zu-TS-Migration ohne Big-Bang-Risiko planen und umsetzen.

Strict-Mode-Einführung

tsconfig.json, ESLint-Regeln und CI-Checks für dauerhafte Typsicherheit aufsetzen.

Team-Onboarding

Entwickler mit Workshops und Code-Reviews in TypeScript-Best-Practices einarbeiten.

10. Zusammenfassung

Custom ESLint-Regeln

Regel-Kern

meta beschreibt die Regel, create(context) liefert den AST-Visitor zurück

Zwei Linting-Arten

Syntaktisch reicht der AST, typisiert braucht den TypeChecker via parserServices

Testing

RuleTester bzw. @typescript-eslint/rule-tester für typisierte Regeln

Performance-Regel

TypeChecker-Zugriffe sind teuer, erst syntaktisch vorfiltern

11. FAQ: Custom ESLint-Regeln

1Brauche ich für jede eigene ESLint-Regel Zugriff auf den TypeChecker?
Nein. Die meisten projektspezifischen Konventionen, etwa verbotene Imports, Namenskonventionen oder das Verbot bestimmter Funktionsaufrufe, lassen sich rein syntaktisch über die AST-Struktur prüfen. Der TypeChecker wird erst nötig, wenn die Antwort tatsächlich vom Typ eines Ausdrucks abhängt, wie beim Erkennen von Nicht-Error-Objekten beim throw.
2Was ist der Unterschied zwischen context.getFilename() und context.filename?
Beides liefert den aktuellen Dateipfad, context.filename ist die neuere Property-Schreibweise, context.getFilename() die ältere Methode aus früheren ESLint-Versionen. Neue Regeln sollten die Property-Variante nutzen, sofern die unterstützte ESLint-Mindestversion sie bereits kennt, ansonsten empfiehlt sich ein Fallback wie im Beispielcode gezeigt.
3Warum benötigt eine typisierte Regel eine tsconfig.json in den Tests?
Der TypeChecker kann nur arbeiten, wenn ein vollständiges TypeScript-Programm geladen wurde, und dieses Programm entsteht aus einer tsconfig.json mit den einbezogenen Dateien. Ohne diese Angabe in parserOptions.project findet @typescript-eslint/rule-tester keine Typinformationen, und die Regel bekommt keinen TypeChecker.
4Kann ich eine Regel schreiben, die sowohl syntaktisch als auch typisiert prüft?
Ja, üblich ist sogar ein zweistufiger Aufbau: Erst eine günstige syntaktische Vorprüfung, die die meisten Knoten schnell aussortiert, und nur für die verbleibenden Kandidaten ein teurer Aufruf an den TypeChecker. Das hält die Regel im Durchschnitt schnell, ohne auf Typwissen verzichten zu müssen.
5Was passiert, wenn ich context.report() ohne fix aufrufe?
Die Regel meldet den Verstoß ganz normal in der IDE, im Terminal und in der CI-Ausgabe, aber eslint --fix ändert nichts an dieser Stelle. Das ist völlig legitim, viele sinnvolle Regeln haben bewusst keinen Autofix, weil die richtige Korrektur menschliches Urteilsvermögen braucht.
6Wie finde ich heraus, welche Selektor-Syntax für einen bestimmten Fall passt?
esquery-Selektoren, wie ESLint sie für Visitor-Schlüssel nutzt, funktionieren ähnlich wie CSS-Selektoren auf dem AST: Attributwerte in eckigen Klammern, Kindbeziehungen über Leerzeichen oder Kombinatoren. Am schnellsten findet man die richtige Syntax, indem man den Beispielcode in AST Explorer öffnet, die relevanten Feldnamen abliest und daraus den Selektor zusammensetzt.
7Muss eine eigene Regel als npm-Paket veröffentlicht werden?
Nein, für ein einzelnes Repository reicht ein lokales Verzeichnis mit einer index.js, die alle Regeln in einer rules-Map bündelt, und die Einbindung als lokaler Plugin-Namensraum in eslint.config.js. Ein eigenes npm-Paket lohnt sich erst, wenn die Regeln über mehrere Projekte hinweg geteilt werden sollen.
8Wie wirkt sich parserOptions.projectService auf die Performance aus?
projectService, verfügbar in neueren Versionen von typescript-eslint, verwaltet TypeScript-Programme intern über einen Language-Service, der Programme zwischen Dateien wiederverwendet statt sie bei jeder Datei neu aufzubauen. Das reduziert die Ladezeit spürbar gegenüber der älteren parserOptions.project-Variante, besonders in großen Monorepos.
9Kann eine Regel mehrere Message-IDs gleichzeitig nutzen?
Ja, meta.messages kann beliebig viele Einträge enthalten, und context.report() wählt pro Aufruf die passende messageId aus. Das ist üblich, wenn eine Regel je nach erkanntem Fall unterschiedliche, präzise formulierte Meldungen ausgeben soll, statt einer einzigen generischen Fehlermeldung.
10Was ist der Unterschied zwischen fix und suggest im Regel-Report?
fix wird automatisch von eslint --fix angewendet und muss deshalb absolut sicher sein. suggest liefert stattdessen mehrere benannte Vorschläge, die nur manuell in der IDE ausgewählt werden, etwa wenn mehrere plausible Korrekturen existieren oder eine automatische Anwendung riskant wäre.