运行时类型契约
类型消失的时刻
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,还有哪些运行时验证方案?它们的权衡是什么?
运行时验证和单元测试的关系是什么?是互补还是部分重叠?
Related Posts
Articles you might also find interesting
继承基础配置
配置不需要重复书写。继承机制让每个层次只表达自己的差异。
tsc --noEmit:即时类型反馈
类型错误不应该等到构建时才发现。最快的反馈来自最简单的命令。
Purikura的页面系统
通过五层分层继承复用架构,实现零代码修改的页面生成系统。从类型定义到页面渲染,每一层专注单一职责,实现真正的数据驱动开发。
全局安装 TypeScript
当 tsc 命令未找到时,你缺少的不是命令,而是编译器本身。
让错误浮现
Next.js 构建悬挂问题的根源不在工具,而在掩盖。严格类型检查不是负担,而是质量的守护者。