Menu

TypeScript 最佳实践:8 条规则及前后对比

八个能防止真实 bug 的 TypeScript 习惯:保持 strict 开启,用 unknown 代替 any,让类型推断发挥作用,优先用联合类型而不是枚举,用 satisfies 检查配置,用可辨识联合建模状态,避免 ! 和 as,以及让数据只读。每一条都附有可运行的前后对比。

本页包含可运行的编辑器。编辑、运行,立即查看输出。

下面这些规则,是在真实的 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 等选项。

Coddy 编程语言插图

和 Coddy 一起学编程

开始