TypeScript中的域建模:超越基础类型注解
学习实用的 TypeScript 编程习惯——从 unknown 与 any 的区别,到区分联合类型以及 satisfies 关键字——这些习惯能帮助你构建有效的状态模型,而不仅仅是给数据加标签。
TypeScript 的学习门槛其实很低。
首先你要了解接口。
接着是类型别名。
之后则是联合类型、泛型、实用类型,以及偶尔用到的映射类型。
不久之后,你就能看一眼普通的 JavaScript 对象,毫不犹豫地为其指定类型。
但到了某个阶段,使用 TypeScript 就不再仅仅是给事物添加类型了。
它变成了有意识地设计类型。
这需要掌握完全不同的技能。
看看这个例子:
type Payment = {
status: 'SUCCESS' | 'FAILED'
transactionId?: string
error?: string
}
乍看之下似乎没什么问题。
但仔细想想,这种类型实际上允许哪些状态存在。
它允许所有这些情况:
{
status: 'SUCCESS'
}
{
status: 'SUCCESS',
error: 'Something went wrong'
}
{
status: 'FAILED',
transactionId: '123'
}
{
status: 'FAILED',
error: 'Something went wrong'
}
该类型并没有概念上区分哪些组合才是合理的。
这并非 TypeScript 本身的限制。
这说明该域模型的设计存在缺陷。
更好的版本如下:
type Payment =
| {
status: 'SUCCESS'
transactionId: string
}
| {
status: 'FAILED'
error: string
}
现在类型系统会直接编码实际的业务规则。
成功的支付必须包含交易ID。
失败的支付必须包含错误信息。
那些不合逻辑的组合将变得难以构建,甚至完全无法构建。
这就是TypeScript开始真正发挥作用的时刻。
重点不在于在所有可能的地方添加类型注解,
而在于让类型体现应用程序实际遵循的规则。
以下是一些有助于实现这一目标的习惯。
1. 当你真正想表达“我不知道”时,停止使用any
最快消除TypeScript警告的方法之一就是:
const response: any = await fetchData()
有时候情况确实如此。
你遇到了错误。
你正处于实现过程中。
你无法立即确定正确的类型。
所以就使用 any。
编译器不再报错。
但 TypeScript 之前提供的所有帮助也都消失了。
一旦 any 混入你的代码库:
const response: any = await fetchData()
response.user.profile.name // not checked
response.foo.bar.baz // not checked
TypeScript 无法检测到这两种错误中的任何一种。
当值确实未知时,请使用 unknown
const response: unknown = await fetchData()
这迫使你在使用该值之前先确定它的实际类型。
if (typeof response === 'string') {
console.log(response.toUpperCase())
}
对于比基本类型更复杂的对象,应在边界处验证其结构。
这里的区别很重要:
unknown表示“我还不知道。”any表示“我完全不想让 TypeScript 检查这个。”
这两种意图截然不同。
在处理来自系统外部的数据时,unknown 几乎总是一个更准确的起点。
2. 不要输入 TypeScript 已经知道的类型
编写强类型代码并不意味着要手动为每个变量添加注解。
这样的版本:
const name: string = 'Akshat'
const age: number = 30
const active: boolean = true
并不一定比这个版本更好:
const name = 'Akshat'
const age = 30
const active = true
TypeScript 本身就能推断出这些类型。
为所有内容添加注解只会增加视觉上的杂乱,而不会提供实际信息。
只有当注解能传达有意义的内容时,才有存在的必要。
例如:
function calculateTotal(
items: Product[],
discount: number
): number {
// ...
}
在这里,函数签名实际上起到了文档化契约部分内容的作用。
这确实是很有用的信息。
一个有用的检查方法是:
这个注解是否告诉 TypeScript 它自己无法推断出的信息?
如果答案是否定的,那就可以忽略它。
3. 当值与类型相同时使用 as const
考虑这样一个对象:
const STATUS = {
ACTIVE: 'ACTIVE',
INACTIVE: 'INACTIVE',
}
有时你希望各个值保持为原始类型,而不是变为 string 类型。
这正是 as const 能提供的功能:
const STATUS = {
ACTIVE: 'ACTIVE',
INACTIVE: 'INACTIVE',
} as const
由此:
type Status = typeof STATUS[keyof typeof STATUS]
最终结果为:
'ACTIVE' | 'INACTIVE'
当你需要从同一个定义中同时获取运行时值和对应的编译时类型时,这种模式非常有用。
例如:
export const ALTERNATE_CODE_TYPES = {
CHARGE_CODE: 'CHARGE_CODE',
NFTP_MDG_CODE: 'NFTP_MDG_CODE',
FACT_MDG_CODE: 'FACT_MDG_CODE',
CW1_CHARGE_CODE: 'CW1_CHARGE_CODE',
} as const
export type AlternateCodeType =
typeof ALTERNATE_CODE_TYPES[keyof typeof ALTERNATE_CODE_TYPES]
在这里,对象本身与其派生类型源自同一个源头。
这意味着无需再维护类似如下的独立声明:
type AlternateCodeType =
| 'CHARGE_CODE'
| 'NFTP_MDG_CODE'
| 'FACT_MDG_CODE'
| 'CW1_CHARGE_CODE'
除此之外。
保持单一的真实数据源比手动同步两个定义要容易管理得多。
4. 当领域具有固定状态集时使用联合类型
当一个值只能取少数几种可能值时,你的类型应直接体现这一点。
与其这样写:
function setStatus(status: string) {
// ...
}
不如选择:
type Status = 'pending' | 'approved' | 'rejected'
function setStatus(status: Status) {
// ...
}
采用这种方式后,以下的调用可以正常工作:
setStatus('approved')
但这样的调用会被拒绝:
setStatus('something-else')
类型定义越严格,编译器就能为你完成更多工作。
这一好处远不止体现在编辑器的自动补全功能上。精确的联合类型还有助于:
- 代码重构
- 文档编写
- 错误检测
- API设计
- 代码可发现性
如果你的业务逻辑确实只允许三种可能的值,就不要将该字段表示为松散的字符串。
5. 不要一味使用枚举
枚举有时确实是合适的选择,但它们不应成为处理每组常量的默认方式。
如果只需要编译时的联合类型,像下面这样的形式通常就足够了:
type Status = 'ACTIVE' | 'INACTIVE'
往往已经足够。
如果还需要这些值在运行时也存在,那就使用:
const STATUS = {
ACTIVE: 'ACTIVE',
INACTIVE: 'INACTIVE',
} as const
type Status = typeof STATUS[keyof typeof STATUS]
这样既能获得类型定义,又能拥有可供操作的真实对象。
需要牢记的关键点是:一旦代码开始运行,TypeScript 的类型就会消失,而普通对象则不会。
因此需要问的问题是:
这个值在运行时必须存在,还是仅仅为了在编译时起到约束作用?
选择与答案相匹配的方案。
6. 使无效状态无法被表示
这可能是整个讨论中最有价值的观点。
想象一个可以处于以下状态之一的表单组件:
- 加载中
- 已准备就绪
- 正在提交
- 成功
- 失败
一种常见但存在缺陷的建模方式是:
type FormState = {
loading: boolean
submitting: boolean
error?: string
data?: FormData
}
采用这种结构的话,就没有任何办法防止意外生成类似这样的结果:
{
loading: true,
submitting: true,
data: {...},
error: 'Something went wrong'
}
那组组合实际上代表什么?类型系统无从知晓,后续阅读代码的开发者也同样不会明白。
更好的结构会根据状态将各个字段关联起来:
type FormState =
| { status: 'loading' }
| { status: 'ready'; data: FormData }
| { status: 'submitting'; data: FormData }
| { status: 'success'; data: FormData }
| { status: 'error'; error: string }
这样每个分支都只包含对其而言有意义的数据。
function render(state: FormState) {
switch (state.status) {
case 'loading':
return 'Loading...'
case 'ready':
return state.data
case 'submitting':
return 'Submitting...'
case 'success':
return state.data
case 'error':
return state.error
}
}
这就是区分联合类型带来的优势。
与其将应用程序建模为一堆松散的独立布尔值和可选字段,不如将其建模为一组固定的合法状态。这样的基础要可靠得多。
7. 小心处理可选属性
可选字段虽有其用途,但也很容易在不知不觉中引入不确定性。
看看这个例子:
type User = {
id?: string
name?: string
email?: string
}
根据这个定义,现在所有使用 User 对象的代码都必须处理这些字段全部缺失的情况。
但该领域真正的规则或许是:
用户始终拥有 ID、姓名和电子邮件地址。
如果是这样,就应按此方式设计模型:
type User = {
id: string
name: string
email: string
}
可选属性应反映那些确实有时会缺失的字段。它们并非用来表示定义类型的人不确定 API 实际会返回什么内容这种模糊的情况。
如果这种不确定性源自外部系统,应在该边界处直接处理。不要让某次集成中的不确定性蔓延到整个代码库。
8. 理解 null 与 undefined
在实际应用中,这种差异带来的影响往往超出人们的预期。
以这类情况为例:
type User = {
middleName: string | null
}
这种表述意味着:
该字段存在,但故意没有设置值。
现在再对比一下这种表述:
type User = {
middleName?: string
}
它通常表示:
该字段可能根本不存在。
在 API 开发中,这种区别尤为重要。在 PATCH 请求中,这样的请求体:
{
middleName: null
}
可能表示:
删除现有的中间名。
而这样的请求体:
{}
可能表示:
保持中间名不变。
如果类型系统无法体现这种差异,微小的错误就可能在 API 层出现。
请记住,类型的存在是为了传递意义,而不仅仅是为了让编译器满意。
9. 使用 satisfies 而非盲目断言类型
考虑如下这样的配置类型:
type Config = {
timeout: number
retries: number
}
一种方法是这样写:
const config = {
timeout: 5000,
retries: 3,
} as Config
但 as 实际上是一种断言,使用它基本上就是在告诉编译器无需质疑地接受该值。
一种更好的方法是:
const config = {
timeout: 5000,
retries: 3,
} satisfies Config
采用这种方式后,TypeScript 会实际检查该对象是否符合 Config 的要求,同时仍保留该字面量对象本身更窄的推断类型。
记住两者区别的一个简单方法:
as
将此值视为该类型。
satisfies
确认此值满足该类型的要求。
正因如此,satisfies 在处理配置对象、静态映射表和查找表时尤为实用。
10. 将 as 视为边界条件,而非默认工具
确实存在需要类型断言的情况。但如下所示的写法:
const user = response as User
实际上在运行时并不会进行任何检查。
假设某个 API 调用真的返回了:
{
username: 'akshat'
}
TypeScript 无法检测到这种不匹配,因为类型断言已经要求它直接接受该值。这里并没有向编译器证明任何内容——只是让它忽略这一问题而已。
当数据从代码库外部传入时,这种情况就会带来风险,例如:
- API 响应
- localStorage
- URL 参数
- 环境变量
- 用户输入
- 第三方库
每当数据从 TypeScript 无法访问的来源进入应用程序时,与其进行类型转换,不如先对数据进行验证。运行时的架构检查实际上可以确认如下内容:
“这些数据确实符合应用程序所期望的结构。”
这比仅仅写以下代码提供了更强的保障:
value as User
TypeScript 是一种编译时工具,而且是非常出色的工具。它并非为验证程序运行时的情况而设计。
真正的目标:对领域进行建模
一旦掌握了这种思维方式,TypeScript 就不再只是语法练习。你不会再问“如何为这个对象定义类型?”,而是开始思考“这个对象实际上可以处于哪些状态?”;你不会再问“这个属性应该是可选的吗?”,而是开始思考“这个属性真的是可选的,还是只是隐藏了某些尚未知晓的信息?”;你不会再问“这里能用 as 吗?”,而是开始思考“这个值真的能够被证明具有所声称的类型吗?”
这种转变正是关键所在。编写优秀的 TypeScript 代码并非在于添加更多的类型注解,而在于让你所写的类型真正具有意义。
一条值得记住的简单规则
每当你设计一个类型时,都要通过三个问题来审视它:
1. 哪些状态才是有效的?
如果某种类型允许表示无效状态,那么很可能需要重新思考该模型本身。
2. 编译器已经知道什么?
避免仅出于习惯而添加注解——让编译器发挥它已具备的推理能力。
3. 这些数据何时才具有可信度?
数据从原始外部来源传播得越远,其类型就应该能够表达出越高的确定性。
强大的 TypeScript 并非取决于其类型的复杂程度,而在于那些能让正确实现显而易见、同时让错误实现难以编写的类型。
一旦以这种思维方式设计类型,TypeScript 就不再像是硬加在 JavaScript 之上的层,而是成为应用程序实际构建方式的一部分。
相关阅读
- 会悄悄破坏代码的常见 JavaScript 和 TypeScript 潜在问题 — 阐述了那些看似正确实则会导致错误的 JavaScript 和 TypeScript 细节问题,涵盖从 NaN 比较到异步时序处理以及类型强制转换等内容。
- 替代 TypeScript 的
any:六种适用于常见场景的类型安全模式 — 学习实用且类型安全的替代方案,用以取代 TypeScript 中的any— 包括 unknown、泛型、区分联合类型以及全面检查等机制 — 以处理不可预测的数据。