Configuring ESLint
If I add up all the time I’ve spent configuring ESLint so far, it would be at least 10 hours and at most 100 hours, and I still don’t know exactly how the ESLint config file works. If you’ve spent 100 hours on something and have learned nothing, that means you’re doing something wrong. So I sat down and studied it seriously for about 2 hours. (August 17, 2025 based on v9.33.0 “Flat Config”)
Setting up a study environment
It’s important to get fast feedback on how things change when you fix something.
Reading the official documentation on how to debug config files, I found that there is a command called npx eslint --inspect-config. When you run it, it opens a web page that clearly shows the currently applied configuration. If you edit the config file, the contents of the page are updated automatically.
Starting from an empty config
Let’s find out what the default config is.
import { defineConfig } from "eslint/config"
export default defineConfig([])
When you do this, the default config is as follows.
[
{
files: ["**/*.js", "**/*.mjs"],
ignores: [".git/", "**/node_modules/"],
languageOptions: {
sourceType: 'module',
ecmaVersion: 'latest'
},
linterOptions: {
reportUnusedDisableDirectives: 1
}
},
{
files: ["**/*.cjs"],
languageOptions: {
sourceType: 'commonjs',
ecmaVersion: 'latest'
}
}
]
What stands out is that it specifies the source type separately for module JS (*.js and *.mjs) and CommonJS (*.cjs). There still aren’t any rules yet.
tseslint.config
If you use the typescript-eslint package, it tells you to use tseslint.config() instead of defineConfig(). Even if you replace it exactly as instructed, nothing changes in the configuration. Looking into it here, it seems there is no difference in functionality; it is needed to fix type issues caused by defineConfig().
import tseslint from "typescript-eslint"
// eslint.defineConfig() has a types incompatibility issue
export default tseslint.config([])
Still a situation with no rules at all.
Global ignores
If there are files you want to additionally ignore across the entire source codebase, it says you need to add a config with no other keys and only ignores, but adding name was fine too. (s4 is the name of the project I’m currently working on. If you use the “/” character like this, ESLint Config Inspector renders it nicely.)
import tseslint from "typescript-eslint"
// eslint.defineConfig() has a types incompatibility issue
export default tseslint.config([
{
name: "s4/global ignores",
ignores: [".cursor/", ".github/", "dist/", "coverage/"],
// do not add anything else here to make `ignores` apply globally
},
])
If you add another key besides name, for example rules, then ignores no longer works globally. That is exactly the kind of mistake that’s easy to make. So they provide a function to make the intention clearer and help prevent mistakes.
import { globalIgnores } from "eslint/config";
export default tseslint.config([
globalIgnores([".cursor/", ".github/", "dist/", "coverage/"]),
])
ESLint recommended
Now let’s add some rules.
@eslint/js contains the all and recommended configs. If you inspect what is in recommended with console.log(), you get the following.
{
rules: {
'constructor-super': 'error',
'for-direction': 'error',
// ...중략...
'use-isnan': 'error',
'valid-typeof': 'error'
}
}
It’s a JavaScript object that defines only rules. What happens if you add this to the ESLint config as below?
import js from "@eslint/js"
export default tseslint.config([
// ...중략...
js.configs.recommended,
])
In the inspector, it says that 61 rules apply to “all files.” Why is that? Reading the documentation, it says that if you omit files, it applies to all files specified in other configs. Since ESLint’s default config explicitly specifies *.js, *.mjs, and *.cjs, you can think of it as applying to files matching those new patterns.
Meanwhile, the way recommended by the documentation is as follows.
[
// ...중략...
{ files: ["**/*.js"], plugins: { js }, extends: ["js/recommended"] },
]
However, the method of putting a string in extends cannot be used in config() of typescript-eslint, so you can write it like this instead.
[
// ...중략...
{ files: ["**/*.js"], extends: [js.configs.recommended] },
]
Actually, js.configs.recommended is an object in the form {rules: […]}, so you can also use the spread operator … like this.
[
// ...중략...
{ ...js.configs.recommended, files: ["**/*.js"] },
]
However, when you want to add or override rules, it can become cumbersome like this.
{
...js.configs.recommended,
rules: {
...js.configs.recommended.rules,
"max-params": ["error", { "max": 5 }],
}
}
If you use extends, it becomes neat like below.
{
files: ["**/*.js"],
extends: [js.configs.recommended],
rules: {
"max-params": ["error", { "max": 5 }],
}
}
typescript-eslint: strictTypeChecked
Let’s tighten up the type system by using the “Typed Linting” feature of typescript-eslint. You can use the strictTypeChecked config instead of recommended.
However, to use this, you need to additionally specify languageOptions as below.
{
extends: [ts.configs.strictTypeChecked],
languageOptions: { parserOptions: { projectService: true } },
rules: {
'@typescript-eslint/restrict-template-expressions': 'off',
"@typescript-eslint/switch-exhaustiveness-check": "error",
}
}
Final version
The final version. I tightened things a bit excessively with the goal of enforcing the quality of code produced by AI agents.
Still, the linter alone has limits, and other means are needed as well. For example (jscpd: duplicate code detection; dependency-cruise: enforcing dependency structure between modules; knip: detecting unused code. For details, see Agent-based Coding Experiment 3)
import js from "@eslint/js"
import { globalIgnores } from "eslint/config"
import importPlugin from "eslint-plugin-import"
import jsdoc from "eslint-plugin-jsdoc"
import sonarjs from "eslint-plugin-sonarjs"
import ts from "typescript-eslint"
// eslint.defineConfig() has a types incompatibility issue
export default ts.config([
// global ignores
globalIgnores([".cursor/", ".github/", "dist/", "coverage/", ".dependency-cruiser.cjs", "eslint.config.js"]),
// check for typescript files
{ name: "s4/ts", files: ["src/**/*.ts"] },
// js/recommended with custom rules
{
name: "s4/js-recomm-mod",
extends: [js.configs.recommended],
rules: {
"no-undef": "off",
"max-params": ["error", { max: 5 }],
"max-statements": ["error", { max: 15 }],
},
},
// jsdoc
{
name: "s4/jsdoc-recomm-mod",
extends: [jsdoc.configs["flat/recommended-error"]],
rules: {
"jsdoc/require-jsdoc": ["error", { publicOnly: true }],
"jsdoc/require-param-type": "off",
"jsdoc/require-returns-type": "off",
"jsdoc/require-returns-check": "error",
},
},
// import
{
name: "s4/import-recomm-mod",
extends: [importPlugin.flatConfigs.recommended],
ignores: ["eslint.config.js"],
rules: {
"import/max-dependencies": ["error", { max: 8, ignoreTypeImports: false }],
},
},
// typescript-eslint
{
name: "s4/ts-strict-type-checked-mod",
extends: [ts.configs.strictTypeChecked],
languageOptions: { parserOptions: { projectService: true } },
rules: {
"@typescript-eslint/restrict-template-expressions": "off",
"@typescript-eslint/switch-exhaustiveness-check": "error",
"@typescript-eslint/consistent-type-imports": "error",
"@typescript-eslint/no-magic-numbers": [
"error",
{
ignore: [-2, -1, 0, 1, 2, 10, 16, 24, 32, 42, 60, 100, 255, 256, 512, 1024],
ignoreEnums: true,
ignoreNumericLiteralTypes: true,
ignoreReadonlyClassProperties: true,
ignoreTypeIndexes: true,
},
],
},
},
// sonarjs
{
name: "s4/sonarjs-recomm-mod",
extends: [sonarjs.configs.recommended],
rules: {
"sonarjs/todo-tag": "off",
"sonarjs/pseudo-random": "off",
"sonarjs/no-os-command-from-path": "off",
"sonarjs/prefer-regexp-exec": "off",
"sonarjs/cognitive-complexity": ["error", 6],
"sonarjs/max-lines": ["error", { maximum: 200 }],
"sonarjs/elseif-without-else": "error",
"sonarjs/no-collapsible-if": "error",
"sonarjs/no-inconsistent-returns": "error",
"sonarjs/slow-regex": "off",
"no-useless-escape": "off",
"no-magic-numbers": "off",
},
},
// tests (overrides previous rules)
{
name: "s4/test",
files: ["src/**/*.test.ts"],
rules: {
"max-statements": ["error", { max: 20 }],
"sonarjs/cognitive-complexity": ["error", 3],
"sonarjs/max-lines": ["error", { maximum: 300 }],
},
},
])
A few interesting(?) settings:
- It’s okay for test cases to get a bit long, but cognitive complexity should be lower than in production code
- For entire files, constrain
max-lines, and for individual functions, constrainmax-statements