# 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”)

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](https://eslint.org/docs/latest/use/configure/debug), 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.

```js
import { defineConfig } from "eslint/config"

export default defineConfig([])
```

When you do this, the default config is as follows.

```js
[
  {
    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

> [!info] The issue below has been fixed, so now you can just use defineConfig().

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](https://github.com/typescript-eslint/typescript-eslint/issues/10899), it seems there is no difference in functionality; it is needed to fix type issues caused by `defineConfig()`.

```js
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.)

```js
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](https://eslint.org/docs/latest/use/configure/configuration-files#globally-ignoring-files-with-ignores) to make the intention clearer and help prevent mistakes.

```js
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.

```js
{
  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?

```js
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](https://eslint.org/docs/latest/use/configure/configuration-files#configuration-objects), 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.

```js
[
  // ...중략...
  { 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.

```js
[
  // ...중략...
  { 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
[
  // ...중략...
  { ...js.configs.recommended, files: ["**/*.js"] },
]
```

However, when you want to add or override rules, it can become cumbersome like this.

```js
{
  ...js.configs.recommended,
  rules: {
    ...js.configs.recommended.rules,
    "max-params": ["error", { "max": 5 }],
  }
}
```

If you use `extends`, it becomes neat like below.

```js
{
  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.

```js
{
  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](https://wiki.g15e.com/en/pages/Agentic%20coding%20experiment%203.txt))

```js
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](https://wiki.g15e.com/en/pages/Cognitive%20complexity.txt) should be lower than in production code
- For entire files, constrain `max-lines`, and for individual functions, constrain `max-statements`