Custom Rules
This page describes how to write your own custom ESLint rules using typescript-eslint.You should be familiar withESLint's developer guide andASTs before writing custom rules.
As long as you are using@typescript-eslint/parser
as theparser
in your ESLint configuration, custom ESLint rules generally work the same way for JavaScript and TypeScript code.The main four changes to custom rules writing are:
- Utils Package: we recommend using
@typescript-eslint/utils
to create custom rules - AST Extensions: targeting TypeScript-specific syntax in your rule selectors
- Typed Rules: using the TypeScript type checker to inform rule logic
- Testing: using
@typescript-eslint/rule-tester
'sRuleTester
instead of ESLint core's
Utils Package
The@typescript-eslint/utils
package acts as a replacement package foreslint
that exports all the same objects and types, but with typescript-eslint support.It also exports common utility functions and constants most custom typescript-eslint rules tend to use.
@types/eslint
types are based on@types/estree
and do not recognize typescript-eslint nodes and properties.You should generally not need to import fromeslint
when writing custom typescript-eslint rules in TypeScript.
RuleCreator
The recommended way to create custom ESLint rules that make use of typescript-eslint features and/or syntax is with theESLintUtils.RuleCreator
function exported by@typescript-eslint/utils
.
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.RuleCreator
will infer the allowed message IDs the rule is allowed to emit from the providedmeta.messages
object.
This rule bans function declarations that start with a lower-case letter:
import{ ESLintUtils}from'@typescript-eslint/utils';
const createRule= ESLintUtils.RuleCreator(
name=>`https://example.com/rule/${name}`,
);
// Type: RuleModule<"uppercase", ...>
exportconst rule=createRule({
create(context){
return{
FunctionDeclaration(node){
if(node.id!=null){
if(/^[a-z]/.test(node.id.name)){
context.report({
messageId:'uppercase',
node: node.id,
});
}
}
},
};
},
name:'uppercase-first-declarations',
meta:{
docs:{
description:
'Function declaration names should start with an upper-case letter.',
},
messages:{
uppercase:'Start this name with an upper-case letter.',
},
type:'suggestion',
schema:[],
},
defaultOptions:[],
});
RuleCreator
rule creator functions return rules typed as theRuleModule
interface exported by@typescript-eslint/utils
.It allows specifying generics for:
MessageIds
: a union of string literal message IDs that may be reportedOptions
: what options users may configure for the rule (by default,[]
)
If the rule is able to take in rule options, declare them as a tuple type containing a single object of rule options:
import{ ESLintUtils}from'@typescript-eslint/utils';
typeMessageIds='lowercase'|'uppercase';
typeOptions=[
{
preferredCase?:'lower'|'upper';
},
];
// Type: RuleModule<MessageIds, Options, ...>
exportconst rule=createRule<Options, MessageIds>({
// ...
});
Extra Rule Docs Types
By default, rulemeta.docs
is allowed to contain onlydescription
andurl
as described inESLint's Custom Rules > Rule Structure docs.Additional docs properties may be added as a type argument toESLintUtils.RuleCreator
:
interfaceMyPluginDocs{
recommended:boolean;
}
const createRule= ESLintUtils.RuleCreator<MyPluginDocs>(
name=>`https://example.com/rule/${name}`,
);
createRule({
// ...
meta:{
docs:{
description:'...',
recommended:true,
},
// ...
},
});
Undocumented Rules
Although it is generally not recommended to create custom rules without documentation, if you are sure you want to do this you can use theESLintUtils.RuleCreator.withoutDocs
function to directly create a rule.It applies the same type inference as thecreateRule
s above without enforcing a documentation URL.
import{ ESLintUtils}from'@typescript-eslint/utils';
exportconst rule= ESLintUtils.RuleCreator.withoutDocs({
create(context){
// ...
},
meta:{
// ...
},
});
We recommend any custom ESLint rule include a descriptive error message and link to informative documentation.
Handling rule options
ESLint rules can take options. When handling options, you will need to add information in at most three places:
- The
Options
generic type argument toRuleCreator
, where you declare the type of the options - The
meta.schema
property, where you add a JSON schema describing the options shape - The
defaultOptions
property, where you add the default options value
typeMessageIds='lowercase'|'uppercase';
typeOptions=[
{
preferredCase:'lower'|'upper';
},
];
exportconst rule=createRule<Options, MessageIds>({
meta:{
// ...
schema:[
{
type:'object',
properties:{
preferredCase:{
type:'string',
enum:['lower','upper'],
},
},
additionalProperties:false,
},
],
},
defaultOptions:[
{
preferredCase:'lower',
},
],
create(context, options){
if(options[0].preferredCase==='lower'){
// ...
}
},
});
When reading the options, use the second parameter of thecreate
function, notcontext.options
from the first parameter. The first is created by ESLint and does not have the default options applied.
AST Extensions
@typescript-eslint/estree
creates AST nodes for TypeScript syntax with names that begin withTS
, such asTSInterfaceDeclaration
andTSTypeAnnotation
.These nodes are treated just like any other AST node.You can query for them in your rule selectors.
This version of the above rule instead bans interface declaration names that start with a lower-case letter:
import{ ESLintUtils}from'@typescript-eslint/utils';
exportconst rule=createRule({
create(context){
return{
TSInterfaceDeclaration(node){
if(/^[a-z]/.test(node.id.name)){
// ...
}
},
};
},
// ...
});
Node Types
TypeScript types for nodes exist in aTSESTree
namespace exported by@typescript-eslint/utils
.The above rule body could be better written in TypeScript with a type annotation on thenode
:
AnAST_NODE_TYPES
enum is exported as well to hold the values for AST nodetype
properties.TSESTree.Node
is available as union type that uses itstype
member as a discriminant.
For example, checkingnode.type
can narrow down the type of thenode
:
import{AST_NODE_TYPES, TSESTree}from'@typescript-eslint/utils';
exportfunctiondescribeNode(node: TSESTree.Node):string{
switch(node.type){
caseAST_NODE_TYPES.ArrayExpression:
return`Array containing${node.elements.map(describeNode).join(', ')}`;
caseAST_NODE_TYPES.Literal:
return`Literal value${node.raw}`;
default:
return'🤷';
}
}
Explicit Node Types
Rule queries that use more features ofesquery such as targeting multiple node types may not be able to infer the type of thenode
.In that case, it is best to add an explicit type declaration.
This rule snippet targets name nodes of both function and interface declarations:
import{ TSESTree}from'@typescript-eslint/utils';
exportconst rule=createRule({
create(context){
return{
'FunctionDeclaration, TSInterfaceDeclaration'(
node: TSESTree.FunctionDeclaration| TSESTree.TSInterfaceDeclaration,
){
if(/^[a-z]/.test(node.id.name)){
// ...
}
},
};
},
// ...
});
Typed Rules
Read TypeScript'sCompiler APIs > Type Checker APIs for how to use a program's type checker.
The biggest addition typescript-eslint brings to ESLint rules is the ability to use TypeScript's type checker APIs.
@typescript-eslint/utils
exports anESLintUtils
namespace containing agetParserServices
function that takes in an ESLint context and returns aservices
object.
Thatservices
object contains:
program
: A full TypeScriptts.Program
object if type checking is enabled, ornull
otherwiseesTreeNodeToTSNodeMap
: Map of@typescript-eslint/estree
TSESTree.Node
nodes to their TypeScriptts.Node
equivalentstsNodeToESTreeNodeMap
: Map of TypeScriptts.Node
nodes to their@typescript-eslint/estree
TSESTree.Node
equivalents
If type checking is enabled, thatservices
object additionally contains:
getTypeAtLocation
: Wraps the type checker function, with aTSESTree.Node
parameter instead of ats.Node
getSymbolAtLocation
: Wraps the type checker function, with aTSESTree.Node
parameter instead of ats.Node
Those additional objects internally map from ESTree nodes to their TypeScript equivalents, then call to the TypeScript program.By using the TypeScript program from the parser services, rules are able to ask TypeScript for full type information on those nodes.
This rule bans for-of looping over an enum by using the TypeScript type checker via typescript-eslint's services:
import{ ESLintUtils}from'@typescript-eslint/utils';
import*as tsfrom'typescript';
exportconst rule=createRule({
create(context){
return{
ForOfStatement(node){
// 1. Grab the parser services for the rule
const services= ESLintUtils.getParserServices(context);
// 2. Find the TS type for the ES node
const type= services.getTypeAtLocation(node.right);
// 3. Check the TS type's backing symbol for being an enum
if(type.symbol.flags& ts.SymbolFlags.Enum){
context.report({
messageId:'loopOverEnum',
node: node.right,
});
}
},
};
},
meta:{
docs:{
description:'Avoid looping over enums.',
},
messages:{
loopOverEnum:'Do not loop over enums.',
},
type:'suggestion',
schema:[],
},
name:'no-loop-over-enum',
defaultOptions:[],
});
Rules can retrieve their full backing TypeScript type checker withservices.program.getTypeChecker()
.This can be necessary for TypeScript APIs not wrapped by the parser services.
Conditional Type Information
We recommendagainst changing rule logic based solely on whetherservices.program
exists.In our experience, users are generally surprised when rules behave differently with or without type information.Additionally, if they misconfigure their ESLint config, they may not realize why the rule started behaving differently.Consider either gating type checking behind an explicit option for the rule or creating two versions of the rule instead.
Documentation generators such aseslint-doc-generator
can automatically indicate in a rule's docs whether it needs type information.
Testing
@typescript-eslint/rule-tester
exports aRuleTester
with a similar API to the built-in ESLintRuleTester
.It should be provided with the sameparser
andparserOptions
you would use in your ESLint configuration.
Below is a quick-start guide. For more in-depth docs and examplessee the@typescript-eslint/rule-tester
package documentation.
Testing Untyped Rules
For rules that don't need type information, no constructor parameters are necessary:
import{ RuleTester}from'@typescript-eslint/rule-tester';
import rulefrom'./my-rule';
const ruleTester=newRuleTester();
ruleTester.run('my-rule', rule,{
valid:[
/* ... */
],
invalid:[
/* ... */
],
});
Testing Typed Rules
For rules that do need type information,parserOptions
must be passed in as well.We recommend usingparserOptions.projectService
with options to allow a default project for each test file.
import{ RuleTester}from'@typescript-eslint/rule-tester';
import rulefrom'./my-typed-rule';
const ruleTester=newRuleTester({
languageOptions:{
parserOptions:{
projectService:{
allowDefaultProject:['*.ts*'],
},
tsconfigRootDir: __dirname,
},
},
});
ruleTester.run('my-typed-rule', rule,{
valid:[
/* ... */
],
invalid:[
/* ... */
],
});
SeeRule Tester >Type-Aware Testing for more details.