Type-Aware Linting: Slow, Painful, and Worth It

I held out for so long. Seriously. The idea of adding seconds—whole seconds!—to my linting process just to catch a few edge cases seemed ridiculous. I like my CI fast. I like my feedback loop instant. Why would I want to force ESLint to parse my entire TypeScript project structure just to tell me I missed a return type?

Then last Tuesday happened.

I pushed a bug to production that standard linting ignored, the TypeScript compiler didn’t catch (because I was being lazy with an any cast in a third-party library), and unit tests missed because I mocked the wrong thing. It was a classic “floating promise” situation. The code ran, the request fired, but the UI never updated because the function wasn’t awaited. It just… floated away into the void.

That was the moment I decided to go down the rabbit hole of type-aware linting. And yeah, it is a rabbit hole. But once you come out the other side, you realize you’ve been coding with one eye closed.

It’s Not Just Regex on Steroids

Standard ESLint works on a file-by-file basis. It looks at the text, parses the syntax tree of that specific file, and yells at you if you use var instead of const. It’s fast because it doesn’t need to know what the rest of your project looks like.

Type-aware linting is different. It actually asks TypeScript: “Hey, what is this variable, really?”

This allows you to catch logic errors, not just syntax errors. Here is the classic example that bit me. Without type information, ESLint sees a function call. With type information, it sees a Promise that you ignored.

code editor error message - Broken communication between VSCode Julia terminal and code editor ...
code editor error message – Broken communication between VSCode Julia terminal and code editor …
// The silent killer
async function updateUserData(id, data) {
    const response = await fetch(/api/users/${id}, {
        method: 'POST',
        body: JSON.stringify(data)
    });
    return response.json();
}

function handleSubmit() {
    // STANDARD LINTING: "Looks good to me!"
    // TYPE-AWARE LINTING: "Error: Promises must be awaited, end with a call to .catch, or be explicitly marked as ignored with the void operator."
    updateUserData(123, { name: 'Dave' }); 
    
    console.log('User updated'); // No they weren't, not yet.
}

That console.log fires before the request finishes. If updateUserData throws an error? You’ll never catch it. It’s an unhandled promise rejection waiting to crash your process or clutter your logs.

The Configuration Headache

I won’t lie to you—setting this up is annoying. You can’t just flip a switch. You have to tell ESLint where your tsconfig.json is, and that creates a tight coupling between your linter and your compiler.

If you’re using the Flat Config (which you should be by now), it looks something like this. Note the projectService part—that’s the newer, slightly faster way to do it compared to the old parserOptions.project.

// eslint.config.mjs
import tseslint from 'typescript-eslint';

export default tseslint.config(
  {
    languageOptions: {
      parserOptions: {
        // This is the magic sauce
        projectService: true,
        tsconfigRootDir: import.meta.dirname,
      },
    },
  },
  // Extend the type-checked recommended rules
  ...tseslint.configs.recommendedTypeChecked,
  {
    rules: {
      // My personal favorites
      '@typescript-eslint/no-floating-promises': 'error',
      '@typescript-eslint/await-thenable': 'error',
      '@typescript-eslint/no-misused-promises': 'error',
    }
  }
);

Once you add this, your lint command will take longer. On my mid-sized project, it went from 4 seconds to about 18 seconds. That hurts. But you know what hurts more? Debugging race conditions at 11 PM.

The “Aha” Moments

After I got it running (and fixed the 400+ errors it found—yes, four hundred), I started seeing the value immediately. It wasn’t just catching bugs; it was teaching me better habits.

1. The Useless Await

I have a habit of slapping await on everything just to be safe. “Is this function async? I don’t know, I’ll await it anyway.” Type-aware linting calls me out on this immediately with await-thenable.

programmer frustrated with computer code - Free Frustrated programmer working Image - Technology, Frustration ...
programmer frustrated with computer code – Free Frustrated programmer working Image – Technology, Frustration …
function getLocalConfig() {
    return { mode: 'dark' }; // Synchronous!
}

async function init() {
    // Error: Unexpected await of a non-Promise (object) value.
    const config = await getLocalConfig(); 
}

Why does this matter? Because it’s confusing for the next developer. They see await and assume there’s a side effect or a network call. Removing it clarifies the code’s intent.

2. DOM Event Listeners

This one is subtle. Passing an async function to something that expects a void return is usually fine, but sometimes it leads to swallowed errors. The rule no-misused-promises is aggressive, but it forces you to be explicit.

const button = document.querySelector('#save-btn');

// Error: Promise-returning function provided to attribute where a void return was expected.
button.addEventListener('click', async () => {
    await saveToDatabase();
    alert('Done!');
});

// The fix: Handle the promise explicitly
button.addEventListener('click', () => {
    void saveToDatabase().then(() => {
        alert('Done!');
    }).catch(err => {
        console.error('Failed', err);
    });
});

I actually argued with the linter on this one for a while. “Let me pass async to onClick!” I yelled at my screen. But eventually, I realized it was right. If that click handler fails, where does the error go? Nowhere good.

Making It Usable

magnifying glass on computer code - Program code on computer display in magnifying glass. close-up ...
magnifying glass on computer code – Program code on computer display in magnifying glass. close-up …

The performance hit is real. If you run this on every file save, you will hate your life. The latency is noticeable enough to break your flow.

My strategy has shifted. I now have two lint commands in my package.json:

"scripts": {
  "lint": "eslint .", 
  "lint:fast": "eslint . --config eslint.fast.config.mjs"
}

The “fast” config turns off the type-aware rules and the parser services. I run that on pre-commit hooks or while I’m actively iterating. The heavy, type-aware linting runs in CI/CD. If I break a type rule, the build fails. It’s a compromise, but it keeps my local environment snappy while ensuring safety before deployment.

Another tip: Don’t try to migrate an existing codebase all at once. I tried to fix all those 400 errors in one sitting and nearly burned out. Use ESLint’s “warn” level for the new rules initially, or use a tool to suppress existing violations so you can fix them gradually. If you stop development for three days to fix lint errors, your team will hate you.

Type-aware linting is heavy, complicated, and occasionally infuriating. It forces you to confront the shortcuts you’ve been taking with your types. But the first time it catches a floating promise that would have caused a race condition in production, you’ll forgive it for being slow.

Zahra Al-Farsi

Learn More →

Leave a Reply

Your email address will not be published. Required fields are marked *