I don’t really enjoy linters like eslint/tslint TBH, most of the time they’re enforcing other people’s rules that I only kinda agree with but go along with them because others really like them. It’s a pretty good way for finding a path of least resistance among many people, but I often work alone.
That said, one place where eslint shines and I think is under-represented is in per-project rules which provide fix-its. As I needed to migrate ~60 service files to use some new types to via my new types codegen I figured this was a good time to use the built-in Redwood eslint setup so that I could automate setting the types on all the resolvers.
This is based on Steven Petryk’s blog post.
Step 1: Make a folder for your rules, I just used $appRoot/eslint
Step 2: Add a package.json
, index.js
and custom-rule.js
(yep, lets not deal with typescript for these small files)
package.json
:
{
"name": "eslint-plugin-redwood-resolvers"
}
index.js
:
const fs = require("fs")
const path = require("path")
const ruleFiles = fs.readdirSync(__dirname).filter((file) => file !== "index.js" && !file.endsWith("test.js"))
const rules = Object.fromEntries(ruleFiles.map((file) => [path.basename(file, ".js"), require("./" + file)]))
module.exports = { rules }
custom-rule.js
- this will be your rule, we’ll leave it empty ATM
Step 3: Edit your root package.json
to let eslint know your rules exist
There is an eslintConfig
which you can edit. In my case I only wanted some rules running on the services files, so mine looks like:
"eslintConfig": {
"extends": "@redwoodjs/eslint-config",
"root": true,
"rules": {
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/ban-ts-comment": "off"
},
"overrides": [
{
"files": [
"api/src/services/**/*.ts"
],
"plugins": [
"redwood-resolvers"
],
"rules": {
"redwood-resolvers/use-custom-types": "error"
}
}
]
},
( /use-custom-types
comes from inside your rules, which is currently empty, you’ll need to come back and set this later. )
Then you’ll also need to ‘add’ the ‘dependency’ in your eslint folder:
"devDependencies": {
"@redwoodjs/core": "^3.6.1",
"eslint-plugin-redwood-resolvers": "link:./eslint",
"husky": "^8.0.0"
},
Then run yarn
to install the deps. That’s all your infra set up!
Step 4: Make a rule
Here’s a copy of my rule to give you a sense of how they work:
const { basename } = require("path")
const { ESLintUtils } = require("@typescript-eslint/utils")
const createRule = ESLintUtils.RuleCreator((name) => `https://orta.io/rule/noop/${name}`)
module.exports = createRule({
create(context) {
const thisFilename = basename(context.getFilename())
const thisFileCorrespondingImport = `src/lib/types/${thisFilename.replace(".ts", "")}`
/** @type {import("@typescript-eslint/types/dist/generated/ast-spec").ImportDeclaration} */
let importForThisFile = null
return {
ImportDeclaration(node) {
importForThisFile ||= node.source.value === thisFileCorrespondingImport ? node : null
},
ExportNamedDeclaration(node) {
if (!node.declaration) return
if (!node.declaration.declarations) return
node.declaration.declarations.forEach((vd) => {
// VariableDeclarator means an `export const abcThing =`
if (vd.type === "VariableDeclarator" && vd.id.type === "Identifier") {
if (vd.id.name.startsWith("_")) return
// Lowercase means something we think should be an query/mutation fn
const isGlobalOrMutationResolver = /^[a-z]/.test(vd.id.name)
const suffix = isGlobalOrMutationResolver ? "Resolver" : "TypeResolvers"
const typeName = capitalizeFirstLetter(vd.id.name) + suffix
// Only run for lowercase arrow funcs ATM
if (isGlobalOrMutationResolver && vd.init?.type !== "ArrowFunctionExpression") return
// If there's no type annotation, then we should add one
if (!vd.id.typeAnnotation) {
context.report({
messageId: "needsType",
node: vd.id,
data: {
name: vd.id.name,
typeName,
},
*fix(fixer) {
yield fixer.insertTextAfter(vd.id, `: ${typeName}`)
if (!importForThisFile) {
yield fixer.insertTextBeforeRange([0, 0], `import { ${typeName} } from "${thisFileCorrespondingImport}"\n`)
} else {
const lastImportSpecifier = importForThisFile.specifiers[importForThisFile.specifiers.length - 1]
yield fixer.insertTextAfter(lastImportSpecifier, `, ${typeName}`)
}
},
})
return
}
// If there is one and it's wrong, edit it
if (vd.id.typeAnnotation.typeAnnotation) {
const type = vd.id.typeAnnotation.typeAnnotation
// E.g. not Thing but could be kinda anything else, which we should switch
const isIdentifier = type.typeName?.type === "Identifier"
const isCorrectType = isIdentifier && type.typeName.name === typeName
if (isCorrectType) return
context.report({
messageId: "needsType",
node: vd.id,
data: {
name: vd.id.name,
typeName,
},
*fix(fixer) {
// Remove the old type reference - does this need to include a -1 for the ':'?
yield fixer.removeRange([type.range[0] - 2, type.range[1]])
yield fixer.insertTextAfter(vd.id, `: ${typeName}`)
if (!importForThisFile) {
yield fixer.insertTextBeforeRange([0, 0], `import { ${typeName} } from "${thisFileCorrespondingImport}"\n`)
} else {
const lastImportSpecifier = importForThisFile.specifiers[importForThisFile.specifiers.length - 1]
yield fixer.insertTextAfter(lastImportSpecifier, `, ${typeName}`)
}
},
})
}
}
})
},
}
},
name: "use-custom-types",
meta: {
docs: {
description: "Sets the types on a query/mutation function to the correct type",
recommended: "warn",
},
messages: {
needsType: "The query/mutation function ({{name}}) needs a type annotation of {{typeName}}.",
},
fixable: "code",
type: "suggestion",
schema: [],
},
defaultOptions: [],
})
const capitalizeFirstLetter = (str) => str.charAt(0).toUpperCase() + str.slice(1)
It is not comprehensive, built to fit my code style, but it gives you an idea of how they work. A version of this which works with the default types would need to know whether an exported function was either a query or mutation - which isn’t really accessible here without an SDL lookup, which is outside of the scope of this example.
Anyway, that’s enough to start running custom eslint rules. You can then test it on a single file via yarn rw lint [file] --fix
or use Restart ESLint server
in VS Code to test it directly in your editor.
I migrated ~60 service files in a single command once I got it right, and now I know that setting up the resolver types isn’t something I ever have to think about anymore. So, it probably took about the same time as hand changing it, but sans human errors. Useful.