跳到主要内容

第 12 章 · 类型安全与工程质量

读完本章你会
  • 吃透 strict 及其子开关,知道每个开关防的是什么
  • 学会写「类型测试」:用 Expect / Equal 断言类型行为正确
  • 了解 TypeScript + ESLint 的分工,配好基础规则
  • 掌握 JS → TS 渐进迁移的三种策略,不搞「大爆炸」重写

12.1 strict 全家桶:每个开关防什么

strict: true 是以下开关的合集。逐个认识它们,你就知道「严格模式到底在保护什么」:

开关保护什么反例
strictNullChecksnull/undefined 不能随便混进其他类型let n: number = null
noImplicitAny不允许「看不见的 any」参数没标类型
strictFunctionTypes函数参数不能「宽进窄出」回调参数类型不符
strictPropertyInitialization类字段必须初始化声明了 x: number 却不在构造器赋值
useUnknownInCatchVariablescatch (e)eunknown 而非 anye.message 直接访问
noUncheckedIndexedAccess(不在 strict 内,建议开)下标访问可能为 undefinedarr[i] 不判空
两个最容易踩的
  • strictNullChecks:最影响日常写法的开关。开了它,所有可能为空的访问都要先判空——这是「别扭但安全」的代价。
  • useUnknownInCatchVariablescatch (e)e 变成 unknown,访问 e.message 前必须先收窄。很多人不习惯,但它逼你处理「异常到底是什么」。
// strict: true + useUnknownInCatchVariables
try {
JSON.parse('bad')
} catch (e) {
e.message // ❌ e 是 unknown
if (e instanceof Error) e.message // ✅ 收窄后
}
建议的 tsconfig 起点
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true
}
}

noUncheckedIndexedAccessexactOptionalPropertyTypes 是「更严格但更安全」的两项,新项目建议一并开。


12.2 类型测试:像测逻辑一样测类型

12.2.1 为什么需要类型测试

你写的工具类型(PartialAwaited、自己造的 DeepPartial……)也是代码,会写错。类型测试就是用断言锁住类型行为,防止重构时悄悄变坏。type-challenges 的每一道题本质都是一个「类型测试用例」。

12.2.2 Expect 与 Equal

两个核心断言工具:

// 断言 T 必须为 true,否则编译报错
type Expect<T extends true> = T

// 严格比较两个类型是否「一模一样」(不是互相可赋值,是完全相等)
type Equal<X, Y> =
(<T>() => T extends X ? 1 : 2) extends
(<T>() => T extends Y ? 1 : 2) ? true : false

用它们给类型写「测试」:

type MyAwaited<T extends PromiseLike<any>> = /* ... */

type cases = [
Expect<Equal<MyAwaited<Promise<string>>, string>>,
Expect<Equal<MyAwaited<Promise<Promise<number>>>, number>>,
]

cases 数组里任何一行 Expect 不满足,编译就报错。这就是类型测试——把 tsc --noEmit 当成测试运行器。

Equal 为什么这么写

<T>() => T extends X ? 1 : 2 这套「函数签名比较」能区分 booleantrue、区分 readonly 与可变——比 A extends B ? B extends A 严格得多。本书挑战题的参考答案就是靠它验证的。

12.2.3 @ts-expect-error:断言「这里必须报错」

类型测试还能断言「负例」——某行必须编译报错:

type cases = [
Expect<Equal<First<[1, 2]>, 1>>, // 正例
// @ts-expect-error // 负例:下面这行必须报错
First<'notAnArray'>,
]

如果 First<'notAnArray'> 没有报错,@ts-expect-error 会变成「多余的」→ 编译报错。于是「错误的用法必须被拦截」也成了可测试的约束。

测试的组织

把类型测试集中到一个 test-types.ts(或每个工具旁边放一个),接入 npm run typecheck类型测试 + 单测是两套互补的测试:单测验行为,类型测试验「类型行为」。


12.3 TypeScript + ESLint

12.3.1 分工

工具管什么
TypeScript(tsc)类型检查(规则 2322、2304……)
ESLint + typescript-eslint代码风格与「类型启发」的规则(不许 any、不许未使用变量……)

两者互补:tsc 管「类型对不对」,ESLint 管「写得规不规范」。

12.3.2 基础配置

npm i -D eslint typescript-eslint
// eslint.config.js
import tseslint from 'typescript-eslint'

export default tseslint.config(
...tseslint.configs.recommended,
{
rules: {
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': 'error',
'@typescript-eslint/consistent-type-imports': 'error', // import type 必须写 type
},
},
)

12.3.3 三条最值得开的规则

规则作用
no-explicit-any禁止裸 any(逼你想清楚)
consistent-type-imports纯类型导入必须 import type(配合 isolatedModules
no-floating-promises异步调用必须处理(不 await 也不 .catch 就报错)
import type 是什么
import type { User } from './user' // 纯类型导入,编译时被擦掉
import { createUser } from './user' // 值导入,保留

consistent-type-imports 强制区分,能让 isolatedModules(Vite 等场景要求)不出问题。


12.4 JS → TS 渐进迁移

12.4.1 不要「大爆炸重写」

把整个项目一次性改写成 TS 风险极高。推荐渐进式,三个阶段:

12.4.2 三步走

第一步:allowJs + checkJs 关掉,先让 TS 和 JS 共存

{
"compilerOptions": {
"allowJs": true, // 允许 .js 文件
"checkJs": false, // 暂不检查 .js
"strict": false // 过渡期先放松
}
}

项目能用 TS 写新代码,老 JS 先不动。

第二步:逐模块改写成 .ts

一个模块一个模块地把 .js 改成 .ts、补上类型。此时把 checkJs 打开、strict 开回来,让 TS 指出老代码的问题。

第三步:收尾清理

{
"compilerOptions": {
"allowJs": false,
"checkJs": true,
"strict": true
}
}

:any 清零、@ts-ignore 清零,全绿。

迁移时的实用技巧
  • 叶子模块(被依赖多的)开始改,收益大、风险小
  • 过渡期允许 any,但用 // TODO: 迁移后消除 标注,别让它变成「永久 any」
  • 每改一个模块跑一次 tsc --noEmit + 测试,保持「一直能跑」

本章小结

  • strict 是一组开关的合集,每个开关都在防一类「类型漏洞」;建议再加 noUncheckedIndexedAccess
  • 类型测试用 Expect + Equal + @ts-expect-error 锁住类型行为,接入 typecheck
  • tsc 管类型、ESLint 管规范,两者互补;优先开 no-explicit-any / consistent-type-imports / no-floating-promises
  • JS → TS 用「共存 → 逐模块改写 → 收紧」三步渐进迁移,拒绝大爆炸

本章练习

挑战题(类型安全判定的四兄弟)

题号题目难度考察点
00089必需的键hard判定必选键
00090可选类型的键hard判定可选键
02857IsRequiredKeyhard单键是否必选
05181Mutable Keyshard判定可变键

这 4 道题是「给类型写断言」的实战——你要写的是「判断一个属性是必选/可选/可变」的类型,正好用本章的 Equal 思路实现(提示:{} extends Pick<T, K> 判断可选,Equal<{[P in K]: T[P]}, {-readonly [P in K]: T[P]}> 判断可变)。

项目练习 · 类型测试 + 迁移

  1. 选一个你自己写过的工具函数(或项目里的一段 JS),为它写 Expect<Equal<...>> 类型测试,验证至少 3 个正例 + 1 个 @ts-expect-error 负例,接入 npm run typecheck

  2. Equal 验证:boolean 不等于 truereadonly [1] 不等于 [1]——体会为什么必须用函数签名比较而不是 A extends B

  3. 给项目加 ESLint(typescript-eslint),开 no-explicit-any,把代码里现有的 any 全部消灭或标注 TODO

  4. 对照 12.4 三步走,为一个有 2-3 个模块的小项目设计迁移清单:哪些先改、哪些后改、any 怎么过渡。