Criando uma regra de ESLint personalizada com TypeScript
1. Fundamentos do ecossistema de regras ESLint
O ESLint é um dos linters mais populares do ecossistema JavaScript/TypeScript, e sua arquitetura baseada em plugins permite que desenvolvedores criem regras personalizadas para atender necessidades específicas de seus projetos. Cada regra no ESLint segue um ciclo de vida bem definido: create (criação da regra), meta (metadados da regra) e fix (autocorreção opcional).
Um plugin ESLint é um pacote npm que exporta um conjunto de regras. A estrutura básica de um plugin inclui:
// plugin.ts
import type { Rule } from '@typescript-eslint/utils';
export const rules = {
'no-any-params': {
meta: { /* ... */ },
create: (context) => { /* ... */ }
}
};
As regras podem ser classificadas em três categorias principais:
- Regras de sintaxe: verificam padrões de código (ex: ponto-e-vírgula obrigatório)
- Regras semânticas: analisam o significado do código (ex: variáveis não utilizadas)
- Regras de estilo: garantem consistência visual (ex: indentação)
2. Configurando o ambiente de desenvolvimento
Para começar, crie um projeto com as dependências necessárias:
mkdir eslint-plugin-custom
cd eslint-plugin-custom
npm init -y
npm install --save-dev typescript @typescript-eslint/utils @typescript-eslint/rule-tester jest ts-jest @types/jest
Estrutura de pastas recomendada:
eslint-plugin-custom/
├── src/
│ ├── rules/
│ │ └── no-any-params.ts
│ └── index.ts
├── tests/
│ └── rules/
│ └── no-any-params.test.ts
├── docs/
│ └── rules/
│ └── no-any-params.md
├── tsconfig.json
├── jest.config.js
└── package.json
Configure o tsconfig.json:
{
"compilerOptions": {
"target": "ES2019",
"module": "commonjs",
"lib": ["ES2019"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "tests"]
}
3. Anatomia de uma regra personalizada com TypeScript
Cada regra deve exportar um objeto com meta e create. O meta contém informações como tipo, documentação e esquema de configuração:
import type { Rule } from '@typescript-eslint/utils';
type MessageIds = 'noAnyParam' | 'suggestion';
const noAnyParamsRule: Rule.RuleModule = {
meta: {
type: 'suggestion',
docs: {
description: 'Proíbe o uso de `any` em parâmetros de função',
recommended: 'warn',
url: 'https://example.com/docs/no-any-params'
},
schema: [
{
type: 'object',
properties: {
allowCallback: { type: 'boolean' }
},
additionalProperties: false
}
],
messages: {
noAnyParam: 'Parâmetro "{{param}}" não deve ser do tipo `any`.',
suggestion: 'Considere usar um tipo específico ou `unknown`.'
},
fixable: 'code'
},
create(context) {
return {
// Visitors serão implementados aqui
};
}
};
A assinatura do create recebe um context tipado com RuleContext e retorna um objeto com visitors que percorrem a AST.
4. Navegando pela AST do TypeScript com @typescript-eslint/utils
O @typescript-eslint/utils fornece acesso à AST do TypeScript através do TSESTree. Para navegar pelos nós, utilizamos funções auxiliares como isIdentifier, isCallExpression e isTypeNode:
import { ASTUtils, TSESTree } from '@typescript-eslint/utils';
// Verificando se um nó é uma declaração de função
function isFunctionDeclaration(node: TSESTree.Node): node is TSESTree.FunctionDeclaration {
return node.type === 'FunctionDeclaration';
}
// Acessando informações de tipo com parserServices
function getTypeAtPosition(context: Rule.RuleContext, node: TSESTree.Node) {
const parserServices = context.sourceCode.parserServices;
if (!parserServices?.hasFullTypeInformation) {
return null;
}
return parserServices.getTypeAtLocation(node);
}
5. Implementando a lógica da regra: validação e relato
Vamos implementar uma regra que proíbe any em parâmetros de função:
import { ASTUtils, TSESTree, Rule } from '@typescript-eslint/utils';
type MessageIds = 'noAnyParam';
const noAnyParamsRule: Rule.RuleModule = {
meta: {
type: 'suggestion',
docs: {
description: 'Proíbe o uso de `any` em parâmetros de função',
recommended: 'warn'
},
schema: [],
messages: {
noAnyParam: 'Parâmetro "{{param}}" não deve ser do tipo `any`. Use um tipo específico ou `unknown`.'
},
fixable: 'code'
},
create(context) {
return {
FunctionDeclaration(node: TSESTree.FunctionDeclaration) {
node.params.forEach((param) => {
if (param.typeAnnotation) {
const typeAnnotation = param.typeAnnotation.typeAnnotation;
if (typeAnnotation.type === 'TSAnyKeyword') {
context.report({
node: param,
messageId: 'noAnyParam',
data: {
param: param.type === 'Identifier' ? param.name : 'parâmetro'
}
});
}
}
});
},
ArrowFunctionExpression(node: TSESTree.ArrowFunctionExpression) {
// Lógica similar para arrow functions
node.params.forEach((param) => {
if (param.typeAnnotation) {
const typeAnnotation = param.typeAnnotation.typeAnnotation;
if (typeAnnotation.type === 'TSAnyKeyword') {
context.report({
node: param,
messageId: 'noAnyParam',
data: {
param: param.type === 'Identifier' ? param.name : 'parâmetro'
}
});
}
}
});
}
};
}
};
6. Adicionando autofix com TypeScript
Para adicionar um autofix que substitui any por unknown, utilizamos o fixer:
create(context) {
return {
FunctionDeclaration(node: TSESTree.FunctionDeclaration) {
node.params.forEach((param) => {
if (param.typeAnnotation?.typeAnnotation.type === 'TSAnyKeyword') {
context.report({
node: param,
messageId: 'noAnyParam',
data: {
param: param.type === 'Identifier' ? param.name : 'parâmetro'
},
fix: (fixer) => {
const typeNode = param.typeAnnotation!.typeAnnotation;
return fixer.replaceText(typeNode, 'unknown');
}
});
}
});
}
};
}
O fixer oferece métodos como:
- fix.replaceText(node, text): substitui o texto de um nó
- fix.insertTextBefore(node, text): insere texto antes de um nó
- fix.remove(node): remove um nó completamente
7. Testando a regra com TypeScript e Jest
Configure o RuleTester do @typescript-eslint/rule-tester:
// tests/rules/no-any-params.test.ts
import { RuleTester } from '@typescript-eslint/rule-tester';
import noAnyParamsRule from '../../src/rules/no-any-params';
const ruleTester = new RuleTester({
parser: require.resolve('@typescript-eslint/parser'),
parserOptions: {
ecmaVersion: 2020,
sourceType: 'module'
}
});
ruleTester.run('no-any-params', noAnyParamsRule, {
valid: [
'function foo(x: string) {}',
'const bar = (y: number) => y * 2;',
'function baz<T>(param: T) {}'
],
invalid: [
{
code: 'function foo(x: any) {}',
errors: [{ messageId: 'noAnyParam' as const }],
output: 'function foo(x: unknown) {}'
},
{
code: 'const bar = (y: any) => y;',
errors: [{ messageId: 'noAnyParam' as const }],
output: 'const bar = (y: unknown) => y;'
}
]
});
8. Publicando e documentando o plugin
Configure o package.json para publicação:
{
"name": "eslint-plugin-no-any-params",
"version": "1.0.0",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": ["dist"],
"scripts": {
"build": "tsc",
"test": "jest",
"prepublishOnly": "npm run build && npm test"
},
"peerDependencies": {
"@typescript-eslint/parser": "^6.0.0",
"eslint": "^8.0.0"
}
}
Documente a regra com exemplos práticos:
# `no-any-params`
Proíbe o uso de `any` em parâmetros de função.
## Regra
### Código inválido
```typescript
function process(data: any) {}
Código válido
function process(data: string) {}
function process(data: unknown) {}
```
Siga boas práticas de versionamento semântico (semver) e considere integrar o plugin em monorepos com ferramentas como Lerna ou Nx para gerenciar múltiplos pacotes.
Referências
- Documentação oficial do ESLint - Regras personalizadas — Guia completo sobre criação de regras personalizadas no ESLint
- TypeScript ESLint - Criando regras — Tutorial oficial do typescript-eslint para criação de regras com TypeScript
- AST Explorer — Ferramenta interativa para visualizar a AST do TypeScript e testar regras
- @typescript-eslint/utils - Documentação — API completa para criação de regras com utilitários TypeScript
- ESLint Plugin Developer Guide — Guia oficial para desenvolvimento de plugins ESLint
- TypeScript Deep Dive - AST — Explicação detalhada sobre a AST do TypeScript