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
@eslint/js eslint eslint-config-prettier eslint-plugin-import typescript-eslint
# eslint.config.mjs
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
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
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
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
{
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
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
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
--- 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.