TypeScript 中的 Strict 模式:启用了什么

1. 概述

"strict": true 是 tsconfig.json 中最重要的一行。但很多人启用它而不知道实际上启用了什么。本文逐个讲解 strict 激活的标志、每个标志捕获的实际错误、还应该启用哪些额外的标志,以及文末还有一种方式,可以将旧项目迁移到 strict,而不用一次修复数千个错误。

2. strict 是一组标志

strict 不是单一的检查。它是一个总开关,同时启用一组标志:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "strict": true
  }
}

对于 TypeScript 5.x 和 6.x,strict: true 等同于:

{
  "compilerOptions": {
    "noImplicitAny": true,
    "strictNullChecks": true,
    "strictFunctionTypes": true,
    "strictBindCallApply": true,
    "strictPropertyInitialization": true,
    "strictBuiltinIteratorReturn": true,
    "noImplicitThis": true,
    "useUnknownInCatchVariables": true,
    "alwaysStrict": true
  }
}

你需要记住两点:

  • 每当 TypeScript 添加一项新的 “strict” 检查时,例如 4.4 版本中的 useUnknownInCatchVariables 或 5.6 版本中的 strictBuiltinIteratorReturn,该检查会自动根据 strict 启用。因此,升级 TypeScript 可能会产生新错误,即使你没有修改代码。
  • 你可以启用 strict,然后关闭特定的标志。具体的标志总是优先于总开关。

TypeScript 6.0 已将 strict 的默认值改为 true。尽管如此,你仍然应该在 tsconfig.json 中明确指定这个标志,这样读取配置的人不必猜测。

要尝试下面的示例,你只需要安装 TypeScript,然后运行 tsc --noEmit:

npm install --save-dev typescript
npx tsc --noEmit

3. 最重要的标志

3.1. noImplicitAny

当 TypeScript 无法推断类型时,它会隐式地分配 any。此标志将该情况变成错误。

function calculateTotal(items) {
  return items.reduce((sum, item) => sum + item.price, 0);
}
error TS7006: 参数 'items' 隐式具有 'any' 类型。

如果没有此标志,items 是 any。那么 item.prise(拼写错误)仍然会正常编译。解决方法是明确声明类型:

interface OrderItem {
  name: string;
  price: number;
}

function calculateTotal(items: OrderItem[]): number {
  return items.reduce((sum, item) => sum + item.price, 0);
}

此标志仅阻止隐式的 any。如果真的需要,你仍然可以明确写出 any。

3.2. strictNullChecks

这是最有价值的标志。当禁用时,null 和 undefined 可以分配给任何类型。当启用时,它们变成独立的类型,你必须明确处理。

interface Customer {
  id: number;
  email: string;
}

const customers: Customer[] = [{ id: 1, email: "an@example.com" }];

function getCustomerEmail(id: number): string {
  const customer = customers.find((c) => c.id === id);
  return customer.email;
}
error TS18048: 'customer' 可能是 'undefined'。

Array.prototype.find 返回 Customer | undefined。当不启用标志时,TypeScript 忽略 undefined,错误 Cannot read properties of undefined 只在运行时才会暴露。解决方法:

function getCustomerEmail(id: number): string | undefined {
  const customer = customers.find((c) => c.id === id);
  return customer?.email;
}

函数现在返回 string | undefined。返回类型说出了真相,所以调用函数的地方也必须处理未找到的情况。

注意: 不要用 non-null 运算符 customer!.email 来“修复”错误。运算符 ! 只会让编译器沉默,它不会进行任何检查。只有当你确信值存在时才使用,并且有编译器看不到的原因。

3.3. strictPropertyInitialization

此标志要求类的每个属性都必须被赋值,要么在声明时立即赋值,要么在构造函数中赋值。它需要 strictNullChecks 启用才能生效。

class PaymentService {
  apiKey: string;
  retries = 3;

  constructor(private readonly baseUrl: string) {}
}
error TS2564: 属性 'apiKey' 没有初始值设定项,在构造函数中也没有被明确分配。

你有三种方法来处理:

class PaymentService {
  apiKey: string;              // 1. 在构造函数中赋值
  webhookSecret?: string;      // 2. 声明为可选
  client!: HttpClient;         // 3. definite assignment:"相信我,它会被赋值"

  constructor(apiKey: string) {
    this.apiKey = apiKey;
  }
}

interface HttpClient {
  post(url: string, body: unknown): Promise<unknown>;
}

方法 3(!)常见于使用依赖注入或装饰器的框架,例如 Angular 或 TypeORM。那里属性在构造函数运行完后被赋值。除了这样的情况,请优先使用方法 1 或 2。

3.4. useUnknownInCatchVariables

在 JavaScript 中,你可以 throw 任何东西,不一定是 Error。此标志将 catch 中变量的类型从 any 改为 unknown。

async function chargeOrder(orderId: string): Promise<void> {
  try {
    await fetch(`/api/orders/${orderId}/charge`, { method: "POST" });
  } catch (err) {
    console.error(err.message);
  }
}
error TS18046: 'err' 的类型是 'unknown'。

要使用 err,你必须先缩小类型:

catch (err) {
  const message = err instanceof Error ? err.message : String(err);
  console.error(`Charge failed: ${message}`);
}

3.5. strictFunctionTypes

此标志按 contravariant 类型检查函数类型的参数。简单来说:你不能将只能处理窄类型的函数传递给需要处理宽类型的函数的位置。

interface Animal { name: string; }
interface Dog extends Animal { bark(): void; }

const handleDog = (dog: Dog) => dog.bark();

const handleAnimal: (animal: Animal) => void = handleDog;
error TS2322: 类型 '(dog: Dog) => void' 不能分配给类型 '(animal: Animal) => void'。

如果上述赋值被接受,调用 handleAnimal({ name: "Cat" }) 会崩溃,因为传入的对象没有 bark。有一个需要知道的例外:此标志不适用于在接口中以 method(x: T): void 语法声明的方法。这是 TypeScript 的有意选择,以保持与许多现有 API 的兼容性。

3.6. 其他三个标志

这三个标志很少在新代码中引起错误,但仍然值得了解:

function formatPrice(amount: number, currency: string): string {
  return `${amount.toFixed(2)} ${currency}`;
}

formatPrice.call(null, "100", "VND"); // strictBindCallApply

function onClick() {
  console.log(this.id);               // noImplicitThis
}
error TS2345: 类型 'string' 的参数不能分配给类型 'number' 的参数。
error TS2683: 'this' 隐式具有类型 'any',因为它没有类型注解。
  • strictBindCallApply 检查你调用 call、apply 和 bind 时的参数。
  • noImplicitThis 报告当 this 隐式具有类型 any 时的错误。你可以通过声明虚拟参数 function onClick(this: HTMLButtonElement) 来修复。
  • alwaysStrict 将 "use strict" 发出到每个输出文件,并以 JavaScript 的 strict 模式解析代码。对于 ES 模块,strict 模式已经是默认的。

4. strict 不启用的标志

许多人认为 strict: true 是最严格的级别。并非如此。以下标志在 strict 组之外,但对于新项目非常值得启用:

{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noImplicitOverride": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true
  }
}

其中,noUncheckedIndexedAccess 是最重要的标志。默认情况下,TypeScript 认为通过索引访问数组或通过动态键访问对象总是有值的:

const stockBySku: Record<string, number> = { "SKU-1": 10 };

const quantity = stockBySku["SKU-999"];
console.log(quantity.toFixed(0));

没有此标志,quantity 的类型是 number,上面的代码在运行时会崩溃。当启用标志时,quantity 的类型是 number | undefined:

error TS18048: 'quantity' 可能是 'undefined'。

其他标志:

  • exactOptionalPropertyTypes:discount?: number 表示“可能没有键”,而不是允许明确分配 discount: undefined。
  • noImplicitOverride:覆盖父类的方法必须有 override 关键字。这样当父类改变方法名称时,子类会报告错误,而不是静静地创建一个新方法。
  • noImplicitReturns:函数的每个分支都必须 return。
  • noFallthroughCasesInSwitch:阻止在 switch 中忘记 break 的情况。

注意: noUncheckedIndexedAccess 相当“嘈杂”。循环 for (let i = 0; i < arr.length; i++) 也会产生 arr[i] 的类型为 T | undefined。切换到 for...of 或 arr.forEach。对于现有的大型项目,应该最后启用此标志。

5. 用测试检查行为

类型检查在编译时运行,但你仍然应该对运行时行为和返回类型都进行测试。Vitest 两者都支持:

npm install --save-dev vitest
// customer.ts
export interface Customer { id: number; email: string; }

const customers: Customer[] = [{ id: 1, email: "an@example.com" }];

export function getCustomerEmail(id: number): string | undefined {
  return customers.find((c) => c.id === id)?.email;
}
// customer.test.ts
import { describe, it, expect, expectTypeOf } from "vitest";
import { getCustomerEmail } from "./customer";

describe("getCustomerEmail", () => {
  it("找到客户时返回邮箱", () => {
    expect(getCustomerEmail(1)).toBe("an@example.com");
  });

  it("未找到时返回 undefined", () => {
    expect(getCustomerEmail(42)).toBeUndefined();
  });

  it("返回类型包含 undefined", () => {
    expectTypeOf(getCustomerEmail).returns.toEqualTypeOf<string | undefined>();
  });
});

第三个测试是一个类型测试。假设稍后有人将函数签名改为 : string 并使用 ! 来强制转换类型。那么 vitest --typecheck(或在测试文件上运行 tsc --noEmit)会报告错误,因为返回类型不再匹配 string | undefined。

npx vitest run --typecheck
 ✓ customer.test.ts (3 tests)
 Test Files  1 passed (1)
      Tests  3 passed (3)

6. 将旧项目迁移到 strict

在大型代码库上启用 strict: true 可能会产生数千个错误。实际的做法是逐步进行。

第 1 步:启用 strict,但暂时禁用最嘈杂的标志。

{
  "compilerOptions": {
    "strict": true,
    "strictNullChecks": false,
    "strictPropertyInitialization": false,
    "useUnknownInCatchVariables": false
  }
}

noImplicitAny 通常最容易修复,所以先处理它。每次修复完一个标志时,删除禁用该标志的行。新写的代码将自动受到所有启用检查的约束。

第 2 步:按标志计算错误以估计工作量。

npx tsc --noEmit --strictNullChecks true | grep -c "error TS"

第 3 步:使用 strictNullChecks,按目录修复。 用 // @ts-expect-error 临时标记那些还没来得及修复的地方,并注上原因。不要使用 @ts-ignore。原因:当下面的行不再有错误时,@ts-expect-error 会自动报告错误,这样你就不会错过多余的注释。

// @ts-expect-error TODO(strict): customer 可能是 undefined,在 PR #482 中处理
const email: string = findCustomer(id).email;

第 4 步:在 CI 中防止回归。 将 tsc --noEmit 添加到管道中,以防止任何人合并会产生新类型错误的代码。

注意: 不要使用 skipLibCheck 或大量 any 作为“快速启用 strict” 的方式。skipLibCheck: true 只跳过 .d.ts 文件的检查,所以可以保留。而在各处撒布 any 将失去转换的所有好处。

7. 结论

strict: true 启用九项检查。带来最大价值的两项检查是 noImplicitAny 和 strictNullChecks,因为它们在编译时就阻止了大多数 undefined is not a function 和 Cannot read properties of undefined 错误。对于新项目,你应该从一开始就启用 strict 以及 noUncheckedIndexedAccess、noImplicitOverride 和 exactOptionalPropertyTypes。对于旧项目,启用 strict,然后暂时关闭各个子标志,按从容易到难的顺序逐步修复,并使用 CI 确保错误数量只减不增。

typescripttsconfigstrict modetype safety