HomeBlogProjectsAbout
© 2020-2025 Notes of Dev.
This content is licensed under CC BY 4.0
— Mar 10, 2025

My optimal ESLint setup

After Airbnb’s TypeScript ESLint config was archived, I tested alternatives and found the perfect setup. Here’s my optimized ESLint configuration for TypeScript projects.

For a long time, I relied on eslint-config-airbnb-typescript. Unfortunately, in August 2024, the project was archived. Since then, the most popular fork on GitHub has only five stars (as of March 10, 2025). So, I decided it was time to switch to something else. After numerous trials, errors, and reading on Reddit and TwitterX, I believe I've found my sweet spot setup. Here’s a TL;DR version

The Configuration

# required packages
Copied
@eslint/js eslint eslint-config-prettier eslint-plugin-import typescript-eslint
# eslint.config.mjs
Copied
import js from '@eslint/js';
import prettierConfig from 'eslint-config-prettier';
import importPlugin from 'eslint-plugin-import';
import tseslint from 'typescript-eslint';

const baseConfig = tseslint.config(
  {
    ignores: ['var/'],
  },
  js.configs.recommended,
);

const importConfig = tseslint.config({
  extends: [
    importPlugin.flatConfigs.recommended,
    importPlugin.flatConfigs.typescript,
  ],
  rules: {
    // https://typescript-eslint.io/troubleshooting/typed-linting/performance/#eslint-plugin-import
    'import/named': 0,
    'import/namespace': 0,
    'import/default': 0,
    'import/no-named-as-default-member': 0,
    'import/no-unresolved': 0,
    'import/prefer-default-export': 0,

    // 'import/no-anonymous-default-export': 'error',

    'import/no-commonjs': 'error',
    'import/first': 'error',
    'import/newline-after-import': 'error',
    'import/order': [
      'error',
      {
        alphabetize: {
          order: 'asc',
        },
      },
    ],
    'import/no-extraneous-dependencies': [
      'error',
      {
        devDependencies: ['**/*.test.ts', 'tests/**/*.ts', '*.config.mjs'],
      },
    ],
    'import/no-cycle': ['error'],
    'import/no-duplicates': ['error', { 'prefer-inline': true }],
    'import/consistent-type-specifier-style': ['error', 'prefer-inline'],
  },
});

const tsEslintConfig = tseslint.config(
  {
    languageOptions: {
      parserOptions: {
        projectService: true,
      },
    },
  },
  tseslint.configs.recommended,
  tseslint.configs.stylistic,
  {
    files: ['**/*.ts', '**/*.tsx'],
    extends: [
      tseslint.configs.recommendedTypeCheckedOnly,
      tseslint.configs.stylisticTypeCheckedOnly,
    ],
    rules: {
      '@typescript-eslint/consistent-type-definitions': 0,
      '@typescript-eslint/consistent-type-imports': [
        'error',
        { prefer: 'type-imports', fixStyle: 'inline-type-imports' },
      ],
    },
  },
  {
    rules: {
      '@typescript-eslint/no-unused-vars': [
        'error',
        {
          argsIgnorePattern: '^_',
          caughtErrorsIgnorePattern: '^_',
          destructuredArrayIgnorePattern: '^_',
        },
      ],
    },
  },
);

const customConfig = tseslint.config({
  rules: {
    'no-console': ['error', { allow: ['error', 'warn', 'info'] }],
    'class-methods-use-this': 0,
  },
});

export default tseslint.config(
  baseConfig,
  importConfig,
  tsEslintConfig,
  prettierConfig,
  customConfig,
);

Breaking It Down

Let’s break the config into logical parts to understand how everything fits together.

Base Configuration

# javascript
Copied
const baseConfig = tseslint.config(
  {
    ignores: ['var/'],
  },
  js.configs.recommended,
);

I use the /var directory for miscellaneous local files that aren’t part of the project - such as temporary snippets, local SQLite files, and caches. This habit comes from my experience with PHP and has stuck with me over the years.

Then, I include ESLint's core recommendations - safe rules, that cover basic behavior.

Import Plugin Configuration

# javascript
Copied
const importConfig = tseslint.config({
  extends: [
    importPlugin.flatConfigs.recommended,
    importPlugin.flatConfigs.typescript,
  ],
  rules: {
    // https://typescript-eslint.io/troubleshooting/typed-linting/performance/#eslint-plugin-import
    'import/named': 0,
    'import/namespace': 0,
    'import/default': 0,
    'import/no-named-as-default-member': 0,
    'import/no-unresolved': 0,
    'import/prefer-default-export': 0,

    // 'import/no-anonymous-default-export': 'error',

    'import/no-commonjs': 'error',
    'import/first': 'error',
    'import/newline-after-import': 'error',
    'import/order': [
      'error',
      {
        alphabetize: {
          order: 'asc',
        },
      },
    ],
    'import/no-extraneous-dependencies': [
      'error',
      {
        devDependencies: ['**/*.test.ts', 'tests/**/*.ts', '*.config.mjs'],
      },
    ],
    'import/no-cycle': ['error'],
    'import/no-duplicates': ['error', { 'prefer-inline': true }],
    'import/consistent-type-specifier-style': ['error', 'prefer-inline'],
  },
});

Again, I extend base configs that provide sane defaults. Then I apply my own rules. Some of these rules follow TypeScript ESLint recommendations. I also enforce strict import practices, such as requiring imports to be at the top of a file and preventing invalid imports.

TypeScript ESLint Setup

# javascript
Copied
const tsEslintConfig = tseslint.config(
  {
    languageOptions: {
      parserOptions: {
        projectService: true,
      },
    },
  },
  tseslint.configs.recommended,
  tseslint.configs.stylistic,
  {
    files: ['**/*.ts', '**/*.tsx'],
    extends: [
      tseslint.configs.recommendedTypeCheckedOnly,
      tseslint.configs.stylisticTypeCheckedOnly,
    ],
    rules: {
      '@typescript-eslint/consistent-type-definitions': 0,
      '@typescript-eslint/consistent-type-imports': [
        'error',
        { prefer: 'type-imports', fixStyle: 'inline-type-imports' },
      ],
    },
  },
  {
    rules: {
      '@typescript-eslint/no-unused-vars': [
        'error',
        {
          argsIgnorePattern: '^_',
          caughtErrorsIgnorePattern: '^_',
          destructuredArrayIgnorePattern: '^_',
        },
      ],
    },
  },
);

Again - using recommended values provided by the module configs. All overrides are personal preference. I prefer using type over interface, except when working with classes. I also allow unused variables prefixed with an underscore for flexibility.

Types ESLint Rules

# javascript
Copied
  {
    languageOptions: {
      parserOptions: {
        projectService: {
          allowDefaultProject: ['*.config.mjs'],
        },
        tsconfigRootDir: import.meta.dirname,
      },
    },
  },

This configuration is required to use TypeScript ESLint’s typed rules. It also allows me to run ESLint on root-level config files like ESLint and Prettier configs.

Personal Preferences

# javascript
Copied
const customConfig = tseslint.config({
  rules: {
    'no-console': ['error', { allow: ['error', 'warn', 'info'] }],
    'class-methods-use-this': 0,
  },
});

These are my personal preferences. I allow some console methods (warn, error, info) and disable class-methods-use-this, which often gets in the way unnecessarily for utility classes or handlers.

Putting It All Together

# javascript
Copied
export default tseslint.config(
  baseConfig,
  importConfig,
  tsEslintConfig,
  prettierConfig,
  customConfig,
);

All these separate configs are combined using tseslint.config(...) . This modular structure improves readability and makes it easy to adjust or extend individual parts without touching the entire config.

Also, I put prettierConfig (eslint-config-prettier) right before my custom rules. This makes sure that Prettier handles formatting separately by disabling any conflicting style rules introduced by the previous configurations.

This setup strikes a balance between strict linting and maintainability.

Special Case: Next.js

Next.js has its own rules and requires the FlatCompat class to function correctly. To accommodate this, install the @eslint/eslintrc package and modify your ESLint config as follows:

# diff
Copied
--- eslint.config.mjs
+++ eslint-next.config.mjs
@@ -1,8 +1,12 @@
+import { FlatCompat } from '@eslint/eslintrc';
 import js from '@eslint/js';
 import prettierConfig from 'eslint-config-prettier';
-import importPlugin from 'eslint-plugin-import';
 import tseslint from 'typescript-eslint';

+const compat = new FlatCompat({
+  baseDirectory: import.meta.dirname,
+});
+
 const baseConfig = tseslint.config(
   {
     ignores: ['var/'],
@@ -11,10 +15,6 @@
 );

 const importConfig = tseslint.config({
-  extends: [
-    importPlugin.flatConfigs.recommended,
-    importPlugin.flatConfigs.typescript,
-  ],
   rules: {
     // https://typescript-eslint.io/troubleshooting/typed-linting/performance/#eslint-plugin-import
     'import/named': 0,
@@ -95,6 +95,7 @@
 });

 export default tseslint.config(
+  ...compat.extends('next/core-web-vitals'),
   baseConfig,
   importConfig,
   tsEslintConfig,

Next.js already defines configures import as a plugin so you have to omit the defaults.

  • javascript
  • typescript
  • ci/cd
  • code quality

Related posts

— Mar 11, 2025

TypeScript Announces Rewrite in Go

— Feb 5, 2025

Sticky Auto-Hiding Header with JS (or React)

— Jun 4, 2023

Using GitHub Action to deploy Cloudflare Pages project

— May 6, 2025

Payload Purge Cache Plugin v0.1.1

— Feb 19, 2025

Using Radix Colors with Tailwind CSS