Ohhnews

分类导航

$ cd ..
Jetbrains Blog原文

如何使用Qodana修复常见的TypeScript问题

#typescript#qodana#静态分析#代码质量

[LOADING...]

大多数 TypeScript 项目已经使用 ESLint 配合 @typescript-eslint。这涵盖了很多内容:显式 any、未处理的 Promise、非空断言等等。如果你的 lint 配置足够完善,你可以在代码审查之前就在编辑器中捕获那些显而易见的问题。

ESLint 规则无法产生跨文件的检查结果。每条规则只在单个文件范围内运行,这意味着 ESLint 无法告诉你某个导出在整个代码库中未被使用、某个文件中 any 类型的值导致五个文件之外出现了不安全假设、或者两个组件独立实现了相同的逻辑。这就是 Qodana 所填补的空白。

以下是五个值得关注的 TypeScript 问题,按 ESLint 能处理的范围和超出其能力范围的情况进行组织。

隐式 any 在代码库中扩散

ESLint 的 no-explicit-any 规则能捕获你显式写出 any 的地方。但它无法追踪 any 从外部来源(例如 response.json()、无类型的第三方库或未类型化的导入)进入代码库后发生了什么。一旦外部类型的 any 值进入你的代码,它会通过属性访问和函数调用静默传播。ESLint 的 no-unsafe-* 规则可以捕获这一点,但前提是你使用了 @typescript-eslint/recommended-type-checked,这需要类型感知的 lint 功能,且远比标准推荐的配置罕见。

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

  return data.profile.name; // 无错误 — 如果 profile 为 undefined 则会崩溃
}

response.json() 在标准库中返回 any。下游所有内容都是无类型的。编译器接受任何属性名和任何方法调用。错误在运行时才会暴露。Qodana 会追踪 any 如何在程序中的文件之间流动。当 any 类型的值到达一个期望特定形状的代码路径时,Qodana 会标记出这种不匹配,即使这距离 any 进入代码库已有多个函数调用。

添加 UserResponse 并不能解决这个问题。它只是将谎言移到了崩溃点附近。正确的做法应该是为边界进行类型化:

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

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

如果 API 响应结构发生了变化,类型错误会在编译时暴露出来。

非空断言被用作捷径

ESLint 的 no-non-null-assertion 规则会统一标记所有的 ! 操作符。这很有效,但许多团队会禁用该规则或添加宽泛的例外情况,因为合法的使用场景(例如在运行时检查之后)会与危险的使用场景一同被标记。信号变得嘈杂,规则被关闭,问题也就从视野中消失了。

$ tsc
function renderUser(user: User | null) {
  return `Hello, ${user!.name}`; // 如果 user 为 null 则运行时崩溃
}

const button = document.querySelector(".submit-btn");
button!.addEventListener("click", handleSubmit); // 如果元素不存在则崩溃

两个例子都能编译通过,没有错误。在可预测的条件下都会崩溃。! 通常是为了消除类型错误而添加的,但并未修复根本问题。

正确的做法是处理 null 的情况:

$ tsc
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 将非空断言作为报告中的一个单独类别来呈现。并非每个 ! 都是错误的,但将它们集中在一起查看,可以更容易地分辨出合法用途和捷径,而不必在嘈杂的规则和完全没有规则之间做选择。

未处理的 Promise

ESLint 的 @typescript-eslint/no-floating-promises 规则很有效,但它是一条类型感知规则。它需要在 ESLint 配置中通过 parserOptions.project 启用 TypeScript 类型检查。在未配置此选项或仅配置了部分代码库的项目中,该规则会在未覆盖的文件上静默地不起作用。

$ tsc
async function onSubmit(data: FormData) {
  saveToDatabase(data); // Promise<void>,未被 await
  router.push("/success"); // 在保存完成之前执行
}

TypeScript 会静默接受这段代码。调用 async 函数但不使用 await 被认为是有效语法,返回值会被丢弃。然而,这种行为是错误的:用户在保存完成之前就看到了成功页面,并且任何数据库错误都会被静默吞掉。

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

Qodana 的分析默认在整个项目范围内都是类型感知的,无需单独配置 ESLint 的 TypeScript 集成。无论项目的 ESLint 设置结构如何,未处理的 Promise 都会被一致地标记出来。

未使用的导出

tsconfig 中的 noUnusedLocals 能捕获文件内未使用的变量。但导出的符号被有意排除在外。从编译器的角度来看,当前文件之外的某些内容可能会导入它们。ESLint 的 eslint-plugin-import 提供了 import/no-unused-modules 规则来检测这一点,但这需要在每次 lint 运行时扫描整个依赖图,并且在大型代码库中会带来显著性能开销。对于大多数项目来说,保持该规则开启并不现实。

$ tsc
// utils/format.ts
export function formatCurrency(n: number): string { ... }
export function formatPercent(n: number): string { ... } // 功能已删除,但仍保留
export function formatBytes(n: number): string { ... }    // 从未在任何地方被导入

三个导出都通过检查而没有警告。但 formatPercentformatBytes 是死代码。它们增加了维护面,拖慢了重构速度,并且会误导开发人员以为导出的符号正在被使用。

检测这一点需要全项目分析。Qodana 在整个代码库中构建引用图,追踪每个导入和重新导出。仅作为源出现、从未作为导入目标的符号会被标记出来。无论是 tsc 还是 ESLint 都无法做到这一点。

跨文件的重复逻辑

ESLint 本身没有重复代码检测功能。存在像 jscpd 这样的独立工具,但它们不属于你的 lint 流水线。这意味着需要单独的设置、单独的维护,以及另一件需要记住的事情。结果是:在组件或工具文件之间复制的逻辑不断累积,却无人标记。

$ tsc
// 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(" ");
}

这不是一个风格问题。它意味着错误修复需要在多个地方应用,而当它们没有被应用时,两个副本之间的行为会静默地产生分歧。

Qodana 在同一个分析过程中检测跨文件的重复代码,该过程也会暴露类型问题和未使用的导出。当它与其他所有问题一起出现在报告中时,它比一个没人记得运行的独立工具更难被优先级排后。

为你的 TypeScript 项目设置 Qodana

以上五个问题都可以通过 Qodana 针对 JavaScript 和 TypeScript 项目的默认配置文件可见。以下是一个最小的 qodana.yaml 文件,可帮助你入门:

$ config
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

如果第一次运行报告了数百个现有问题,请不要因此阻止 CI 采纳。Qodana 的基线功能会将项目的当前状态捕获到一个 qodana.sarif.json 文件中。提交该文件后,从那时起,CI 只会因新引入的问题而失败。现有的遗留问题仍然在报告中可见,但在你逐步处理它们的过程中,不会阻塞每一次 PR。

[LOADING...]

准备好用 Qodana 修复常见的 TypeScript 问题了吗?

试用 Qodana,并告诉我们你的想法。

试用 Qodana Ultimate Plus

我们特别感谢 Qodana 开发者 Lev Liadov 对本指南的贡献。