运行时类型契约

4 min read
Zekari
类型系统TypeScript运行时验证系统边界

类型消失的时刻

TypeScript 给你类型安全。但这种安全有个前提:你控制的代码范围内。

当数据从外部进入——用户输入、API 响应、文件读取、环境变量——TypeScript 的类型就失效了。

因为 TypeScript 只存在于编译时。运行时的 JavaScript 不知道什么是类型。

你写 user: User,编译后就是 user。没有任何检查。没有任何保护。

💡 Click the maximize icon to view in fullscreen

边界的脆弱性

问题不在于 TypeScript 不够好。问题在于类型系统和运行时之间的断层。

看一个常见的 API 调用:

interface User {
  id: number
  name: string
  email: string
}

async function getUser(userId: string): Promise<User> {
  const response = await fetch(`/api/users/${userId}`)
  const data = await response.json()
  return data // TypeScript 相信这是 User
}

const user = await getUser("123")
console.log(user.name.toUpperCase()) // 如果 name 不存在?

TypeScript 看到的是类型声明。它相信 getUser 返回 User

但运行时不会检查。如果 API 返回的数据结构变了、字段缺失了、类型错了,程序直接崩溃。

这就是边界的脆弱性:类型声明是承诺,不是保证。

为什么需要运行时验证

你可能会说:我们有单元测试、集成测试,API 不会出错。

但现实是:

后端 API 改了返回结构。 你的前端类型定义没有同步更新。

第三方 API 偶尔返回 null。 文档没写,你的类型也没考虑。

用户输入了意外格式。 你假设 email 是字符串,但有人传了一个对象。

环境变量配置错误。 你期待 PORT 是数字,但读到的是字符串。

数据库迁移导致字段类型变化。 ORM 查询返回的数据不再匹配旧的类型定义。

这些不是假设的场景。这些是生产环境每天发生的事。

类型声明告诉开发者"数据应该是什么样"。运行时验证确保"数据真的是那样"。

契约的延续

理想的类型安全是:编译时的类型契约能够延续到运行时。

Zod 做的就是这件事。

import { z } from 'zod'

const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email()
})

type User = z.infer<typeof UserSchema>

async function getUser(userId: string): Promise<User> {
  const response = await fetch(`/api/users/${userId}`)
  const data = await response.json()

  // 运行时验证
  return UserSchema.parse(data)
}

这里发生了什么?

UserSchema 定义了数据的形状。 它既是类型定义,也是运行时检查器。

z.infer<typeof UserSchema> 从 schema 推导出 TypeScript 类型。 类型和验证规则是同一个来源,不会不一致。

UserSchema.parse(data) 在运行时检查数据。 如果数据不符合 schema,抛出错误。如果符合,返回类型安全的数据。

这是类型契约的延续。编译时有保证,运行时也有保证。

Schema 不只是验证器。它是系统边界的契约定义。

单一来源: 类型定义和验证规则来自同一个 schema,避免了双重维护和不一致。

文档化: Schema 本身就是文档,清晰描述了数据结构和约束条件。

可组合: 复杂的 schema 可以由简单的 schema 组合而成,保持模块化。

可测试: Schema 可以独立测试,确保验证逻辑正确。

参考 api-boundary-testing 了解如何在边界处系统性地测试数据契约。

边界的定位

不是所有地方都需要运行时验证。

内部函数之间传递数据? 不需要。TypeScript 已经保证了类型安全。

临时变量、局部计算? 不需要。这些在你的控制范围内。

运行时验证只在边界处:

API 响应: 外部系统返回的数据进入你的系统。

用户输入: 表单提交、查询参数、文件上传。

环境配置: 环境变量、配置文件、命令行参数。

数据库查询: 虽然你控制数据库 schema,但数据可能被其他程序修改。

第三方库返回值: 如果类型定义不可靠,需要验证。

边界是你的系统和不确定性相遇的地方。在那里建立契约。

💡 Click the maximize icon to view in fullscreen

失败的处理

验证会失败。这是预期的。

问题不是"如何避免失败",而是"如何处理失败"。

import { z } from 'zod'

const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email()
})

// 选项 1:抛出错误(快速失败)
function parseUserStrict(data: unknown): User {
  return UserSchema.parse(data)
}

// 选项 2:返回结果对象(优雅处理)
function parseUserSafe(data: unknown) {
  return UserSchema.safeParse(data)
}

const result = parseUserSafe(data)

if (result.success) {
  console.log(result.data.name)
} else {
  console.error(result.error.issues)
}

parse() 失败时抛出异常。 适合不可恢复的错误,需要终止流程。

safeParse() 返回结果对象。 适合可恢复的错误,需要给用户反馈。

选择哪种取决于上下文:

API 请求? 通常用 safeParse,返回 400 错误和详细的验证信息。

配置文件加载? 通常用 parse,配置错误应该阻止程序启动。

用户表单提交?safeParse,需要展示具体哪个字段有问题。

验证不是性能瓶颈

你可能担心:运行时验证会影响性能。

大多数情况下,这不是问题。

验证的开销远小于网络请求、数据库查询、复杂计算。

而且验证只在边界处发生,不是在每个函数调用。

如果你的系统真的对性能敏感到验证都成为瓶颈,你可以:

在生产环境跳过验证? 不。这会让类型安全只存在于开发环境,生产环境暴露在风险中。

缓存验证结果? 可以。如果同一数据被多次验证,可以标记为"已验证"避免重复。

优化 schema 结构? 可以。Zod 的验证性能与 schema 复杂度相关,简化 schema 可以提升速度。

但通常,你不需要做这些。验证的价值远大于它的开销。

类型安全的完整性

类型安全不是 TypeScript 编译通过就结束了。

类型安全是一个完整的链条:

编译时: TypeScript 检查类型一致性。

边界处: Zod 验证外部数据符合类型契约。

运行时: 程序在类型安全的环境中执行。

只有三个环节都做到,类型安全才是真的安全。

如果只有编译时类型检查,你的程序在遇到意外数据时会崩溃。

如果有运行时验证,错误被捕获在边界,程序保持稳定,用户得到清晰的反馈。

这不是过度工程。这是基本的防御性编程。

从验证到转换

Zod 不只验证,还能转换。

const ConfigSchema = z.object({
  port: z.string().transform(val => parseInt(val, 10)),
  debug: z.enum(['true', 'false']).transform(val => val === 'true'),
  apiUrl: z.string().url()
})

type Config = z.infer<typeof ConfigSchema>

const rawConfig = {
  port: "3000",       // 字符串
  debug: "true",      // 字符串
  apiUrl: "https://api.example.com"
}

const config = ConfigSchema.parse(rawConfig)
// config.port 是 number
// config.debug 是 boolean

环境变量总是字符串。但你的程序需要 number、boolean、date。

transform() 让 schema 在验证的同时完成类型转换。

输入是原始数据,输出是你需要的类型。

这是验证和转换的合一。边界处一次处理,内部代码无需再关心。

有一个编程哲学:"Parse, don't validate"(解析,而非验证)。

验证回答:"这个数据是否有效?"

解析回答:"把这个数据转换成我需要的形式。"

验证只做检查,数据还是原样。解析在检查的同时完成转换。

Zod 的 transform() 实现的就是解析。你不需要先验证,再手动转换。一步到位。

这减少了代码重复,也减少了转换错误的风险。

类型推导的力量

Zod 最优雅的地方是类型推导。

const ArticleSchema = z.object({
  title: z.string(),
  publishedAt: z.string().datetime(),
  tags: z.array(z.string()),
  author: z.object({
    name: z.string(),
    email: z.string().email()
  })
})

type Article = z.infer<typeof ArticleSchema>

// TypeScript 知道:
// - Article.title 是 string
// - Article.publishedAt 是 string(ISO 8601 格式)
// - Article.tags 是 string[]
// - Article.author.name 是 string
// - Article.author.email 是 string

你不需要写两遍类型定义。

z.infer 从 schema 推导出 TypeScript 类型。

Schema 是单一来源。类型定义自动生成,永远不会不一致。

这消除了手动维护类型和验证规则的双重负担。

最后

TypeScript 给你编译时的保证。Zod 延续这个保证到运行时。

类型安全不是一个阶段的事,是整个系统的事。

从数据进入的那一刻起,到数据在程序中流转的每一步,类型契约都应该被遵守。

边界是不确定性进入的地方。在那里建立契约,验证数据,转换格式。

内部代码在类型安全的环境中运行,不再需要处处防御。

这不是额外的工作。这是必要的架构。


如果系统有多个边界(API、数据库、消息队列),如何统一管理 schema 定义?

Schema 应该放在哪一层?领域层?接口层?还是独立的契约层?

当 schema 需要版本演进时(新增字段、修改约束),如何平滑迁移?

Zod 的验证错误信息如何国际化,以便给不同语言的用户展示?

在微服务架构中,不同服务间的数据契约如何共享和验证?

除了 Zod,还有哪些运行时验证方案?它们的权衡是什么?

运行时验证和单元测试的关系是什么?是互补还是部分重叠?