下面这些规则,是在真实的 TypeScript 代码中防止 bug 最多的规则。每一条都先给出常见写法,再给出更好的写法,代码都可以运行。第一条最重要:别再用 any 了。
用 unknown 代替 any
any 会关闭对一个值以及由它计算出的一切的类型检查。unknown 同样接受任何值,但你必须先检查才能使用,这样检查就放在了数据进入程序的地方:
这些边界就是类型不再有保证的地方:JSON.parse、fetch 的响应、localStorage、表单输入、环境变量以及来自其他进程的消息。在这些地方用类型守卫或模式校验库进行校验,其余的代码就可以信任自己的类型。
保持 strict 开启
strict 是 TypeScript 7 的默认设置,不要关掉它。再加上它没有包含、但能发现最多 bug 的几项检查:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true
}
}
noUncheckedIndexedAccess 让 arr[i] 和 record[key] 的类型包含 undefined,因为索引不存在时它们返回的就是 undefined。noImplicitOverride 要求子类方法写明 override,noFallthroughCasesInSwitch 则拒绝一路执行到下一个 case 的 case。
让类型推断发挥作用
给 TypeScript 无法知道的东西加注解:函数参数,以及会被其他模块使用的函数的返回类型。局部变量和回调参数交给推断。多余的注解不只是噪音,它还可能让类型比值本身更宽:
去掉 @ts-expect-error 注释,setStatus(annotated) 就是错误 TS2345。推断出的 const 保留了字面量类型 "active",所以能被接受。在加类型之前,先在编辑器里把鼠标悬停在变量上,看看推断出了什么。
优先用联合类型而不是枚举
字符串字面量的联合类型能提供自动补全和穷尽性检查,而且不生成任何代码。如果运行时还需要值的列表,就从一个 as const 数组派生出类型:
const ROLES = ["admin", "editor", "viewer"] as const;
type Role = (typeof ROLES)[number]; // "admin" | "editor" | "viewer"
function canEdit(role: Role): boolean {
return role !== "viewer";
}
console.log(canEdit("editor")); // true
console.log(ROLES.filter(canEdit)); // [ 'admin', 'editor' ]
canEdit("owner"); // error TS2345: Argument of type '"owner"' is not assignable to parameter of type '"admin" | "editor" | "viewer"'.
enum Role { Admin, Viewer } 会编译成一个带反向映射的对象,而数字枚举类型的参数会接受任何 number 变量,即使它装的值不在枚举里。枚举也无法在 Node 的类型剥离模式下运行(TypeScript enum is not supported in strip-only mode)。两者的利弊在枚举页面中有对比。
用 satisfies 检查配置对象
用 Record<string, Route> 这样宽泛的类型注解一个对象,会检查它的值,却丢掉了它的键。satisfies 做同样的检查,同时保留确切的类型:
如果变量应该恰好是声明的类型(函数参数、之后会重新赋值的值),就用注解。查找表、路由映射、主题变量和其他常量对象,则用 satisfies。
用可辨识联合建模状态
一个带可选字段的对象会允许根本不会发生的状态:loading: true 同时又有 error,或者成功后却没有 data。由带有共同标签的对象组成的联合类型只允许真实存在的状态,而且每个分支只能看到自己的字段:
never 那一行就是穷尽性检查。当有人新增了一种状态却忘了处理时,构建会在这一行失败:
错误是 index.ts(14,13): error TS2322: Type '{ status: "cancelled"; }' is not assignable to type 'never'.,它指出了没有处理的情况。加上 case "cancelled": 就能通过编译。
避免 ! 和 as
非空断言 x! 和类型断言 x as T 都是在告诉编译器别再检查了。两者在运行时都不会改变值,所以一个错误的断言会在之后变成崩溃,而且发生在远离原因的地方:
把 ! 换成 ?.、??、提前 return,或者抛出一个带有有用信息的错误。把 as 换成真正检查值的类型守卫。唯一总是安全的断言是 as const,因为它只会让类型更窄、变为只读。配置好 lint(typescript-eslint 的 no-non-null-assertion 和 no-explicit-any),其余的情况都会被标记出来。
让数据只读
当代码不应修改属性和数组时,把它们标记为 readonly。编译器随后会拒绝 push、sort 和赋值,函数也会返回新值,而不是修改传入的参数:
readonly 只在编译时检查,而且只作用于一层:它不会在运行时冻结对象。但这已经足以发现意外修改共享状态的问题,而这类 bug 大多正是这样产生的。
常见问题
TypeScript 中应该用 any 吗?
在应用代码里几乎永远不该用。any 会关闭对这个值以及由它派生的一切的检查。类型暂时未知的值用 unknown,再通过检查收窄;any 只留给极少数不得不用的逃生口,并加注释说明原因。
TypeScript 中需要给每个变量加类型注解吗?
不需要。局部变量和回调参数交给 TypeScript 推断。函数参数要加注解(它们无法推断),导出函数的返回类型也要加,这样函数内部的改动就不会悄悄改变它对外的类型。
TypeScript 的枚举是不好的做法吗?
不算错,但很多团队避免使用。枚举会生成运行时代码,无法在 Node 的类型剥离(type stripping)模式下运行,而且数字枚举类型的参数会接受任何 number 变量,不管它装的是什么值。字符串字面量的联合类型,或者一个 as const 数组加上由它派生的类型,同样能提供自动补全和检查,而且不生成任何代码。
什么时候应该用 as 做类型断言?
只在你知道某件编译器不知道的事情时使用,最好紧跟在一个能证明它的检查之后。as 在运行时不会改变任何值,所以 {} as User 能通过编译,但实际上并没有 name。大多数情况下,真正检查值的类型守卫是更安全的工具。
新的 TypeScript 项目最好用哪些 tsconfig 设置?
保持 strict 开启(TypeScript 7 的默认值),再加上 noUncheckedIndexedAccess。很多项目还会开启 noImplicitOverride、noFallthroughCasesInSwitch 和 verbatimModuleSyntax。tsc --init 生成的配置会设置 strict、noUncheckedIndexedAccess、exactOptionalPropertyTypes 和 verbatimModuleSyntax 等选项。