首页 / 文章 / TypeScript中的域建模:超越基础类型注解

TypeScript中的域建模:超越基础类型注解

学习实用的 TypeScript 编程习惯——从 unknown 与 any 的区别,到区分联合类型以及 satisfies 关键字——这些习惯能帮助你构建有效的状态模型,而不仅仅是给数据加标签。

2330 词

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. 理解 nullundefined

在实际应用中,这种差异带来的影响往往超出人们的预期。

以这类情况为例:

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 之上的层,而是成为应用程序实际构建方式的一部分。

相关阅读

  • 掌握 TypeScript 的内置实用类型以编写更整洁的代码 — 了解 Partial、Pick、Omit 和 Record 等 TypeScript 实用类型如何消除重复接口,并自动保持类型定义的一致性。