让大型代码库保持可读性与安全性的十个 TypeScript 使用习惯
学习十种实用的 TypeScript 使用习惯,包括有意义的泛型与类型收窄、全面检查机制、readonly 属性以及严格的 tsconfig 配置,这些都能帮助保持不断扩展的代码库的可维护性。
在日益庞大的代码库中,大多数 TypeScript 相关问题并非源于不了解泛型或条件类型是什么。这些问题其实源自日常开发中的种种决策:过度抽象的设计、过于宽松的类型约束、到处使用的 as 关键字、重复定义的契约、难以理解的泛型签名、在调用时参数含义不明的函数、散落在不同文件中的类型定义,以及配置过松的编译器,导致它无法捕捉到团队所依赖的功能。在小型项目中,这些习惯几乎不会显现问题;但当涉及数十名开发人员且经过数年发展后,它们就会逐渐积累成沉重的技术债务。本指南将介绍十种能够保持 TypeScript 代码易于阅读和修改的具体实践,同时还提供一份可在代码审查时使用的检查清单。
如果您希望先了解建模方面的内容,包括如何让无效状态无法被表示,可以从TypeScript中超越基础类型注解的领域建模开始学习。这部分内容重点介绍在良好模型基础上应养成的维护习惯。
1. 将泛型视为表达关系的一种方式
泛型通常被介绍为一种重用机制,确实具有这一功能。但它们更重要的作用是连接类型:告诉编译器函数输出的结果与输入的内容之间存在关联。以下是最简单的实用示例,即一个返回数组中第一个元素的函数。
function getFirst<T>(items: T[]): T | undefined {
return items[0]
}
类型参数T是从参数中获取的。传入一个用户数组:
const users: User[] = [...]
编译器便会据此推断出结果:
const user = getFirst(users)
// User | undefined
无需任何额外注解,同一函数即可用于不同类型的元素:
const products: Product[] = [...]
能够得到类型正确的计算结果:
const product = getFirst(products)
// Product | undefined
现在看看如果去掉泛型而改用unknown会怎样。函数仍然可以运行,但输入与输出之间的关联消失了,每个调用者都必须对结果进行类型转换或限制。
function getFirst(items: unknown[]): unknown {
return items[0]
}
在引入类型参数之前,一个好的测试方法是明确该参数所维护的对应关系。如果无法说明哪种输入类型决定了哪种输出类型,那么这个泛型很可能没有发挥应有的作用。
值得注意的一个细节是:当启用noUncheckedIndexedAccess(详见第10节)时,编译器会自动将items[0]的类型定义为,这与此处明确的返回类型是一致的。
2. 避免将所有内容都转化为泛型
由于泛型功能强大,因此很容易被过度使用。人们往往会倾向于编写包含多个相互关联的受限类型参数的函数签名,如下所示。写出来时确实会让人感觉很高级。
function processData<
T extends Record<string, unknown>,
K extends keyof T,
R extends ...
>(...) {
// ...
}
六个月后打开该文件的开发者往往会有不同的感受。每一个额外的类型参数都是读者需要记住的内容。如果函数实际上只处理用户数据,那么简单的函数签名就能传达更多信息:
function processUser(user: User) {
// ...
}
TypeScript 的水平并非取决于能在一个声明中使用多少类型系统特性。只有当泛型真正能够体现类型之间的关联时才应使用它,而不仅仅是因为语言允许这样做。
3. 限制值的范围而非进行类型转换
类型断言是快速解决此类问题的方法:
const value = something as string
问题在于 as 并不会进行任何检查。它只是告诉编译器忽略不确定性并相信你的说法,而如果你判断错误,错误会在运行时出现。更安全的方法是通过编译器能理解的运行时检查来验证类型:
if (typeof something === 'string') {
console.log(something.toUpperCase())
}
在 if 块中,something 被视为 string,因为 typeof 是一种类型收窄操作。对于对象类型,则需要编写自定义的类型守卫。返回值 value is User 可让编译器明白,当结果为 true 时,该参数即可被当作 User 处理。
function isUser(value: unknown): value is User {
return (
typeof value === 'object' &&
value !== null &&
'id' in value &&
'name' in value
)
}
这样调用方就能免费获得类型收窄功能了:
if (isUser(value)) {
console.log(value.name)
}
区别很简单:断言要求编译器信任你,而类型限定则提供证据。请记住,类型守卫的可靠性完全取决于其实现内容。该示例仅检查id和name是否存在,而不验证它们的类型,因此对于来自网络或存储的数据,你可能需要更严格的检查或模式验证器。编译器会完全信任守卫的判断结果。
4. 使用never标志标记不完整的分支结构
假设某种状态被表示为字符串字面量的联合类型:
type Status =
| 'pending'
| 'approved'
| 'rejected'
一个将每种状态映射到对应标签的switch语句看起来似乎是完整的:
function getLabel(status: Status) {
switch (status) {
case 'pending':
return 'Pending'
case 'approved':
return 'Approved'
case 'rejected':
return 'Rejected'
}
}
目前它确实是完整的。但当联合类型中的元素增多时,问题就出现了,比如有人又添加了“已取消”状态:
type Status =
| 'pending'
| 'approved'
| 'rejected'
| 'cancelled'
Status 可能会在数十个地方被使用,你希望编译器能够指出所有不再覆盖所有情况的用例。标准做法是使用一个能接受 never 值的穷尽性检查辅助函数。在 default 分支中,TypeScript 已经排除了所有已处理的成员,因此剩余的类型应当为 never。如果有新的成员未被处理,它就无法赋值为 never,从而导致编译失败。
function assertNever(value: never): never {
throw new Error(`Unhandled value: ${value}`)
}
function getLabel(status: Status) {
switch (status) {
case 'pending':
return 'Pending'
case 'approved':
return 'Approved'
case 'rejected':
return 'Rejected'
default:
return assertNever(status)
}
}
在添加了 'cancelled' 后,调用 assertNever(status) 会引发编译错误,直到你处理完这个新情况为止。联合类型定义便成了唯一的真实来源,编译器会列出需要更新的位置。此外,throw 语句还能在类型系统外部传入意外值时为程序提供运行时的保护。
5. 使用 readonly 指定数据的使用方式
类型用于说明允许哪些值,但也可描述这些值应如何处理。将某个属性标记为 readonly 即表示一旦对象创建,该属性便不可更改:
type User = {
readonly id: string
name: string
}
此时编译器会拒绝如下形式的赋值操作:
user.id = '123'
数组也可以用相同方式加以保护。将参数定义为 readonly User[] 后,函数可以遍历和读取数组元素,但无法向其中添加新元素、修改现有元素位置或对数组进行排序:
function processUsers(users: readonly User[]) {
// ...
}
该签名会告知所有调用者该函数不会修改他们的集合。readonly 对配置对象、共享数据、常量、函数参数以及不可变状态尤为有用。其主要好处不在于阻止特定的修改操作,而在于向所有读取该类型的对象明确其设计意图。需要注意的是,readonly 只具有表面作用且仅在编译时有效:嵌套对象除非也被标记为 readonly,否则仍然可被修改,且在运行时没有任何内容会被冻结。
6. 不要使用 Record<string, unknown> 隐藏真实结构
类似这样的签名很常见:
function process(data: Record<string, unknown>) {
// ...
}
有时这才是正确的类型。如果某个函数确实需要接收任意的键值数据,比如通用的日志记录器或序列化辅助工具,使用宽泛的记录类型是合理的。问题在于在已经知道对象具体类型时仍使用它。以相同的函数签名为例:
function process(data: Record<string, unknown>) {
// ...
}
然后按照实际期望的数据结构来建模:
type User = {
id: string
name: string
}
function process(user: User) {
// ...
}
这种改动看似只是表面上的,但带来的好处却很大:自动补全、内联文档、安全的代码重构、编译时保障以及清晰的意图表达。宽泛的类型只适用于真正动态的场景,比如解析未知的 JSON 数据,在离开这种场景后应尽快将其转换为具体的类型,而不应到处都作为默认类型使用。
7. 设计能够自我说明的函数接口
位置参数很快就会变得难以理解,尤其是布尔值。看到这样的调用时,如果不查看定义,就无法知道true和false分别控制什么:
createUser(
'Akshat',
'akshat@example.com',
true,
false,
)
选项对象则将含义直接放在调用处:
createUser({
name: 'Akshat',
email: 'akshat@example.com',
sendWelcomeEmail: true,
isAdmin: false,
})
随后函数会为其选项声明一个命名类型:
type CreateUserOptions = {
name: string
email: string
sendWelcomeEmail: boolean
isAdmin: boolean
}
function createUser(options: CreateUserOptions) {
// ...
}
参数数量越多,这种方式的优点就越明显。两个参数通常使用位置参数即可;而七个参数几乎总会引发错误,尤其是当其中多个参数类型相同且可以互换而不出错时。选项对象还能让日后轻松添加可选字段,而不会影响现有的调用方式。
8. 将类型放在其所描述的领域附近
许多项目都是从一个共享的 types.ts 文件开始的。起初这很方便,但随后每个开发者都会往其中添加内容,一年之后这个文件里就包含了数百个彼此无关的类型定义。要找到所需的类型就不得不进行全局搜索,而该文件也变成了合并冲突的高发点。
更好的做法是将类型与定义它们的业务代码放在一起:
users/
user.types.ts
user.service.ts
user.repository.ts
payments/
payment.types.ts
payment.service.ts
payment.repository.ts
facilities/
facility.types.ts
facility.service.ts
facility.repository.ts
具体的文件夹结构并不如其背后的规则重要:类型应与其描述的业务领域同处一处。如果你知道支付相关的业务逻辑位于何处,就应该能够推断出支付类型的位置。那些真正具有跨领域性质的类型,比如通用的 API 结构,仍可以放在一个小的公共模块中。
9. 保持类型系统比业务逻辑更简单
TypeScript 提供了映射类型、条件类型、模板字面量类型、递归类型、infer 以及分配性条件。借助这些工具,你几乎可以在类型层面构建任何东西,这也正是为何需要有所约束的原因。试想这样一个辅助函数,它能将对象过滤为以 Id 结尾的键:
type Magic<T> =
T extends infer U
? U extends Record<string, unknown>
? {
[K in keyof U as K extends `${string}Id`
? K
: never]: U[K]
}
: never
: never
类型级编程确实有其用途,尤其是在库中。但当某种类型的复杂性超过它带来的好处时,就需要重新考虑了。如果团队成员必须先解析复杂的类型才能理解其对应的业务规则,那就应该思考是否可以用更简单的版本替代。有时答案是否定的,这种复杂性是必要的;但往往并非如此。聪明并不等同于高质量。简单且易于理解的类型通常比那些花哨复杂的类型更好,而当确实需要使用高级类型时,加上简短的注释和几组类型测试就能大大提升其可维护性。
10. 有意识地配置tsconfig
削弱 TypeScript 功能的最简单方法之一,就是通过配置来忽略那些你本期望它能够检测到的问题。至少,你应该了解这些选项的功能:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true
}
}
strict 会启用一系列检查机制,包括 strictNullChecks 和 noImplicitAny。另外两个则是独立的可选选项,strict 并不会自动启用:noUncheckedIndexedAccess 会在对数组和记录的索引访问时将结果视为 undefined,而 exactOptionalPropertyTypes 则能区分缺失的属性与明确设置为 undefined 的属性。在现有的项目中,这两种选项都可能引发大量错误。
最佳的配置组合取决于代码库的情况。对于传统项目而言,可能无法一次性启用所有选项,逐步开启这些标志是完全合理的。重要的是团队要清楚编译器正在检查哪些内容以及未检查哪些内容,首先从了解这一点开始:
"strict": true
严格模式并非为了增加 TypeScript 的使用难度。它的作用是让编译器在面对不确定性时更加诚实,而这正是使用 TypeScript 的根本目的:在用户发现问题之前就将其捕获。
为何这些习惯共同重要
这些做法本身并非作为技巧才有价值,它们的意义在于让代码库更易于理解。想象一下有位新同事遇到了这样的类型:
type Payment =
| {
status: 'SUCCESS'
transactionId: string
}
| {
status: 'FAILED'
error: string
}
无需阅读任何实现代码,他们就能理解业务规则:成功的支付会带有交易ID,而失败的支付则会带有错误信息。该类型传达的是系统这一部分的运行方式,而不仅仅是某个属性是字符串而已。这才是我们应该追求的标准。
代码审查检查清单
在提交 TypeScript 代码之前,先思考以下问题:
- 这个
any可以是unknown或某种特定类型吗? - 这个注解是否在重复编译器已经推断出的信息?
- 这些类型是否仅表示有效的领域状态?
- 这个属性是真正可选的,还是出于便利才如此设计?
- 使用联合类型能否更精确地描述这种状态?
- 这里的
as是因为该值确实安全,还是仅仅为了消除错误? - 这个泛型是否真正表达了类型之间的关联?
- 新的抽象结构是否比它所替代的代码更易于理解?
- 读者能否在调用处明白每个参数的含义?
readonly能否更清晰地体现所有权或不可变性?- 当这个领域发生变化时,编译器能否察觉到?
- 同事无需解码就能理解这种类型吗?
最后一个问题往往最为重要。
总结:类型作为设计工具
随着经验的积累,语法会成为 TypeScript 中最不值得关注的部分。真正重要的是你选择表达的内容。你可以描述一个恰好包含一些字符串的对象,也可以描述一种始终处于四种状态之一且每种状态都对应特定属性集的操作。后者要实用得多。
因此,优秀的 TypeScript 并不是由开发者能背诵多少高级特性来评判的,而是取决于类型系统在帮助团队理解、修改和维护软件方面的效果。当编译器强制执行应用程序已依赖的规则时,类型就不再仅仅是安全网,而是成为架构的一部分。
- 在需要表达关联关系时使用泛型,而在无需捕捉任何关联时则使用普通类型签名。
readonly、精确的形状以及选项对象来体现设计意图。tsconfig的检测规则,并有针对性地加以强化。