Setting Up TypeScript ESLint Rules Teams Actually Follow
I've seen two types of ESLint configs:
Type 1: The Abandoned Config
{
"rules": {
"no-console": "error",
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/explicit-function-return-type": "error",
"react/prop-types": "error"
}
}
Result: Developers disable ESLint entirely because it's "too annoying." The codebase has // eslint-disable comments everywhere. The config sits unused.
Type 2: The "Whatever" Config
{
"extends": ["eslint:recommended"]
}
Result: ESLint catches nothing useful. Bugs slip through. The team wonders why they even have ESLint.
After 5 years of tuning ESLint configs across multiple teams, I've found the sweet spot: rules that prevent real bugs without driving developers crazy.
Here's the config that actually works.
The Philosophy: Three Tiers
Tier 1: Rules That Catch Bugs (Always Enable)
These prevent production incidents. Never disable them.
Tier 2: Rules That Improve Quality (Enable, But Configurable)
These catch code smells and maintainability issues. Worth enabling, but might need team discussion.
Tier 3: Rules That Annoy Developers (Usually Disable)
These enforce style preferences that don't prevent bugs. Let Prettier handle formatting.
The Base Configuration
Start here. This works for 90% of teams.
npm install --save-dev \
eslint \
@typescript-eslint/eslint-plugin \
@typescript-eslint/parser \
eslint-plugin-react \
eslint-plugin-react-hooks \
eslint-plugin-jsx-a11y \
eslint-config-prettier
// .eslintrc.json
{
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": 2022,
"sourceType": "module",
"ecmaFeatures": {
"jsx": true
},
"project": "./tsconfig.json"
},
"plugins": [
"@typescript-eslint",
"react",
"react-hooks",
"jsx-a11y"
],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react/recommended",
"plugin:react-hooks/recommended",
"plugin:jsx-a11y/recommended",
"prettier" // Must be last
],
"rules": {
// Our custom rules go here
},
"settings": {
"react": {
"version": "detect"
}
}
}
Tier 1: Bug-Preventing Rules (Always Enable)
These rules have caught real bugs in production. Never disable them.
1. @typescript-eslint/no-floating-promises
What it catches:
// ❌ Promise rejection unhandled - app crashes
async function deleteUser(id: string) {
await api.delete(`/users/${id}`);
}
function handleClick() {
deleteUser(userId); // Forgot await! Error disappears silently
}
// ✅ Forces you to handle it
async function handleClick() {
await deleteUser(userId);
// or
deleteUser(userId).catch(handleError);
// or explicitly ignore
void deleteUser(userId);
}
Configuration:
{
"rules": {
"@typescript-eslint/no-floating-promises": "error"
}
}
Why it matters: Unhandled promise rejections crash Node.js and cause silent failures in browsers.
2. @typescript-eslint/no-misused-promises
What it catches:
// ❌ Async function in event handler - doesn't wait
<button onClick={async () => {
await saveData();
console.log('Saved!'); // This runs, but React doesn't wait
}}>Save</button>
// ❌ Using promise in conditional
if (fetchData()) { // Always truthy! fetchData returns Promise
// This always runs, even if fetch fails
}
// ✅ Correct
const handleClick = async () => {
await saveData();
console.log('Saved!');
};
<button onClick={handleClick}>Save</button>
// ✅ Correct
const data = await fetchData();
if (data) {
// Now checking actual data
}
Configuration:
{
"rules": {
"@typescript-eslint/no-misused-promises": [
"error",
{
"checksVoidReturn": true,
"checksConditionals": true
}
]
}
}
3. react-hooks/exhaustive-deps
What it catches:
// ❌ Stale closure bug
function Component({ userId }: { userId: string }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser(userId).then(setUser);
}, []); // Missing userId!
// User never updates when userId changes
}
// ✅ Correct dependencies
useEffect(() => {
fetchUser(userId).then(setUser);
}, [userId]);
Configuration:
{
"rules": {
"react-hooks/exhaustive-deps": "error"
}
}
Why it matters: Stale closures are the #1 cause of bugs in hooks-based React apps.
4. @typescript-eslint/no-unnecessary-type-assertion
What it catches:
// ❌ Lying to TypeScript
const value = apiResponse as User; // No validation!
// If API shape changes, TypeScript can't help
value.newProperty; // Runtime error
// ✅ Use type guards instead
function isUser(value: unknown): value is User {
return (
typeof value === 'object' &&
value !== null &&
'id' in value &&
'name' in value
);
}
if (isUser(apiResponse)) {
apiResponse.name; // Safe
}
Configuration:
{
"rules": {
"@typescript-eslint/no-unnecessary-type-assertion": "error"
}
}
5. @typescript-eslint/await-thenable
What it catches:
// ❌ Awaiting non-promise
async function example() {
await nonAsyncFunction(); // Does nothing, misleading
const value = await synchronousValue; // Not a promise
}
// ✅ Only await actual promises
async function example() {
nonAsyncFunction(); // No await needed
const value = synchronousValue; // No await needed
await actualAsyncFunction(); // This needs await
}
Configuration:
{
"rules": {
"@typescript-eslint/await-thenable": "error"
}
}
6. @typescript-eslint/no-unsafe-assignment
What it catches:
// ❌ Unsafe assignment from any
function processData(data: any) {
const user: User = data; // No validation!
user.email.toLowerCase(); // Crashes if data.email is undefined
}
// ✅ Validate before assigning
function processData(data: unknown) {
const validated = UserSchema.parse(data);
validated.email.toLowerCase(); // Safe
}
Configuration:
{
"rules": {
"@typescript-eslint/no-unsafe-assignment": "warn", // Warn first, error later
"@typescript-eslint/no-unsafe-member-access": "warn",
"@typescript-eslint/no-unsafe-call": "warn",
"@typescript-eslint/no-unsafe-return": "warn"
}
}
Note: Start with "warn" because existing code might have many violations. Upgrade to "error" over time.
7. @typescript-eslint/no-unused-vars
What it catches:
// ❌ Dead code
function calculate(a: number, b: number, c: number) {
return a + b; // c is never used - probably a bug
}
// ❌ Unused imports slow down bundle
import { ComponentA, ComponentB, ComponentC } from './components';
// Only using ComponentA
// ✅ Clean code
function calculate(a: number, b: number) {
return a + b;
}
import { ComponentA } from './components';
Configuration:
{
"rules": {
"@typescript-eslint/no-unused-vars": [
"error",
{
"argsIgnorePattern": "^_",
"varsIgnorePattern": "^_",
"caughtErrorsIgnorePattern": "^_"
}
]
}
}
Pro tip: Use _ prefix for intentionally unused variables:
function onClick(_event: MouseEvent) {
// Don't need event, but required by interface
}
8. react-hooks/rules-of-hooks
What it catches:
// ❌ Conditional hooks
function Component({ condition }) {
if (condition) {
const data = useFetch('/api/data'); // Breaks rules of hooks
}
}
// ❌ Hooks in loops
items.map(item => {
const data = useFetch(item.url); // Breaks rules of hooks
});
// ✅ Hooks at top level
function Component({ condition }) {
const data = useFetch(condition ? '/api/data' : null);
}
Configuration:
{
"rules": {
"react-hooks/rules-of-hooks": "error"
}
}
Tier 2: Quality-Improving Rules (Recommended)
These improve code quality but might need discussion with your team.
1. @typescript-eslint/no-explicit-any
The debate:
// Some teams: "any is never okay"
// Other teams: "any is fine for prototypes"
My recommendation: Warn, not error. Allow any with justification.
{
"rules": {
"@typescript-eslint/no-explicit-any": "warn"
}
}
When any is acceptable:
- Working with truly dynamic data (e.g.,
JSON.parse) - Prototyping (temporary)
- Interfacing with untyped libraries (until you add types)
Add a comment when you use any:
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data: any = JSON.parse(response);
// TODO: Add proper type validation with Zod
2. @typescript-eslint/no-non-null-assertion
What it catches:
// ❌ Risky non-null assertion
const user = users.find(u => u.id === userId)!;
user.name; // Crashes if user not found
// ✅ Proper null handling
const user = users.find(u => u.id === userId);
if (!user) throw new Error('User not found');
user.name; // Safe
Configuration:
{
"rules": {
"@typescript-eslint/no-non-null-assertion": "warn"
}
}
When ! is acceptable:
- After explicit null checks
- In tests where you control the data
- When you're 100% certain (document why)
// Acceptable use
const element = document.getElementById('root');
if (!element) throw new Error('Root element not found');
// Now we're certain
ReactDOM.render(<App />, element!);
3. @typescript-eslint/explicit-function-return-type
The debate: Should functions require explicit return types?
My take: No for private functions, yes for public APIs.
{
"rules": {
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/explicit-module-boundary-types": "warn"
}
}
Why:
// ❌ Too verbose for internal code
const add = (a: number, b: number): number => a + b; // Obvious return type
// ✅ Let inference work
const add = (a: number, b: number) => a + b; // TypeScript infers number
// ✅ Explicit for public APIs
export function processUser(user: User): ProcessedUser {
// Return type is part of the contract
return { ...user, processed: true };
}
4. @typescript-eslint/consistent-type-imports
What it does:
// Before
import { User } from './types';
import { api } from './api';
// After
import type { User } from './types';
import { api } from './api';
Why it matters:
- Smaller bundles (type imports are stripped)
- Clearer separation of types vs values
- Faster TypeScript compilation
Configuration:
{
"rules": {
"@typescript-eslint/consistent-type-imports": [
"warn",
{
"prefer": "type-imports",
"fixable": true
}
]
}
}
5. @typescript-eslint/prefer-nullish-coalescing
What it catches:
// ❌ Logical OR treats 0, '', false as falsy
const count = userInput || 0; // If user enters 0, returns 0 (wrong!)
const name = user.name || 'Anonymous'; // If name is '', returns 'Anonymous'
// ✅ Nullish coalescing only treats null/undefined as falsy
const count = userInput ?? 0; // If user enters 0, returns 0 (correct!)
const name = user.name ?? 'Anonymous'; // Only uses default if null/undefined
Configuration:
{
"rules": {
"@typescript-eslint/prefer-nullish-coalescing": "warn"
}
}
6. @typescript-eslint/prefer-optional-chain
What it catches:
// ❌ Old way
const email = user && user.profile && user.profile.email;
// ✅ Optional chaining
const email = user?.profile?.email;
Configuration:
{
"rules": {
"@typescript-eslint/prefer-optional-chain": "warn"
}
}
7. jsx-a11y/* (Accessibility Rules)
Start with recommended, customize as needed:
{
"extends": [
"plugin:jsx-a11y/recommended"
],
"rules": {
// Enforce label association
"jsx-a11y/label-has-associated-control": "error",
// Require alt text
"jsx-a11y/alt-text": "error",
// Warn on missing ARIA labels (not always needed)
"jsx-a11y/aria-label": "warn",
// Some rules are too strict for real apps
"jsx-a11y/no-autofocus": "off", // Sometimes you DO want autofocus
"jsx-a11y/click-events-have-key-events": "warn" // Warn, don't block
}
}
Tier 3: Rules to Disable (Annoying Without Value)
These rules sound good in theory but cause frustration in practice.
1. @typescript-eslint/explicit-function-return-type (Too Verbose)
// ❌ This rule forces:
const add = (a: number, b: number): number => a + b;
// ✅ TypeScript already knows the return type
const add = (a: number, b: number) => a + b;
Disable it:
{
"rules": {
"@typescript-eslint/explicit-function-return-type": "off"
}
}
2. react/prop-types (Redundant with TypeScript)
// ❌ This rule forces PropTypes when you have TypeScript
interface ButtonProps {
label: string;
onClick: () => void;
}
function Button({ label, onClick }: ButtonProps) {
return <button onClick={onClick}>{label}</button>;
}
Button.propTypes = { // Redundant!
label: PropTypes.string.isRequired,
onClick: PropTypes.func.isRequired,
};
Disable it:
{
"rules": {
"react/prop-types": "off"
}
}
3. @typescript-eslint/naming-convention (Too Opinionated)
This rule tries to enforce naming patterns. In practice, it causes more arguments than it's worth.
{
"rules": {
"@typescript-eslint/naming-convention": "off"
}
}
Why: Naming is subjective. Let code review handle it.
4. no-console (Too Aggressive)
// ❌ Blocks legitimate debugging
console.log('User logged in:', userId);
console.error('Payment failed:', error);
Better approach: Allow console, but remove before production
{
"rules": {
"no-console": "off" // Or use a build step to strip console.logs
}
}
5. @typescript-eslint/ban-ts-comment (Too Strict)
Sometimes you need @ts-ignore for legitimate reasons (working around library bugs, etc.)
{
"rules": {
"@typescript-eslint/ban-ts-comment": [
"warn",
{
"ts-ignore": "allow-with-description",
"minimumDescriptionLength": 10
}
]
}
}
This allows @ts-ignore but requires explanation:
// @ts-ignore: Library types are wrong, PR submitted to DefinitelyTyped
const result = libraryFunction(param);
The Complete Configuration
Here's the full config that balances safety and DX:
{
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": 2022,
"sourceType": "module",
"ecmaFeatures": {
"jsx": true
},
"project": "./tsconfig.json"
},
"plugins": [
"@typescript-eslint",
"react",
"react-hooks",
"jsx-a11y"
],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react/recommended",
"plugin:react-hooks/recommended",
"plugin:jsx-a11y/recommended",
"prettier"
],
"rules": {
// === Tier 1: Bug Prevention (Always Error) ===
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/no-misused-promises": "error",
"@typescript-eslint/await-thenable": "error",
"@typescript-eslint/no-unnecessary-type-assertion": "error",
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "error",
"@typescript-eslint/no-unused-vars": [
"error",
{
"argsIgnorePattern": "^_",
"varsIgnorePattern": "^_"
}
],
// === Tier 2: Quality Improvement (Warn) ===
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-non-null-assertion": "warn",
"@typescript-eslint/no-unsafe-assignment": "warn",
"@typescript-eslint/no-unsafe-member-access": "warn",
"@typescript-eslint/no-unsafe-call": "warn",
"@typescript-eslint/prefer-nullish-coalescing": "warn",
"@typescript-eslint/prefer-optional-chain": "warn",
"@typescript-eslint/consistent-type-imports": [
"warn",
{ "prefer": "type-imports" }
],
// === Tier 3: Disable Annoying Rules ===
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/explicit-module-boundary-types": "off",
"react/prop-types": "off",
"react/react-in-jsx-scope": "off", // Not needed in React 17+
"no-console": "off",
// === Accessibility (Customize for your app) ===
"jsx-a11y/no-autofocus": "off",
"jsx-a11y/click-events-have-key-events": "warn"
},
"settings": {
"react": {
"version": "detect"
}
}
}
Performance Optimization
ESLint can be slow on large codebases. Optimize it:
1. Use Type-Aware Rules Selectively
{
"parserOptions": {
// Only use project for files that need type-aware rules
"project": "./tsconfig.json"
},
"overrides": [
{
// Disable type-aware rules for test files
"files": ["**/*.test.ts", "**/*.test.tsx"],
"rules": {
"@typescript-eslint/no-floating-promises": "off"
}
}
]
}
2. Ignore Generated Files
// .eslintignore
node_modules/
dist/
build/
*.config.js
coverage/
.next/
3. Use Caching
// package.json
{
"scripts": {
"lint": "eslint . --cache --cache-location .eslintcache"
}
}
Integration with Prettier
ESLint and Prettier should complement, not fight:
npm install --save-dev eslint-config-prettier
{
"extends": [
// ... other extends
"prettier" // MUST be last
]
}
What Prettier handles:
- Semicolons
- Quotes
- Indentation
- Line length
- Trailing commas
What ESLint handles:
- Code quality
- Bug prevention
- Best practices
Never configure ESLint for formatting. Let Prettier do that.
Gradual Adoption Strategy
Don't enable all rules at once. Roll them out gradually:
Phase 1: Foundation (Week 1)
{
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended"
]
}
Phase 2: React Rules (Week 2-3)
{
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react/recommended",
"plugin:react-hooks/recommended"
]
}
Phase 3: Strict Type Safety (Week 4-6)
{
"rules": {
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/no-misused-promises": "error"
}
}
Phase 4: Quality Rules (Week 7-8)
{
"rules": {
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/prefer-nullish-coalescing": "warn"
}
}
Phase 5: Accessibility (Week 9+)
{
"extends": [
"plugin:jsx-a11y/recommended"
]
}
Handling Existing Violations
When adding new rules to existing codebases:
Option 1: Fix Incrementally
{
"rules": {
"@typescript-eslint/no-explicit-any": "warn" // Start with warning
}
}
Fix warnings over time. Upgrade to "error" when all fixed.
Option 2: Grandfather Existing Code
{
"overrides": [
{
"files": ["src/legacy/**/*"],
"rules": {
"@typescript-eslint/no-explicit-any": "off"
}
}
]
}
New code follows rules. Legacy code exempted (for now).
Option 3: Set Max Warnings
// package.json
{
"scripts": {
"lint": "eslint . --max-warnings 100"
}
}
Allow existing warnings, but don't allow new ones.
VSCode Integration
Make ESLint fix issues on save:
// .vscode/settings.json
{
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
},
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript",
"typescriptreact"
]
}
CI/CD Integration
Block PRs with lint errors:
# .github/workflows/lint.yml
name: Lint
on: [pull_request]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
- run: npm ci
- run: npm run lint
Pro tip: Fail on errors, allow warnings:
{
"scripts": {
"lint": "eslint .",
"lint:ci": "eslint . --max-warnings 0"
}
}
Use lint locally (warnings OK), lint:ci in CI (no warnings).
Common Pitfalls
Pitfall 1: Too Many Rules at Once
Problem: Team gets overwhelmed, disables ESLint entirely.
Solution: Add rules gradually. Measure before/after.
Pitfall 2: Treating Warnings as Noise
Problem: 1000+ warnings. Team ignores them all.
Solution: Either fix warnings or make them errors. Warnings should be temporary.
Pitfall 3: Not Explaining Why
Problem: Team doesn't understand why rules exist.
Solution: Document your rules with links to examples of bugs they prevent.
Pitfall 4: Inconsistent Application
Problem: Rules enforced in CI but not locally.
Solution: Use git hooks to run ESLint pre-commit.
npm install --save-dev husky lint-staged
// package.json
{
"lint-staged": {
"*.{ts,tsx}": ["eslint --fix", "prettier --write"]
}
}
Measuring Success
Track these metrics:
Before ESLint improvements:
- Production bugs per week
- Code review comments about bugs
- Time spent debugging
After ESLint improvements:
- Same metrics
- ESLint violations caught
- Developer satisfaction (survey)
Success looks like:
- Fewer production bugs
- Faster code reviews
- Developers saying "ESLint caught this before I committed"
The Rules Summary
Always Enable (Error)
@typescript-eslint/no-floating-promises@typescript-eslint/no-misused-promises@typescript-eslint/await-thenablereact-hooks/rules-of-hooksreact-hooks/exhaustive-deps@typescript-eslint/no-unused-vars
Usually Enable (Warn)
@typescript-eslint/no-explicit-any@typescript-eslint/no-non-null-assertion@typescript-eslint/prefer-nullish-coalescing@typescript-eslint/prefer-optional-chain@typescript-eslint/consistent-type-imports
Usually Disable
@typescript-eslint/explicit-function-return-typereact/prop-typesno-console@typescript-eslint/naming-convention
Conclusion
Good ESLint configuration is about signal over noise.
The goal isn't to enforce style. The goal is to prevent bugs and improve code quality without annoying developers.
Start conservative:
- Enable bug-preventing rules first
- Add quality rules gradually
- Disable annoying rules
- Let Prettier handle formatting
Measure results:
- Fewer bugs?
- Faster reviews?
- Happier developers?
If ESLint is catching bugs before they ship, it's working. If developers are disabling it, it's not.
The best ESLint config is the one your team actually uses.
What ESLint rules do you love or hate? Share your config in the comments!
Top comments (0)