跳到正文

我所理解的 TypeScript 最佳实践(2):严格模式

我所理解的 TypeScript 最佳实践

  1. 为什么选择 TypeScript
  2. 严格模式(当前)
  3. 状态、映射与泛型
  4. 模块边界与 TypeScript 脚本
  5. 类型检查、Lint 与测试
  6. TypeScript 7 升级与工具链兼容

写了 User 类型,为什么下面的代码还可能在运行时崩溃?find 可以找不到人。如果编译器允许直接读 user.name,调用方看到的类型就漏掉了一条真实的路径。

interface User {
  id: string;
  name: string;
}

function getDisplayName(users: User[], id: string) {
  const user = users.find((item) => item.id === id);
  return user.name;
}

为什么需要严格模式

早期 TypeScript 允许 null 和 undefined 赋给许多普通类型。既有 JavaScript 可以比较容易迁入,但从 User 本身看不出它是否可能缺失。TypeScript 2.0 的 strictNullChecks让缺失值在类型里有了独立位置。上面的 find 结果变成 User | undefined,直接取属性会报错。

类似的检查逐渐增多。比如参数没写类型、也无法从上下文推断时,noImplicitAny 会提醒开发者补齐约定。到 TypeScript 2.3,这些检查可以通过 strict 一起开启。一个项目可以声明自己采用严格检查,以后新增的严格检查也可能随编译器升级生效。这样一来,新建文件、扩充模块或升级编译器时,团队有了一致的默认边界。参见 strict 的配置说明。

strict 具体检查什么

以下按 TypeScript 6.0.3、7.0.2 和 7.1.0-beta 对照。每组第一段在 strict: false 下能通过,在 strict: true 下会报错。第二段是处理了该问题的版本,可以通过严格检查。三个版本都实际跑过这组示例。

从 TypeScript 6.0起,strict 默认就是 true。项目仍可以显式写出这个值,让团队一眼看出采用的检查标准,也方便核对继承配置是否关闭了某项。下面说的「关闭」指明确设成 false,不是删掉配置项。

noImplicitAny:隐式 any 让参数失去检查

参数无法从上下文推断时,宽松模式会把它当成 any。调用方传数字也能通过,但函数会在执行 trim() 时失败。

关闭检查时能通过的写法:

function normalizeId(id) {
  return id.trim();
}
normalizeId(42);

开启后可以这样处理:

function normalizeId(id: string) {
  return id.trim();
}
normalizeId('42');

strictNullChecks:缺失值也属于返回类型

列表查询可能找不到结果。开启后,find() 的返回值包含 undefined,必须决定缺失时的行为。示例把它当作正常空态。

关闭检查时能通过的写法:

const users = [{ id: '1', name: 'Ada' }];
const user = users.find((item) => item.id === '2');
console.log(user.name);

开启后可以这样处理:

const users = [{ id: '1', name: 'Ada' }];
const user = users.find((item) => item.id === '2');
console.log(user?.name ?? '未知用户');

strictFunctionTypes:回调必须能接住调用方传来的值

声明为接收任意 Notice 的回调,可能收到系统通知。只会处理邮件的函数不能交给它。这里用函数类型演示。方法声明有兼容性例外,设计回调属性时可以使用 handle: (notice: Notice) => void。

关闭检查时能通过的写法:

type Notice = { kind: 'email'; address: string } | { kind: 'system' };
let handle: (notice: Notice) => void;
handle = (notice: Extract<Notice, { kind: 'email' }>) => {
  console.log(notice.address.toLowerCase());
};
handle({ kind: 'system' });

开启后可以这样处理:

type Notice = { kind: 'email'; address: string } | { kind: 'system' };
const handle = (notice: Notice) => {
  if (notice.kind === 'email') {
    console.log(notice.address.toLowerCase());
  }
};
handle({ kind: 'system' });

strictBindCallApply:间接调用也检查实参

call、apply 和 bind 都能间接调用函数。开启后,检查器把它们的实参和原函数的参数类型对应起来。

关闭检查时能通过的写法:

const format = (id: string) => id.trim();
format.call(undefined, 42);

开启后可以这样处理:

const format = (id: string) => id.trim();
format.call(undefined, '42');

strictPropertyInitialization:构造完毕的对象应当兑现字段声明

token: string 承诺调用方拿到的是字符串。检查器会要求字段有初始值,或者在构造函数返回前完成赋值。确实允许迟到的值,可以显式写成可缺失的类型。

关闭检查时能通过的写法:

class Session {
  token: string;
}
console.log(new Session().token.trim());

开启后可以这样处理:

class Session {
  token: string;

  constructor(token: string) {
    this.token = token;
  }
}
console.log(new Session('abc').token.trim());

strictBuiltinIteratorReturn:内置迭代器耗尽时也有返回值

空集合的迭代器第一次调用 next() 就会结束。该选项让内置迭代器的结束值使用 undefined,避免 .value 被 any 放宽。配合 strictNullChecks,先检查 done,就能在成功分支拿到真正的元素。自定义迭代器的返回类型由它自己的签名声明。

关闭检查时能通过的写法:

const values = new Set<number>();
const result = values.values().next();
console.log(result.value.toFixed());

开启后可以这样处理:

const values = new Set<number>();
const result = values.values().next();
if (!result.done) {
  console.log(result.value.toFixed());
}

noImplicitThis:函数里的 this 也要有上下文

普通函数可以通过 call 更换接收者。缺乏上下文时,this.name 可能只是对 any 取属性。这里显式声明 this 参数,让编辑器和调用方知道接收者的形状。

关闭检查时能通过的写法:

function getLabel() {
  return this.name.toUpperCase();
}
getLabel.call({ name: 'Ada' });

开启后可以这样处理:

function getLabel(this: { name: string }) {
  return this.name.toUpperCase();
}
getLabel.call({ name: 'Ada' });

useUnknownInCatchVariables:捕获的值可能不是 Error

JavaScript 允许抛出字符串或普通对象。开启后,catch 变量默认是 unknown。先用 instanceof Error 缩小类型,再访问 message。

关闭检查时能通过的写法:

try {
  throw '网络失败';
} catch (error) {
  console.log(error.message.toUpperCase());
}

开启后可以这样处理:

try {
  throw '网络失败';
} catch (error) {
  console.log(error instanceof Error ? error.message : String(error));
}

严格检查之外的开关

旧版 strict 还包含 alwaysStrict,它关系到 JavaScript 的严格语义和编译输出里的 "use strict"。从 TypeScript 6.0 的变更开始,编译器默认按 JavaScript 严格语义处理,关闭类型检查的 strict 不会把脚本变回宽松的 JavaScript。上面列的是这组版本中随 strict 联动的八项检查。

  • noUncheckedIndexedAccess让未经证明存在的数组下标和索引签名查找结果包含 undefined。字典用外部 ID 查数据时尤其有用。
  • exactOptionalPropertyTypes区分省略属性和显式赋值为 undefined。比如 theme?: string 允许不传 theme,如果也要允许显式传 undefined,类型里要写出来。

这两项需要单独启用。严格模式也无法从类型注解验证网络返回的 JSON,外部输入仍要在运行时检查。

在数据进入应用时确认形状

JSON.parse(text) as Snapshot 只告诉编译器如何看待结果。读取公开 API、用户导入文件或本地旧缓存时,至少要检查当前操作依赖的字段。数据量小、结构简单时,可以在入口根据字段检查收窄类型。协议较大、多个入口需要执行同一套校验时,再考虑复用 schema。下面这段只验证合同到期日是字符串或 null,日期格式和精度可以由后续的日期解析处理。

interface ContractDto {
  personId: string;
  expiresAt: string | null;
}

function parseContract(value: unknown): ContractDto {
  if (typeof value !== 'object' || value === null || !('personId' in value) || !('expiresAt' in value)) {
    throw new Error('合同记录缺少必填字段');
  }

  const { personId, expiresAt } = value;
  if (typeof personId !== 'string' || (typeof expiresAt !== 'string' && expiresAt !== null)) {
    throw new Error('合同记录字段格式错误');
  }

  return { personId, expiresAt };
}

const contract = parseContract(JSON.parse('{"personId":"123","expiresAt":null}'));

这里 null 表示来源没有给出日期。旧快照如果连字段都没有,可以在旧版本读取入口明确迁移为 null,或者判定快照不可用。对于由项目自己生成、写入契约已受控的内部文件,我会在读取点集中使用类型断言,并检查版本及关键字段。外部输入则要经过实际校验。

接口 DTO 记录的是传输形状。比如 ProtoJSON 的类型映射默认把 int64、uint64 序列化为字符串。一个值在文档里看上去是整数,也可能是超过 JavaScript 安全整数范围的用户 ID,前端应按真实响应保留字符串。页面需要计算倒计时时,再在转换层检查范围和单位。

我通常让 DTO 归接口所在的业务域,组件直接消费已确认的接口类型,或者消费由它转换出的展示模型。两个接口今天碰巧都有 id 和 title,它们明天仍可能独立变化,是否共享类型要看业务所有权。只给一个函数、composable 或测试夹具使用的参数类型留在附近。跨组件和请求层共享的领域契约,再放到本业务域的 types/ 下。组件成立所需的数据写成必传,让父层在加载完成后再渲染。只有省略后仍有独立、合理的展示行为时,才给 prop 设默认值。

哪些选项要额外打开

strict 管的是前面逐项演示的八项检查。下面三份配置都另外打开 noUncheckedIndexedAccess 和 exactOptionalPropertyTypes。前者保护数组与字典查找,后者让可选字段按真实语义区分「没传」和「传了 undefined」。新项目可以从这两个约束开始,已有项目可分别迁移。

配置项 在这些模板里的用途
strict 一起开启上文的八项检查,子选项仍能被显式覆盖。
noUncheckedIndexedAccess、exactOptionalPropertyTypes 额外推荐的类型检查,独立于 strict。
isolatedModules、verbatimModuleSyntax 按实际工具链约束转译与导入写法。前者检查单文件转译无法安全处理的写法,后者要求区分类型导入和值导入。
noEmit 让 tsc 或 vue-tsc 负责检查,交由打包器或运行器处理执行。
target、lib、module、moduleResolution、types 按浏览器、打包器或 Node 的真实环境选择。

三份可以直接用的配置

普通 TypeScript 浏览器项目

这份适合交给打包器处理的普通 TypeScript 项目。保存为项目根目录的 tsconfig.json,源码放 src/,测试放 test/。安装 typescript 后执行 pnpm exec tsc -p tsconfig.json。

{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "strict": true,
    "noEmit": true,
    "isolatedModules": true,
    "verbatimModuleSyntax": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true
  },
  "include": ["src/**/*.ts", "test/**/*.ts"]
}

Vue 3 和 Vite

这份配置适合 Vue 3、Vite 和 <script setup lang="ts">。项目需要 vue、vite、typescript 和 vue-tsc,后两者要用彼此兼容的版本。运行 pnpm exec vue-tsc -p tsconfig.json,检查会覆盖组件脚本和模板。若已有 create-vue 或 Nuxt 的配置,保留它生成的引用、别名和框架类型,再把这些严格选项加到应用使用的配置里。此处启用 skipLibCheck,避免依赖包的声明文件因项目的额外严格选项而阻塞检查。实测 Vue 3.6.0-rc.14 的部分声明会在 exactOptionalPropertyTypes 下报错,启用后项目自己的代码和模板仍会被检查。详见 Vue 的 TypeScript 指南。

{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "strict": true,
    "noEmit": true,
    "isolatedModules": true,
    "verbatimModuleSyntax": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "skipLibCheck": true,
    "types": ["vite/client"]
  },
  "include": ["src/**/*.ts", "src/**/*.vue", "test/**/*.ts"]
}

用 tsx 执行的 Node 脚本

这份以 ESM 为前提。在 package.json 中设置 "type": "module",安装 typescript、@types/node 和 tsx。例如把入口放在 tools/main.ts,用 pnpm exec tsx tools/main.ts 运行,再用 pnpm exec tsc -p tsconfig.json 检查。allowImportingTsExtensions 允许入口直接写 import { run } from './run.ts',与这里的源码运行和 noEmit 配套。若要先生成 JavaScript 再交给 Node,应按产物的导入路径配置。

{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2022"],
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "noEmit": true,
    "verbatimModuleSyntax": true,
    "allowImportingTsExtensions": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "types": ["node"]
  },
  "include": ["tools/**/*.ts", "test/**/*.ts"]
}

旧项目怎样逐步打开

  1. 先看实际生效的配置和检查范围。 用 pnpm exec tsc -p tsconfig.json --showConfig 核对继承项,找出显式关闭的严格选项,并确认源码、脚本和测试有没有被漏掉。省略的选项还要按项目当前编译器版本的默认值判断。
  2. 选一块依赖边界清楚的代码试开。 可以从工具脚本、独立包或项目引用中的一个子项目开始。先修共享输入、返回类型和缺失分支,通常能同时消掉多个调用点的报错。单独配置也会检查被导入的依赖,所以迁移单位应以真实的模块边界为准。
  3. 暂时关闭确实无法一起迁移的检查。 可以先设 strict: true,再将仍待迁移的个别选项显式设为 false,逐项删除覆盖。先让 strictNullChecks 生效,才能单独验收依赖它的 strictPropertyInitialization。避免在每个报错点加 as any 或 !,否则迁移后仍然看不到原本要找的失败路径。
  4. 把已通过的范围固定下来。 将对应的检查命令纳入 CI,再扩大到下一个模块。等严格检查稳定后,根据字典访问和可选字段的业务含义,分别评估额外的两项检查。

迁移过程中可以留一条最小的回归样例。查不到用户是正常结果,就用测试确认界面会展示空态。某条调用必须返回用户,就在明确的边界抛出有上下文的错误。以后有人修改查询或缓存,这些约定仍然能被检查到。

LoL 职业库的实际迁移

这个项目的 Nuxt 应用已有自己的类型检查入口。缺口在另一边:采集工具和测试由 tsx 执行,能运行并不等于已做类型检查。迁移时先给 tools/ 和 test/ 建立独立的 tsconfig.tools.json,保持 Nuxt 继续使用生成的配置,再尝试打开工具和测试的 strict。

第一轮报错集中在数据进入程序的位置和可能缺失的查询结果。采集快照用 RawArchive 表达读取时所依赖的结构,Wiki API 响应也补了类型。只在项目自己写入的快照边界声明类型,遇到外部输入时仍检查实际内容。SQLite 查询可能返回空结果,测试如果预期一定存在,就先 assert.ok(row),让断言同时验证行为和收窄类型。对 catch 收到的 unknown,则先判断它是否为 Error,再读取消息。

工具和测试通过 strict 后,再分别试开两项额外检查。Nuxt 生成的配置已经启用了 noUncheckedIndexedAccess,所以索引检查的新报错来自工具和测试。用 pnpm exec tsc -p tsconfig.tools.json 分别加上 --noUncheckedIndexedAccess 和 --exactOptionalPropertyTypes,可以在修改配置前看清各自的影响。Vue 模板则单独用 vue-tsc 验证。

索引检查让分组保证可见

阵容导出先按人物分组,再从每组的第一条记录读取名字等资料。原来的 Map 把值写成 RawRoster[]。这个类型允许空数组,所以开启检查后,从 entries[0] 取出的记录可能是 undefined。分组代码总是用第一条阵容记录创建分组,之后只追加记录,因此可以把这个保证写进类型。

// 修改前
const grouped = new Map<string, RawRoster[]>();

// 修改后
const grouped = new Map<string, [RawRoster, ...RawRoster[]]>();

来自外部数据的索引则需要检查实际值。例如,队徽和头像的采集脚本从 Wiki 图片路径提取文件名,路径缺少对应片段时直接按无图片处理,避免拼出一个文件名为 undefined 的链接。测试里预期某条查询记录一定存在时,先断言取到了记录,再检查其字段。

精确可选属性检查让缺失值保持一致

采集比赛统计时,HTML 属性读取结果是 string | undefined。统计项允许省略 field,有这个键时则要求它是字符串。直接把读取结果放进对象,会在缺少标题时留下一个值为 undefined 的键。构造对象时可以只在有值时写入该键。

// 修改前
return { field, value };

// 修改后
return { ...(field === undefined ? {} : { field }), value };

Vue 模板里的队徽、头像和国家字段也有类似的边界。子组件已经用 null 表示没有数据,父组件就把来源里缺失的值转成 null,例如 :logo="team.logo ?? null"。两个选项一起开启时,赛事采集又多暴露了一处同类问题。赛事标题切分后的第一段被推断为可能缺失,传给存储层时便需要按其可选字段约定省略 league。

项目最终在 Nuxt 配置和 tsconfig.tools.json 同时启用了这两项检查。pnpm check 会依次检查 lint、Nuxt 与工具代码的类型、测试和格式。