|
| 1 | +--- |
| 2 | +id: custom-rules |
| 3 | +sidebar_label: Custom Rules |
| 4 | +title: Custom Rules |
| 5 | +--- |
| 6 | + |
| 7 | +:::important |
| 8 | +You should be familiar with [ESLint's developer guide](https://eslint.org/docs/developer-guide) and [Development > Architecture](./architecture/asts) before writing custom rules. |
| 9 | +::: |
| 10 | + |
| 11 | +As long as you are using `@typescript-eslint/parser` as the `parser` in your ESLint configuration, custom ESLint rules generally work the same way for JavaScript and TypeScript code. |
| 12 | +The main two changes to custom rules writing are: |
| 13 | + |
| 14 | +- [AST Extensions](#ast-extensions): targeting TypeScript-specific syntax in your rule selectors |
| 15 | +- [Typed Rules](#typed-rules): using the TypeScript type checker to inform rule logic |
| 16 | + |
| 17 | +## AST Extensions |
| 18 | + |
| 19 | +`@typescript-eslint/estree` creates AST nodes for TypeScript syntax with names that begin with `TS`, such as `TSInterfaceDeclaration` and `TSTypeAnnotation`. |
| 20 | +These nodes are treated just like any other AST node. |
| 21 | +You can query for them in your rule selectors. |
| 22 | + |
| 23 | +This rule written in JavaScript bans interfaces that start with a lower-case letter: |
| 24 | + |
| 25 | +```js |
| 26 | +export const rule = { |
| 27 | + create(context) { |
| 28 | + return { |
| 29 | + TSInterfaceDeclaration(node) { |
| 30 | + if (/[a-z]/.test(node.id.name[0])) { |
| 31 | + context.report({ |
| 32 | + messageId: 'uppercase', |
| 33 | + node: node.id, |
| 34 | + }); |
| 35 | + } |
| 36 | + }, |
| 37 | + }; |
| 38 | + }, |
| 39 | + meta: { |
| 40 | + docs: { |
| 41 | + category: 'Best Practices', |
| 42 | + description: 'Interface names should start with an upper-case letter.', |
| 43 | + }, |
| 44 | + messages: { |
| 45 | + uppercase: 'Start this name with an upper-case letter.', |
| 46 | + }, |
| 47 | + type: 'suggestion', |
| 48 | + schema: [], |
| 49 | + }, |
| 50 | +}; |
| 51 | +``` |
| 52 | + |
| 53 | +### Writing Rules in TypeScript |
| 54 | + |
| 55 | +The `@typescript-eslint/experimental-utils` package acts as a replacement package for `eslint` that exports all the same objects and types, but with typescript-eslint support. |
| 56 | + |
| 57 | +:::caution |
| 58 | +`@types/eslint` types are based on `@types/estree` and do not recognize typescript-eslint nodes and properties. |
| 59 | +You should generally not need to import from `eslint` when writing custom typescript-eslint rules in TypeScript. |
| 60 | +::: |
| 61 | + |
| 62 | +#### Rule Types |
| 63 | + |
| 64 | +`@typescript-eslint/experimental-utils` exports a `RuleModule` interface that allows specifying generics for: |
| 65 | + |
| 66 | +- `MessageIds`: a union of string literal message IDs that may be reported |
| 67 | +- `Options`: what options users may configure for the rule |
| 68 | + |
| 69 | +```ts |
| 70 | +import { TSESLint } from '@typescript-eslint/experimental-utils'; |
| 71 | + |
| 72 | +export const rule: TSESLint.RuleModule<'uppercase', []> = { |
| 73 | + create(context /* : Readonly<RuleContext<TMessageIds, TOptions>> */) { |
| 74 | + // ... |
| 75 | + }, |
| 76 | +}; |
| 77 | +``` |
| 78 | + |
| 79 | +For groups of rules that share a common documentation URL, a `RuleCreator` function is exported. |
| 80 | +It takes in a function that transforms a rule name into its documentation URL, then returns a function that takes in a rule module object. |
| 81 | +The returned function is able to infer message IDs from `meta.messages`. |
| 82 | + |
| 83 | +```ts |
| 84 | +import { ESLintUtils } from '@typescript-eslint/experimental-utils'; |
| 85 | + |
| 86 | +const createRule = ESLintUtils.RuleCreator( |
| 87 | + name => `https://example.com/rule/${name}`, |
| 88 | +); |
| 89 | + |
| 90 | +// Type: const rule: RuleModule<"uppercase", ...> |
| 91 | +export const rule = createRule({ |
| 92 | + create(context) { |
| 93 | + // ... |
| 94 | + }, |
| 95 | + meta: { |
| 96 | + messages: { |
| 97 | + uppercase: 'Start this name with an upper-case letter.', |
| 98 | + }, |
| 99 | + // ... |
| 100 | + }, |
| 101 | +}); |
| 102 | +``` |
| 103 | + |
| 104 | +#### Node Types |
| 105 | + |
| 106 | +TypeScript types for nodes exist in a `TSESTree` namespace exported by `@typescript-eslint/experimental-utils`. |
| 107 | +The above rule body could be better written in TypeScript with a type annotation on the `node`: |
| 108 | + |
| 109 | +```ts |
| 110 | +import { TSESLint, TSESTree } from '@typescript-eslint/experimental-utils'; |
| 111 | + |
| 112 | +// ... |
| 113 | + |
| 114 | +export const rule = createRule({ |
| 115 | + create(context) { |
| 116 | + return { |
| 117 | + TSInterfaceDeclaration(node: TSESTree.TSInterfaceDeclaration) { |
| 118 | + // ... |
| 119 | + }, |
| 120 | + }; |
| 121 | + }, |
| 122 | + // ... |
| 123 | +}); |
| 124 | +``` |
| 125 | + |
| 126 | +An `AST_NODE_TYPES` enum is exported as well to hold the values for AST node `type` properties. |
| 127 | +`TSESTree.Node` is available as union type that uses its `type` member as a discriminant. |
| 128 | + |
| 129 | +For example, checking `node.type` can narrow down the type of the `node`: |
| 130 | + |
| 131 | +```ts |
| 132 | +import { |
| 133 | + AST_NODE_TYPES, |
| 134 | + TSESTree, |
| 135 | +} from '@typescript-eslint/experimental-utils'; |
| 136 | + |
| 137 | +export function describeNode(node: TSESTree.Node): string { |
| 138 | + switch (node.type) { |
| 139 | + case AST_NODE_TYPES.ArrayExpression: |
| 140 | + return `Array containing ${node.elements.map(describeNode).join(', ')}`; |
| 141 | + |
| 142 | + case AST_NODE_TYPES.Literal: |
| 143 | + return `Literal value ${node.raw}`; |
| 144 | + |
| 145 | + default: |
| 146 | + return '🤷'; |
| 147 | + } |
| 148 | +} |
| 149 | +``` |
| 150 | + |
| 151 | +## Type Checking |
| 152 | + |
| 153 | +:::tip |
| 154 | +Read TypeScript's [Compiler APIs > Using the Type Checker](https://github.com/microsoft/TypeScript/wiki/Using-the-Compiler-API#using-the-type-checker) section for how to use a program's type checker. |
| 155 | +::: |
| 156 | + |
| 157 | +The biggest addition typescript-eslint brings to ESLint rules is the ability to use TypeScript's type checker APIs. |
| 158 | + |
| 159 | +`@typescript-eslint/experimental-utils` exports an `ESLintUtils` namespace containing a `getParserServices` function that takes in an ESLint context and returns a `parserServices` object. |
| 160 | + |
| 161 | +That `parserServices` object contains: |
| 162 | + |
| 163 | +- `program`: A full TypeScript `ts.Program` object |
| 164 | +- `esTreeNodeToTSNodeMap`: Map of `@typescript-eslint/estree` `TSESTree.Node` nodes to their TypeScript `ts.Node` equivalents |
| 165 | +- `tsNodeToESTreeNodeMap`: Map of TypeScript `ts.Node` nodes to their `@typescript-eslint/estree` `TSESTree.Node` equivalents |
| 166 | + |
| 167 | +By mapping from ESTree nodes to TypeScript nodes and retrieving the TypeScript program from the parser services, rules are able to ask TypeScript for full type information on those nodes. |
| 168 | + |
| 169 | +This rule bans for-of looping over an enum by using the type-checker via typescript-eslint and TypeScript APIs: |
| 170 | + |
| 171 | +```ts |
| 172 | +import { ESLintUtils } from '@typescript-eslint/experimental-utils'; |
| 173 | +import * as ts from 'typescript'; |
| 174 | +import * as tsutils from 'tsutils'; |
| 175 | + |
| 176 | +export const rule: eslint.Rule.RuleModule = { |
| 177 | + create(context) { |
| 178 | + return { |
| 179 | + ForOfStatement(node) { |
| 180 | + // 1. Grab the TypeScript program from parser services |
| 181 | + const parserServices = ESLintUtils.getParserServices(context); |
| 182 | + const checker = parserServices.program.getTypeChecker(); |
| 183 | + |
| 184 | + // 2. Find the backing TS node for the ES node, then that TS type |
| 185 | + const originalNode = parserServices.esTreeNodeToTSNodeMap.get( |
| 186 | + node.right, |
| 187 | + ); |
| 188 | + const nodeType = checker.getTypeAtLocation(node); |
| 189 | + |
| 190 | + // 3. Check the TS node type using the TypeScript APIs |
| 191 | + if (tsutils.isTypeFlagSet(nodeType, ts.TypeFlags.EnumLike)) { |
| 192 | + context.report({ |
| 193 | + messageId: 'loopOverEnum', |
| 194 | + node: node.right, |
| 195 | + }); |
| 196 | + } |
| 197 | + }, |
| 198 | + }; |
| 199 | + }, |
| 200 | + meta: { |
| 201 | + docs: { |
| 202 | + category: 'Best Practices', |
| 203 | + description: 'Avoid looping over enums.', |
| 204 | + }, |
| 205 | + messages: { |
| 206 | + loopOverEnum: 'Do not loop over enums.', |
| 207 | + }, |
| 208 | + type: 'suggestion', |
| 209 | + schema: [], |
| 210 | + }, |
| 211 | +}; |
| 212 | +``` |
| 213 | + |
| 214 | +## Testing |
| 215 | + |
| 216 | +`@typescript-eslint/experimental-utils` exports a `RuleTester` with a similar API to the built-in [ESLint `RuleTester`](https://eslint.org/docs/developer-guide/nodejs-api#ruletester). |
| 217 | +It should be provided with the same `parser` and `parserOptions` you would use in your ESLint configuration. |
| 218 | + |
| 219 | +### Testing Untyped Rules |
| 220 | + |
| 221 | +For rules that don't need type information, passing just the `parser` will do: |
| 222 | + |
| 223 | +```ts |
| 224 | +import { ESLintUtils } from '@typescript-eslint/experimental-utils'; |
| 225 | +import rule from './my-rule'; |
| 226 | + |
| 227 | +const ruleTester = new ESLintUtils.RuleTester({ |
| 228 | + parser: '@typescript-eslint/parser', |
| 229 | +}); |
| 230 | + |
| 231 | +ruleTester.run('my-rule', rule { |
| 232 | + valid: [/* ... */], |
| 233 | + invalid: [/* ... */], |
| 234 | +}); |
| 235 | +``` |
| 236 | + |
| 237 | +### Testing Typed Rules |
| 238 | + |
| 239 | +For rules that do need type information, `parserOptions` must be passed in as well. |
| 240 | +Tests must have at least an absolute `tsconfigRootDir` path provided as well as a relative `project` path from that directory: |
| 241 | + |
| 242 | +```ts |
| 243 | +import { ESLintUtils } from '@typescript-eslint/experimental-utils'; |
| 244 | +import rule from './my-typed-rule'; |
| 245 | + |
| 246 | +const ruleTester = new ESLintUtils.RuleTester({ |
| 247 | + parser: '@typescript-eslint/parser', |
| 248 | + parserOptions: { |
| 249 | + project: './tsconfig.json', |
| 250 | + tsconfigRootDir: __dirname, |
| 251 | + } |
| 252 | +}); |
| 253 | + |
| 254 | +ruleTester.run('my-typed-rule', rule { |
| 255 | + valid: [/* ... */], |
| 256 | + invalid: [/* ... */], |
| 257 | +}); |
| 258 | +``` |
| 259 | + |
| 260 | +:::note |
| 261 | +For now, `ESLintUtils.RuleTester` requires the following physical files be present on disk for typed rules: |
| 262 | + |
| 263 | +- `tsconfig.json`: tsconfig used as the test "project" |
| 264 | +- One of the following two files: |
| 265 | + - `file.ts`: blank test file used for normal TS tests |
| 266 | + - `file.tsx`: blank test file used for tests with `parserOptions: { ecmaFeatures: { jsx: true } }` |
| 267 | + |
| 268 | +::: |
0 commit comments