Well, that Tuesday debugging session was a real headache, wasn’t it? The culprit? A single, unawaited promise in a background job that silently failed, causing a cascading memory leak that took down our staging cluster. Embarrassing, to say the least.
The worst part wasn’t the downtime. It was realizing that a simple ESLint rule could have caught it three weeks ago when I wrote the code. We tend to treat linters like glorified spellcheckers — tools to nag us about semicolons or indentation — when they should be our safety net against stupidity.
And if you’re writing TypeScript in 2026 without type-aware linting enabled, you’re basically driving a Ferrari with the handbrake on. You have the engine, but you aren’t using the power. Here’s how I actually set up TypeScript ESLint to catch the bugs that AI assistants and tired developers (myself included) keep introducing.
The “Flat Config” Reality Check
Remember the collective groan when ESLint forced the migration to eslint.config.js? Yeah, I hated it too. But now that I’ve converted my fourth monorepo, I have to admit: it’s better. Not easier, just better.
The biggest mistake I see? People copy-pasting the “recommended” config and calling it a day. The recommended config is weak. It’s designed not to annoy beginners. But you aren’t a beginner.
Here’s the setup I’m currently running on a Node 24.2 backend service. It uses typescript-eslint‘s strict type-checked rules.
// eslint.config.mjs
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';
export default tseslint.config(
eslint.configs.recommended,
...tseslint.configs.strictTypeChecked,
...tseslint.configs.stylisticTypeChecked,
{
languageOptions: {
parserOptions: {
projectService: true, // The game changer for performance
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
// My non-negotiables
'@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/await-thenable': 'error',
'@typescript-eslint/no-explicit-any': 'warn',
'no-console': ['warn', { allow: ['warn', 'error'] }],
},
}
);
Catching the “AI Hallucination” Bugs
We all use AI to scaffold code now. It’s faster. But AI is confident and wrong. It loves to invent APIs or assume types that don’t match reality. This is where type-aware linting saves your bacon.
1. The Async Trap
Here’s a classic scenario. You ask an AI to write a function that updates a user profile and logs an event. It gives you this:
async function updateUser(userId: string, data: UserData) {
const user = await db.users.update(userId, data);
// ❌ BAD: This promise is floating!
// If analytics fails, nobody knows. If it throws, unhandled rejection.
analytics.track('user_update', { userId });
return user;
}
The fix isn’t always to await it (you might not want to block). The fix is to be explicit:
async function updateUser(userId: string, data: UserData) {
const user = await db.users.update(userId, data);
// ✅ GOOD: Explicitly handle the background task
void analytics.track('user_update', { userId }).catch((err) => {
logger.error('Analytics failed', err);
});
return user;
}
DOM Manipulation: Where Null Goes to Die
Don’t do this:
function setupModal() {
// ❌ The '!' tells TypeScript "trust me", but runtime doesn't care about trust
const modal = document.getElementById('user-modal')!;
const input = modal.querySelector('input')!;
input.value = 'default';
}
The linter forces you to write defensive code that actually survives in the wild:
function setupModal() {
const modal = document.getElementById('user-modal');
if (!modal) return; // Fail gracefully
const input = modal.querySelector('input');
// Optional chaining is your friend
if (input) {
input.value = 'default';
}
}
API Responses: Trust Nothing
Another area where I see people get lazy is typing API responses. It’s tempting to slap any on a fetch result just to get the red squigglies to go away. But any leaks. It spreads through your codebase like a virus, disabling type checking everywhere it touches.
interface ApiResponse {
id: number;
title: string;
}
async function fetchData(url: string): Promise<ApiResponse> {
const response = await fetch(url);
// ✅ Better: Type assertion (still risky, but explicit)
const data = (await response.json()) as unknown;
if (!isValidResponse(data)) {
throw new Error('Invalid schema');
}
return data;
}
// Simple type guard
function isValidResponse(data: unknown): data is ApiResponse {
return (
typeof data === 'object' &&
data !== null &&
'id' in data &&
'title' in data
);
}
Performance: The Elephant in the Room
Here’s the trick I used to survive the revolt:
- Split the config. We run standard (fast) linting on
git commithooks. It catches syntax errors and style issues instantly. - Run heavy linting in CI only. The type-aware rules run in the pipeline. If you break a type rule, you find out 5 minutes later, not 5 seconds later. It’s a trade-off, but it keeps the local dev loop fast.
- Use
tsc --noEmitfirst. Often, running the TypeScript compiler directly is faster than running ESLint with type information. I usually runtscfirst to catch actual type errors, then let ESLint catch the logic bugs.
Final Thoughts
Tools like TypeScript ESLint aren’t there to make your code “pretty.” They’re there to compensate for the fact that JavaScript is inherently chaotic and our brains (and our AI assistants) are prone to shortcuts. I was skeptical about the strict configs initially. But after avoiding three potential production fires in Q4 2025 solely because the linter flagged a “misused promise” or an “unsafe return,” I’m converted. Turn on the strict rules. Fix the errors. Your future self, debugging at 2 AM on a Tuesday, will thank you.
If you’re looking to improve your TypeScript testing strategy, check out our article on TypeScript Tests That Actually Catch Bugs (And Don’t Make You Cry).
