第 12 章 · 类型安全与工程质量
- 吃透
strict及其子开关,知道每个开关防的是什么 - 学会写「类型测试」:用
Expect/Equal断言类型行为正确 - 了解 TypeScript + ESLint 的分工,配好基础规则
- 掌握 JS → TS 渐进迁移的三种策略,不搞「大爆炸」重写
12.1 strict 全家桶:每个开关防什么
strict: true 是以下开关的合集。逐个认识它们,你就知道「严格模式到底在保护什么」:
| 开关 | 保护什么 | 反例 |
|---|---|---|
strictNullChecks | null/undefined 不能随便混进其他类型 | let n: number = null |
noImplicitAny | 不允许「看不见的 any」 | 参数没标类型 |
strictFunctionTypes | 函数参数不能「宽进窄出」 | 回调参数类型不符 |
strictPropertyInitialization | 类字段必须初始化 | 声明了 x: number 却不在构造器赋值 |
useUnknownInCatchVariables | catch (e) 的 e 是 unknown 而非 any | e.message 直接访问 |
noUncheckedIndexedAccess(不在 strict 内,建议开) | 下标访问可能为 undefined | arr[i] 不判空 |
strictNullChecks:最影响日常写法的开关。开了它,所有可能为空的访问都要先判空——这是「别扭但安全」的代价。useUnknownInCatchVariables:catch (e)里e变成unknown,访问e.message前必须先收窄。很多人不习惯,但它逼你处理「异常到底是什么」。
// strict: true + useUnknownInCatchVariables
try {
JSON.parse('bad')
} catch (e) {
e.message // ❌ e 是 unknown
if (e instanceof Error) e.message // ✅ 收窄后
}
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true
}
}
noUncheckedIndexedAccess 和 exactOptionalPropertyTypes 是「更严格但更安全」的两项,新项目建议一并开。
12.2 类型测试:像测逻辑一样测类型
12.2.1 为什么需要类型测试
你写的工具类型(Partial、Awaited、自己造的 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 当成测试运行器。
<T>() => T extends X ? 1 : 2 这套「函数签名比较」能区分 boolean 与 true、区分 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 { 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 | 判定可选键 |
| 02857 | IsRequiredKey | hard | 单键是否必选 |
| 05181 | Mutable Keys | hard | 判定可变键 |
这 4 道题是「给类型写断言」的实战——你要写的是「判断一个属性是必选/可选/可变」的类型,正好用本章的
Equal思路实现(提示:{} extends Pick<T, K>判断可选,Equal<{[P in K]: T[P]}, {-readonly [P in K]: T[P]}>判断可变)。
项目练习 · 类型测试 + 迁移
-
选一个你自己写过的工具函数(或项目里的一段 JS),为它写
Expect<Equal<...>>类型测试,验证至少 3 个正例 + 1 个@ts-expect-error负例,接入npm run typecheck。 -
用
Equal验证:boolean不等于true;readonly [1]不等于[1]——体会为什么必须用函数签名比较而不是A extends B。 -
给项目加 ESLint(
typescript-eslint),开no-explicit-any,把代码里现有的any全部消灭或标注TODO。 -
对照 12.4 三步走,为一个有 2-3 个模块的小项目设计迁移清单:哪些先改、哪些后改、
any怎么过渡。