Qodana logo

Qodana

The code quality platform for teams

How To Fix Common TypeScript Issues With Qodana

How to fix common TypeScript issues with Qodana

Most TypeScript projects already run ESLint with @typescript-eslint. That covers a lot: explicit any, floating promises, non-null assertions, and more. If your linting setup is solid, you’re catching the obvious issues in the editor before code review.

ESLint rules can’t produce cross-file findings. Each rule runs within a single file’s scope, which means ESLint can’t tell you that an export is unused everywhere in the codebase, that an any-typed value from one file is causing unsafe assumptions five files away, or that two components implement the same logic independently. That’s the gap Qodana fills.

Here are five TypeScript issues worth addressing, organized by what ESLint handles and where it runs out of scope.

Implicit any spreading through your codebase

ESLint’s no-explicit-any catches places where you write any. It doesn’t track what happens when any enters your codebase from external sources, such as from response.json(), a third-party library without types, or an untyped import. Once an externally-typed any value enters your code, it propagates silently through property accesses and function calls. ESLint’s no-unsafe-* rules can catch this, but only if you’re using @typescript-eslint/recommended-type-checked, which requires type-aware linting and is significantly less common than the standard recommended config.

async function getUser(id: string) {
  const res = await fetch(`/api/users/${id}`);
  const data = await res.json(); // type: any

  return data.profile.name; // no error — crashes if profile is undefined
}

response.json() returns any in the standard lib. Everything downstream is untyped. The compiler accepts any property name and any method call. The bug surfaces at runtime. Qodana tracks how any flows through the program across files. When an any-typed value reaches a code path where a specific shape is assumed, it flags the discrepancy, even if that’s several function calls away from where any entered the codebase.

Adding UserResponse doesn’t fix this. It just moves the lie closer to the crash. Type the boundary instead:

interface UserResponse {
  profile: { name: string };
}

const data: UserResponse = await res.json();

If the API response shape changes, the type error surfaces at compile time.

Non-null assertions used as shortcuts

ESLint’s no-non-null-assertion flags every ! operator uniformly. That works, but many teams disable the rule or add broad exceptions because legitimate uses, like after a runtime check, get flagged alongside the dangerous ones. The signal gets noisy, the rule gets turned off, and the problem disappears from view.

function renderUser(user: User | null) {
  return `Hello, ${user!.name}`; // crashes at runtime if user is null
}

const button = document.querySelector(".submit-btn");
button!.addEventListener("click", handleSubmit); // crashes if element doesn't exist

Both examples compile without errors. Both crash under predictable conditions. The ! is often added to silence a type error without fixing the underlying issue.

The correct approach is to handle the null case:

function renderUser(user: User | null) {
  if (!user) return "Guest";
  return `Hello, ${user.name}`;
}

const button = document.querySelector(".submit-btn");
if (button) {
  button.addEventListener("click", handleSubmit);
}

Qodana surfaces non-null assertions as a separate category in the report. Not every ! is wrong, but seeing them gathered in one place makes it easier to distinguish the legitimate uses from the shortcuts, without having to choose between a noisy rule and no rule at all.

Floating promises

ESLint’s @typescript-eslint/no-floating-promises is effective, but it’s a type-aware rule. It requires TypeScript type checking to be enabled in your ESLint config via parserOptions.project. In projects where that’s not configured, or configured only for part of the codebase, the rule silently doesn’t run on uncovered files.

async function onSubmit(data: FormData) {
  saveToDatabase(data); // Promise<void>, not awaited
  router.push("/success"); // runs before save completes
}

TypeScript accepts this without complaint. Calling an async function without await is considered valid syntax, and the return value is discarded. However, this behavior is incorrect: The user sees the success page before the save completes, and any database error is silently swallowed.

async function onSubmit(data: FormData) {
  await saveToDatabase(data);
  router.push("/success");
}

Qodana’s analysis is type-aware by default across the whole project, without requiring ESLint’s TypeScript integration to be separately configured. Floating promises get flagged consistently regardless of how the project’s ESLint setup is structured.

Unused exports

noUnusedLocals in tsconfig catches unused variables within a file. Exported symbols are excluded by design. From the compiler’s perspective, something outside the current file might import them. ESLint’s eslint-plugin-import provides an import/no-unused-modules rule that can detect this, but it requires scanning the entire dependency graph on every lint run and carries significant performance overhead on large codebases. For most projects, it’s not practical to keep it enabled.

// utils/format.ts
export function formatCurrency(n: number): string { ... }
export function formatPercent(n: number): string { ... } // removed feature, still here
export function formatBytes(n: number): string { ... }    // never imported anywhere

All three pass without a warning. But formatPercent and formatBytes are dead code. They add maintenance surface, slow down refactors, and mislead developers who assume exported symbols are in use.

Detecting this requires whole-project analysis. Qodana builds a reference graph across the entire codebase and tracks every import and re-export. Symbols that appear only as sources, never as import targets, get flagged. Neither tsc nor ESLint can do this.

Duplicated logic across files

ESLint doesn’t have native duplication detection. Standalone tools like jscpd exist for this, but they’re not part of your linting pipeline. That means separate setup, separate maintenance, and another thing to remember. The result: logic that gets copied between components or utility files accumulates without anyone flagging it.

// components/UserCard.tsx
function formatUserName(user: User): string {
  if (!user.firstName && !user.lastName) return "Anonymous";
  return [user.firstName, user.lastName].filter(Boolean).join(" ");
}
// components/UserBadge.tsx
function getDisplayName(user: User): string {
  if (!user.firstName && !user.lastName) return "Anonymous";
  return [user.firstName, user.lastName].filter(Boolean).join(" ");
}

This isn’t a style issue. It means bug fixes need to be applied in multiple places, and when they aren’t, behavior diverges silently between the two copies.

Qodana detects duplicated code across files as part of the same analysis pass that surfaces type issues and unused exports. When it appears in the report alongside everything else, it’s harder to deprioritize than a separate tool nobody remembers to run.

Setting up Qodana for your TypeScript project

All five issues above are visible in Qodana’s default profile for JavaScript and TypeScript projects. Here is a minimal qodana.yaml to get you started:

  version: "1.0"
  linter: jetbrains/qodana-js:2026.1                                                                                                                                                                                                             
  bootstrap: npm ci                                                                                                                                                                                                                              
  profile:                                                                                                                                                                                                                                       
    name: qodana.recommended                                                                                                                                                                                                                     
  failThreshold: 0                                                                                                                                                                                                                               
  exclude:        
    - name: All                                                                                                                                                                                                                                  
      paths:
        - dist                                                                                                                                                                                                                                   
        - node_modules

If the first run surfaces hundreds of existing issues, don’t let that block CI adoption. Qodana’s baseline feature captures the current state of the project in a qodana.sarif.json file. Commit it, and from that point on, CI only fails on newly introduced problems. The existing backlog stays visible in the report, but it doesn’t block every PR while you work through it.

Fix common TypeScript issues

Ready to fix common TypeScript issues with Qodana?

Try Qodana and let us know what you think.

Try Qodana Ultimate Plus

We’d like to extend a special thank-you to Qodana developer Lev Liadov for his contribution to this guide.

Discover more