Chris.luChris.lu header image, displaying an 80s style landscape and sunset

Typescript Linting & custom flat config

On this page we will start by using typescript-eslint to be able to lint typescript files, then we will create a custom next configuration, which will allow us to get rid of any compatibility mode (FlatCompat) and we will fine tune what sets of rules we want to include (or not)

typescript-eslint installation and configuration

When using CNA earlier in this tutorial, to set up our Next.js 15 project, we told it that we wanted to enable Typescript, which means our current configuration uses next/typescript, which (as the documentation explains well) adds the recommended rules from the @typescript-eslint/eslint-plugin

Note

There is one major difference between classic configs and flat config setups for typescript-eslint, for an ESLint v8 setup you would usually install two packages, @typescript-eslint/eslint-plugin the package to the tseslint plugin and also the @typescript-eslint/parser parser package, but since ESLint 9 most tutorials (that I saw) had switched to using a single typescript-eslint package that contains the plugin, the parser as well as configurations

We start by installing typescript-eslint package using the following command:

npm i typescript-eslint@latest --save-dev --save-exact

Next we will fine tune our typescript linting by adding a custom/typescript-eslint/recommended config:

eslint.config.ts
import eslintPlugin from '@eslint/js'
import { FlatCompat } from '@eslint/eslintrc'
import tseslint, { configs as tseslintConfigs } from 'typescript-eslint'
import type { FlatConfig } from '@typescript-eslint/utils/ts-eslint'
 
const compat = new FlatCompat()
 
const eslintConfig = [
    {
        name: 'custom/eslint/recommended',
        files: ['**/*.mjs', '**/*.ts?(x)'],
        ...eslintPlugin.configs.recommended,
    },
]
 
const ignoresConfig = [
    {
        name: 'custom/eslint/ignores',
        // the ignores option needs to be in a separate configuration object
        // replaces the .eslintignore file
        ignores: [
            '.next/',
            '.vscode/',
            'public/',
        ]
    },
] as FlatConfig.Config[]
 
const tseslintConfig = tseslint.config(
    {
        name: 'custom/typescript-eslint/recommended',
        files: ['**/*.mjs', '**/*.ts?(x)'],
        extends: [
            ...tseslintConfigs.recommended,
            // OR more type checked rules
            //...tseslintConfigs.recommendedTypeChecked,
            // OR more strict rules
            //...tseslintConfigs.strict,
            // OR more strict and type checked rules
            //...tseslintConfigs.strictTypeChecked,
            // optional stylistic rules
            ...tseslintConfigs.stylistic,
            // OR the type checked version
            //...tseslintConfigs.stylisticTypeChecked,
        ] as FlatConfig.ConfigArray,
        // only needed if you use TypeChecked rules
        languageOptions: {
            parserOptions: {
                // https://typescript-eslint.io/getting-started/typed-linting
                projectService: true,
                tsconfigRootDir: import.meta.dirname,
                // react recommended is already adding the ecmaFeatures
                /*ecmaFeatures: {
                    jsx: true,
                },*/
                // it is recommended to keep version warnings turned on
                warnOnUnsupportedTypeScriptVersion: true,
            },
        },
    },
    {
        // disable type-aware linting on JS files
        // only needed if you use TypeChecked rules
        // (and you have javascript files in your project)
        files: ['**/*.mjs'],
        ...tseslintConfigs.disableTypeChecked,
        name: 'custom/typescript-eslint/disable-type-checked',
    },
)
 
export default [
    ...compat.extends('next/core-web-vitals'),
    ...eslintConfig,
    ...ignoresConfig,
    ...tseslintConfig,
] satisfies FlatConfig.Config[]

Line 3: we import typescript-eslint (which we installed in the previous step)

Line 4: we import the FlatConfig type from the @typescript-eslint utilities (which we are going to use instead of the type from the ESLint package)

Line 29: we use the config() function from the typescript-eslint package to create a typescript eslint config

Line 31: first we specify that the name for custom flat config will be tseslintConfig

Line 32: We use the files option to tell ESLint which files (extensions) we want to include (for this tutorial we will enable linting for ES Module (esm) files (.mjs), for regular Typescript (.ts) and also for Typescript & JSX files (.tsx))

Lines 33 to 45: we use the extends option to add full sets of rules to our setup (you can still fine tune rules from those sets using the rules config option):

When using typescript-eslint you get several different tseslint.configs.* you can chose from, the recommended is config contains a set of recommended rules (this is what Next.js uses), there is also recommendedTypeChecked which adds recommended rules that require type information; Next we have strict and StrictTypeChecked, which you could use instead of the recommended rules, these sets include rules are more opinionated (which is why some projects don't use them, however they can help catch bugs so I recommend giving it a try, you can still disable them later by switching back to recommended rules or you could disable some of the rules by setting them to off)

Then we have to chose between two more sets of rules, the stylistic set contains rules that do NOT impact program logic but focus on enforcing "simpler code patterns", which means they are even more , and then there is stylisticTypeChecked the second option (which I will use in this tutorial) which adds a few more stylistic rules that require type information

Tip

To better understand what is in those sets I highly recommend checking out the typescript-eslint rules overview page

Lines 47 to 59: Then we add some parser options to configure the typescript-eslint parser; the projectService got introduced in typescript-eslint v8, as they explain in their performance documentation it is recommended to use the default projectService when ever possible, they also point out that adding the path to your tsconfig.json manually "cause more disk IO", which would result in a slower linting process (issue #2611); those options are only needed if you use typed linting, so when you use one of the "type checked" sets (if are NOT using any TypeChecked rules, then you can comment out or delete the languageOptions block)

Note

the ecmaFeatures parser option in the languageOptions is commented out because there are other configurations (that we will add to our setup in an upcoming chapter) that set these options for us

I keep the comment for now as reminder, but will delete it when we add the eslint-plugin-react package, as this package will already set the option

Line 58: It is recommended to keep the typescript-eslint warnOnUnsupportedTypeScriptVersion option enabled (which is why I explicitly added it to the config), if the Typescript version that you have installed is higher than the version typescript-eslint supports then it will show a warning message (when launching the linting process)

Lines 65 to 67: we create yet another config to tell typescript eslint to apply the disableTypeChecked option when parsing ES Module (esm) files (.mjs)

Note

For this config we add the custom/typescript-eslint/disable-type-checked name at the end, this is because the order matters; if we would have set the name first and then added the disableTypeChecked config, then our name would get replaced by the name that the config sets; but because we add our name last it will replace the name that got set by the disableTypeChecked config

Line 72: we can now remove next/typescript from the compatibility mode (compat.extends), as it gets replaced by our custom typescript-eslint config

Line 76: we set the FlatConfig type from the @typescript-eslint utilities as type for our default export (to replace the type from the ESLint package which is what we were using previously), the reason to switch for the Config type from ESLint to the one from @typescript-eslint utilities is because of issues related with the type of the default export, you can read more about those issues in the next chapter

Why we switched from ESLint to typescript-eslint types

In the first ESLint chapters, we used the type from the ESLint package like this:

import type { Linter } from 'eslint'

(...)

] satisfies Linter.Config[]

However if do keep using those types and want to use typescript-eslint then you do will start to see typescript errors like this one:

Type (Config | FlatConfig)[] does not satisfy the expected type Config<RulesRecord>[].

There are several related tickets about this, like for example issue #8613 and issue #9724, the TLDR is use the types from typescript-eslint

This is why in the latest example, we switched to use the type from the typescript-eslint package instead:

import type { FlatConfig } from '@typescript-eslint/utils/ts-eslint'

(...)

] satisfies FlatConfig.Config[]

Replacing eslint-config-next with a custom flat config

Before we can start adding linting for MDX files and markdown content we first need to get rid of eslint-config-next

Warning

I usually recommend to only remove component(s) or disable a feature(s) of a framework if it is really necessary, as doing so will increase your maintenance costs and your technical debt counter will go up, there are however situations where it is ok to make the transition

In the future eslint-config-next will hopefully get converted to a real ESLint v9 sharable flat config and then this chapter will be obsolete

As eslint-config-next needs the FlatCompat, we are going to create a custom version that does not need a compat mode. This is more work but it has the benefit that we will be able to adjust what sets of rules we want to use, meaning instead of only using the recommend rules of each plugin we can add stricter or more opinionated sets of rules (that are not enabled by default)

Note

In this tutorial we will use the eslint-plugin-import package (as does eslint-config-next), there is also eslint-plugin-import-x, which is another popular choice you might want to try out

We will also NOT use @rushstack/eslint-patch (which is now ESLint v9 compatible), however if you use Next.js in a big monorepo then you should probably install / configure it too

For current situation of the eslint-plugin-react-hooks plugin is similar to the situation of the Next.js eslint plugin, the PR (#28773) to add "ESLint v9 support" to the react-hooks plugin got merged, however the ticket about adding support for flat config is not closed (yet), there is however a PR (#30774) in the works

The plugins we will use are eslint-plugin-import (and if you use typescript also eslint-import-resolver-typescript), eslint-plugin-react, eslint-plugin-react-hooks, eslint-plugin-jsx-a11y, and we install them using the following command:

npm i eslint-plugin-import@latest eslint-import-resolver-typescript@latest eslint-plugin-react@latest eslint-plugin-react-hooks@latest eslint-plugin-jsx-a11y@latest --save-dev --save-exact

As we are attempting to reproduce what eslint-config-next, this means that we are installing dependencies that we already in the project, but it will make sure that we are not missing a package in our stack and it will ensure we have the latest version installed for each of them (as we added the @latest tag in the command(s))

If you are using Typescript, you will also want to install the following types:

npm i @types/eslint-plugin-jsx-a11y@latest --save-dev --save-exact
Note

Unfortunately several packages (eslint-plugin-react-hooks, eslint-plugin-import and @next/eslint-plugin-next) have no types, but I assume this will change as more users transition to .ts config files

Now that we have our packages installed we can finally start updating our eslint.config.ts again:

eslint.config.ts
import eslintPlugin from '@eslint/js'
import tseslint, { configs as tseslintConfigs } from 'typescript-eslint'
import type { FlatConfig } from '@typescript-eslint/utils/ts-eslint'
// @ts-expect-error this package has no types
import importPlugin from 'eslint-plugin-import'
import reactPlugin from 'eslint-plugin-react'
// @ts-expect-error this package has no types
import reactHooksPlugin from 'eslint-plugin-react-hooks'
import jsxA11yPlugin from 'eslint-plugin-jsx-a11y'
// @ts-expect-error this package has no types
import nextPlugin from '@next/eslint-plugin-next'
 
const eslintConfig = [
    {
        name: 'custom/eslint/recommended',
        files: ['**/*.mjs', '**/*.ts?(x)'],
        ...eslintPlugin.configs.recommended,
    },
]
 
const ignoresConfig = [
    {
        name: 'custom/eslint/ignores',
        // the ignores option needs to be in a separate configuration object
        // replaces the .eslintignore file
        ignores: [
            '.next/',
            '.vscode/',
            'public/',
        ]
    },
] as FlatConfig.Config[]
 
const tseslintConfig = tseslint.config(
    {
        name: 'custom/typescript-eslint/recommended',
        files: ['**/*.mjs', '**/*.ts?(x)'],
        extends: [
            ...tseslintConfigs.recommended,
            // OR more type checked rules
            //...tseslintConfigs.recommendedTypeChecked,
            // OR more strict rules
            //...tseslintConfigs.strict,
            // OR more strict and type checked rules
            //...tseslintConfigs.strictTypeChecked,
            // optional stylistic rules
            ...tseslintConfigs.stylistic,
            // OR the type checked version
            //...tseslintConfigs.stylisticTypeChecked,
        ],
        // only needed if you use TypeChecked rules
        languageOptions: {
            parserOptions: {
                // https://typescript-eslint.io/getting-started/typed-linting
                projectService: true,
                tsconfigRootDir: import.meta.dirname,
                // it is recommended to keep version warnings turned on
                //warnOnUnsupportedTypeScriptVersion: false,
            },
        },
    },
    {
        // disable type-aware linting on JS files
        // only needed if you use TypeChecked rules
        // (and you have javascript files in your project)
        files: ['**/*.mjs'],
        ...tseslintConfigs.disableTypeChecked,
        name: 'custom/typescript-eslint/disable-type-checked',
    },
)
 
const nextConfig = [
    {
        name: 'custom/next/config',
        // no files (option) for this config as we want to apply it to all files
        plugins: {
            'react': reactPlugin,
            'jsx-a11y': jsxA11yPlugin,
            /* eslint-disable @typescript-eslint/no-unsafe-assignment */
            'react-hooks': reactHooksPlugin,
            '@next/next': nextPlugin,
            'import': importPlugin,
            /* eslint-enable @typescript-eslint/no-unsafe-assignment */
        },
        rules: {
            ...reactPlugin.configs.recommended.rules,
            ...reactPlugin.configs['jsx-runtime'].rules,
            /* eslint-disable @typescript-eslint/no-unsafe-member-access */
            ...reactHooksPlugin.configs.recommended.rules,
            ...nextPlugin.configs.recommended.rules,
            // this is the nextjs strict mode
            ...nextPlugin.configs['core-web-vitals'].rules,
            ...importPlugin.configs.recommended.rules,
            /* eslint-enable @typescript-eslint/no-unsafe-member-access */
            //...jsxA11yPlugin.configs.recommended.rules,
            // OR more strict a11y rules
            ...jsxA11yPlugin.configs.strict.rules,
            // rules from eslint-config-next
            'import/no-anonymous-default-export': 'warn',
            'react/no-unknown-property': 'off',
            'react/react-in-jsx-scope': 'off',
            'react/prop-types': 'off',
            'react/jsx-no-target-blank': 'off',
            'jsx-a11y/alt-text': ['warn', { elements: ['img'], img: ['Image'], },],
            'jsx-a11y/aria-props': 'warn',
            'jsx-a11y/aria-proptypes': 'warn',
            'jsx-a11y/aria-unsupported-elements': 'warn',
            'jsx-a11y/role-has-required-aria-props': 'warn',
            'jsx-a11y/role-supports-aria-props': 'warn',
        },
        settings: {
            'react': {
                version: 'detect',
            },
            // only needed if you use (eslint-import-resolver-)typescript
            'import/resolver': {
                typescript: {
                    alwaysTryTypes: true
                }
            }
        },
    }
] as FlatConfig.Config[]
 
export default [
    ...eslintConfig,
    ...ignoresConfig,
    ...tseslintConfig,
    ...nextConfig,
] satisfies FlatConfig.Config[]

Lines 4 to 11: we import the 5 plugins, but most importantly we get rid of the FlatCompat import (from '@eslint/eslintrc') as we will NOT need it anymore 🎉 (more details bout this change are in the next chapter "Removing ESLintRC support")

Note

If our configuration is a typescript file, then you have to use a typescript comment to inform typescript that it will not find the types for some of the packages, as NOT all packages have types (yet?)

Line 72: we create a custom config to replace configuration from eslint-config-next package

Line 74: we set the name of our config to custom/next/config

Lines 76 to 84: we add the 5 plugins to the config (we use an eslint disable comment for the 3 plugins that don't have types)

Lines 85 to 110: we use rules option to add entire sets of rules to our config but then also to fine tune some of the rules (from those sets):

Lines 111 to 121: next we have the use the settings option to pass some options to the plugins we are using, the first setting is for react plugin and tells the plugin to automatically detect what react version we are using (this often works well and avoids having to update a version manually), then we set the alwaysTryTypes setting from our import resolver to true (there are more options you could set, if for example you use a monorepo), to explain what this does I will quote the explanation from their readme:

always try to resolve types under <root>@types directory even it doesn't contain any source code, like @types/unist

Congratulations 🎉 you just added a custom next config to your linting setup, which means your npm run lint command will now also detect problems related to react, hooks, imports, next.js and accessibility

Removing ESLintRC support

At first we had two plugins that needed the FlatCompat import from @eslint/eslintrc package. In the "Custom ESLint 9 flat config for Next.js 15" chapter (of the previous page) we first converted our classic ESLintRC config to a flat config and then later in the Typescript Linting chapter (of the current page) we added our own Typescript-eslint configuration. Due to those changes we were then able to get rid of the next/typescript plugin (the first of two plugins still depending on the compatibility mode). Then in the previous chapter we have added a custom nextConfig, which allowed us to also remove next/core-web-vitals.

Now that we got rid of both plugins that required a compatibility mode we can finally also remove the ESLintRC Library package (as well as the types package if like me you use typescript) from our dependencies, using the following command:

npm remove @eslint/eslintrc @types/eslint__eslintrc

Congratulations 🎉 if you made it this far, we had to make a lot of changes, but you now know a lot more about how ESLint and typescript-eslint work, and we are now ready to start adding linting for MDX content in the next part of this tutorial

If you liked this post, please consider buying me a coffee ☕ or sponsor ❤️ me on GitHub, as it will help me create more content and keep it free for everyone