0-introduction
课程概述
这门课程讨论的是跨代码库的 TypeScript 类型安全问题。虽然在单个代码库内部我们可以实现完美的类型安全,但在不同代码库之间(特别是通过 HTTP 通信时)存在"信任缺口"。
核心问题
类型安全的信任危机
- 前端应用可能完全类型安全,但
response.json()返回的是any类型 - 后端 Express 路由中的
request.body也是any类型 - 我们通常基于"信任"假设数据符合预期类型,但这种信任经常被打破
常见故障原因统计
根据讲师的经验总结:
- 53% - 后端团队更改 API 未通知前端
- 29% - 某人升级了依赖,导致 API 变更
- 其他 - 意外删除 S3 存储桶等操作失误
解决方案方向
从乐观信任到悲观验证
- 不再盲目信任外部数据
- 通过验证建立信任:"信任但验证"
- 在应用边界处进行严格的类型检查
需要验证的边界点
- API 响应数据
- 客户端发送的负载
- 数据库输入/输出
- 用户表单输入
技术挑战示例
// 问题:response.json() 返回 any 类型
const response = await fetch("/api/tasks");
const data = response.json(); // 类型为 any
// 表面解决方案:类型断言
const getTask = async (): Promise<Task> => {
// ...
return response.json(); // any 类型会绕过所有类型检查
};
课程目标
学习如何在应用程序的各个交互点之间保持类型安全,特别是:
- API 边界
- 数据库边界
- 微服务间通信
- 用户输入验证
1-type-guards-vs-schema-validation
类型守卫的问题
手写类型守卫的复杂性
function isTask(value: unknown): value is Task {
// 需要检查是否为对象
if (typeof value !== "object") return false;
// 还要检查不是 null(typeof null === 'object')
if (value === null) return false;
// 检查每个属性...
// 对于嵌套对象会变得极其复杂
}
手写类型守卫的缺陷
- 代码冗长且容易出错
- 对于嵌套对象处理困难
- 逻辑错误会导致运行时问题
- TypeScript 无法验证守卫函数的正确性
Schema 验证库介绍
主要库对比
- Zod - 本课程重点,与 AI 模型集成良好
- Yup - 名字最有趣,API 相似
- io-ts - 函数式编程风格
为什么选择 Zod
- 与 OpenAI 等 AI 模型原生集成
- 可转换为 JSON Schema
- TypeScript 优先设计
- 活跃的生态系统
Zod 基础语法
简单 Schema 定义
const taskSchema = z.object({
id: z.number(),
title: z.string(),
completed: z.boolean(),
});
类型推导
type Task = z.infer<typeof taskSchema>;
// 自动生成对应的 TypeScript 类型
验证方法
// 抛出异常的解析
const task = taskSchema.parse(data);
// 安全解析,返回结果对象
const result = taskSchema.safeParse(data);
if (result.success) {
// result.data 是验证后的数据
} else {
// result.error 包含错误信息
}
其他库语法对比
Yup 示例
const schema = yup.object().shape({
name: yup.string().required(),
age: yup.number().required(),
});
await schema.validate(data);
io-ts 示例
const UserType = t.type({
name: t.string,
age: t.number,
});
UserType.decode(data);
核心优势
- 大幅减少样板代码
- 自动类型推导
- 丰富的验证规则
- 清晰的错误信息
- 运行时安全保障
2-introduction-to-zod
Zod 生态系统
集成库支持
Zod 官网展示了丰富的生态系统集成:
- React Hook Form - 表单验证
- Formik - 另一个表单库选择
- SvelteKit - 全栈框架集成
- JSON Schema 转换工具
- TypeScript 类型生成工具
关键优势
可以在整个应用栈中使用相同的 schema:
- 前端表单验证
- API 请求体验证
- 数据库输入验证
- 响应数据验证
高级验证功能
类型强制转换
z.string().email(); // 验证邮箱格式
z.number().positive(); // 正数验证
z.coerce.string(); // 强制转换为字符串
z.coerce.number(); // 强制转换为数字
字符串验证
z.string()
.min(5) // 最小长度
.max(100) // 最大长度
.email() // 邮箱格式
.url() // URL 格式
.uuid(); // UUID 格式
数字验证
z.number()
.int() // 整数
.positive() // 正数
.min(0) // 最小值
.max(100); // 最大值
复杂类型构建
字面量类型
z.literal("hello"); // 精确匹配字符串
z.enum(["red", "green", "blue"]); // 枚举值
数组和元组
z.array(z.string()); // 字符串数组
z.tuple([z.string(), z.number()]); // 固定长度元组
联合类型和交集
z.union([z.string(), z.number()]); // 联合类型
z.intersection(schema1, schema2); // 交集类型
Schema 组合
引用其他 Schema
const addressSchema = z.object({
street: z.string(),
city: z.string(),
});
const userSchema = z.object({
name: z.string(),
address: addressSchema, // 引用其他 schema
});
扩展 Schema
const baseSchema = z.object({
id: z.number(),
name: z.string(),
});
const extendedSchema = baseSchema.extend({
email: z.string().email(),
});
实用工具方法
schema.pick({ name: true }); // 选择特定字段
schema.omit({ password: true }); // 排除特定字段
schema.partial(); // 所有字段变为可选
3-zod-basics-exercise
项目结构介绍
目录组织
├── client/ # React 前端应用
├── server/ # Express 后端应用
├── shared/ # 共享代码
└── exercises/ # 练习代码
└── zod/ # Zod 基础练习
开发环境设置
cd exercises/zod
npm test # 运行所有测试
npm test exercises # 只运行特定测试文件
基础 Schema 练习
练习 1:用户 Schema
创建用户对象验证:
const userSchema = z.object({
name: z.string(),
age: z.number().int().positive(),
});
type User = z.infer<typeof userSchema>;
学习要点:
- 基础对象定义
- 数字验证规则
- 类型推导使用
练习 2:可选字段和默认值
const userSchema = z.object({
name: z.string(),
age: z.number().int().positive().optional().default(0),
});
重要发现:
- 方法链的顺序很重要
default()会影响生成的 TypeScript 类型- 有默认值时,字段在类型中不再是可选的
练习 3:嵌套 Schema
const addressSchema = z.object({
street: z.string(),
city: z.string(),
zip: z
.string()
.length(5)
.regex(/^\d{5}$/),
apartment: z.string().optional(),
});
const userProfileSchema = z.object({
name: z.string(),
addresses: z.array(addressSchema),
});
关键概念:
- Schema 可以引用其他 Schema
- JavaScript 变量作用域规则适用(需要先定义才能引用)
- 数组类型的定义方法
高级类型练习
练习 4:联合类型
const anonymousSchema = z.literal("anonymous");
const userSchema = z.object({
id: z.number(),
name: z.string(),
});
const userIdentitySchema = z.union([anonymousSchema, userSchema]);
练习 5:自定义验证
const isPrime = (n: number) => {
// 质数检查逻辑
};
const primeNumberSchema = z.number().refine(isPrime, {
message: "Number must be prime",
});
测试驱动开发流程
开发步骤
- 移除测试中的
.todo标记 - 运行测试查看失败信息
- 实现 Schema 使测试通过
- 验证类型推导结果
调试技巧
- 使用
z.infer检查生成的类型 - 观察测试失败信息了解预期行为
- 利用 TypeScript 错误提示
4-zod-basics-solution
自定义验证解决方案
Refine 方法使用
const isPrime = (n: number) => {
if (n < 2) return false;
for (let i = 2; i <= Math.sqrt(n); i++) {
if (n % i === 0) return false;
}
return true;
};
const primeNumberSchema = z.number().refine(isPrime, {
message: "数字必须是质数",
});
关键特性:
- 先进行基础类型检查(number)
- 然后应用自定义验证逻辑
- 可以提供自定义错误信息
- 验证失败时抛出带有错误信息的异常
错误处理策略
// 抛出异常的方式
try {
const result = schema.parse(data);
} catch (error) {
// 处理验证错误
}
// 安全解析方式
const result = schema.safeParse(data);
if (!result.success) {
console.log(result.error.message);
}
数据转换功能
Transform 方法
const dateSchema = z.string().transform((str) => new Date(str));
应用场景:
- JSON API 中的日期字符串转换
- 序列化数据的反序列化
- 数据格式标准化
类型安全特性:
- Transform 输入参数自动推导为验证后的类型
- 输出类型根据 transform 函数推导
- 全程保持类型安全
品牌类型(Branded Types)
概念和用途
const userIdSchema = z.string().brand<"UserId">();
type UserId = z.infer<typeof userIdSchema>;
// 使用时必须通过验证
function getUser(id: UserId) {
/* ... */
}
// 直接传递字符串会报错
getUser("123"); // ❌ TypeScript 错误
// 必须通过 schema 验证
getUser(userIdSchema.parse("123")); // ✅ 正确
价值:
- 在类型层面区分不同用途的相同基础类型
- 强制数据验证流程
- 提高代码安全性和可读性
实用工具类型
Pick、Partial、Omit 操作
const fullUserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
addresses: z.array(addressSchema),
});
// 选择特定字段
const nameOnlySchema = fullUserSchema.pick({ name: true });
// 所有字段变为可选
const partialUserSchema = fullUserSchema.partial();
// 排除特定字段
const userWithoutEmailSchema = fullUserSchema.omit({ email: true });
使用场景:
- API 不同端点需要不同字段组合
- 更新操作(partial)vs 创建操作(完整)
- 敏感信息过滤
自定义类型验证
复杂字符串模式
const hexColorSchema = z
.custom<`#${string}`>((val) => {
if (typeof val !== "string") return false;
return /^#[0-9A-Fa-f]{3}$|^#[0-9A-Fa-f]{6}$/.test(val);
})
.brand<"HexColor">();
特点:
- 支持自定义验证逻辑
- 可以结合品牌类型使用
- 适用于复杂的业务规则验证
组合复杂 Schema
const registrationFormSchema = z
.object({
username: usernameSchema,
password: passwordSchema,
confirmPassword: z.string(),
birthDate: birthDateSchema,
})
.refine((data) => data.password === data.confirmPassword, {
message: "密码不匹配",
path: ["confirmPassword"],
});
最佳实践:
- 将基础 schema 组合成复杂验证
- 使用 refine 进行跨字段验证
- 指定错误路径提高用户体验
5-advanced-zod-uses-exercise
递归类型处理
自引用 Schema 问题
在 JavaScript 中,变量必须先定义后使用,这在递归类型中造成问题:
// ❌ 这样会报错,因为 categorySchema 尚未定义完成
const categorySchema = z.object({
name: z.string(),
subcategories: z.array(categorySchema), // 引用自身
});
解决方案:z.lazy()
const categorySchema = z.object({
name: z.string(),
subcategories: z.array(z.lazy(() => categorySchema)),
});
工作原理:
z.lazy()接收一个函数,延迟执行- 函数执行时,
categorySchema已经定义完成 - 支持任意层级的嵌套结构
数据预处理
preprocess 方法
处理既可能是对象又可能是 JSON 字符串的数据:
const flexibleSchema = z.preprocess(
(input) => {
// 如果是字符串,尝试解析为 JSON
if (typeof input === "string") {
try {
return JSON.parse(input);
} catch {
return input; // 解析失败保持原样
}
}
return input;
},
z.object({
type: z.literal("JSON"),
data: z.number(),
})
);
应用场景:
- API 可能返回字符串或对象
- 配置文件处理
- 用户输入标准化
异步验证
业务场景
验证用户名是否已被占用:
async function isUsernameTaken(username: string): Promise<boolean> {
// 模拟 API 调用
return new Promise((resolve) => {
setTimeout(() => resolve(username === "admin"), 100);
});
}
const usernameSchema = z.string().refine(
async (username) => {
const taken = await isUsernameTaken(username);
return !taken;
},
{ message: "用户名已被占用" }
);
使用注意事项:
- 验证函数必须返回 Promise
- 适合表单验证场景
- 可以结合防抖减少 API 调用
类型强制转换
coerce 的威力
const numberSchema = z.coerce.number().min(100);
// 这些都会通过验证并转换:
numberSchema.parse("150"); // → 150 (number)
numberSchema.parse(200); // → 200 (number)
numberSchema.parse("50"); // ❌ 抛出错误,小于100
numberSchema.parse("abc"); // ❌ 无法转换为数字
其他强制转换
z.coerce.boolean(); // "true" → true, "1" → true, "0" → false
z.coerce.string(); // 123 → "123"
z.coerce.date(); // "2023-01-01" → Date object
使用场景:
- URL 查询参数处理(总是字符串)
- 表单数据处理
- 配置文件解析
- API 数据标准化
高级技巧总结
何时使用各种功能
- z.lazy(): 递归数据结构(树、图)
- preprocess: 数据格式不统一
- async refine: 需要外部数据源验证
- coerce: 类型转换需求
- brand: 类型安全的字符串标识
性能考虑
- 异步验证增加延迟,谨慎使用
- 复杂的 refine 函数影响性能
- 递归结构注意深度限制
- 预处理尽量简单高效
6-validating-json-schemas
Schema 共享策略
跨技术栈的统一验证
Zod schemas 可以在整个应用栈中重复使用:
- 前端:表单验证、API 响应验证
- 后端:请求体验证、数据库输出验证
- 数据层:输入/输出验证
- 文件系统:JSON 文件读写验证
JSON Schema 序列化
// Zod schema 转换为 JSON Schema
const zodSchema = z.object({
name: z.string(),
age: z.number(),
});
// 可以序列化为标准 JSON Schema
const jsonSchema = zodToJsonSchema(zodSchema);
优势:
- 可以在不同语言间共享
- 支持 Go、Python、Ruby 等后端语言
- 可以托管为独立的配置文件
- 与 Swagger/OpenAPI 集成
从类型到 Schema 的反向工程
satisfies 关键字验证
// 已有的 TypeScript 类型
type User = {
id: number;
name: string;
email: string;
};
// 确保 schema 符合现有类型
const userSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
}) satisfies ZodType<User>;
工作原理:
- 使用 TypeScript 5+ 的
satisfies关键字 - 编译时验证 schema 与类型的一致性
- 类型不匹配时显示 TypeScript 错误
- 保持 schema 和类型的同步
实用验证模式
条件验证策略
// 使用 safeParse 进行条件处理
const result = schema.safeParse(data);
if (result.success) {
// 处理验证成功的情况
processValidData(result.data);
} else {
// 根据不同错误类型进行处理
handleValidationError(result.error);
}
多重验证方案
适用于 API 可能返回不同格式数据的场景:
const handleApiResponse = (data: unknown) => {
const formatAResult = formatASchema.safeParse(data);
if (formatAResult.success) {
return handleFormatA(formatAResult.data);
}
const formatBResult = formatBSchema.safeParse(data);
if (formatBResult.success) {
return handleFormatB(formatBResult.data);
}
throw new Error("无法识别的数据格式");
};
性能和最佳实践
验证边界策略
只在必要的边界进行验证:
- API 请求/响应边界
- 数据库输入/输出边界
- 用户输入边界
- 第三方服务集成点
避免过度验证
- 应用内部传递的已验证数据无需重复解析
- 验证后的数据享有完整的类型安全
- "不做事比做事快" - 避免不必要的性能开销
7-setup-the-api-project
项目架构概览
目录结构
├── client/ # React 前端应用(端口 4000)
│ └── src/
│ ├── api.ts # API 调用逻辑
│ └── types.ts # 类型定义
├── server/ # Express 后端应用(端口 4001)
│ └── src/
│ ├── server.ts # 路由和业务逻辑
│ └── database/ # 数据库相关
└── shared/ # 共享代码和类型
开发环境启动
VS Code 用户:
Cmd + Shift + P → Run Task → Start
手动启动:
# 终端 1:启动前端
cd client && npm run dev
# 终端 2:启动后端
cd server && npm run dev
核心关注点
前端边界点
- api.ts - HTTP 请求的主要边界
- types.ts - 类型定义(将被 schema 替换)
- 组件间的数据传递相对安全
后端边界点
- server.ts - Express 路由处理
- database/ - 数据持久化层
- SQL 预处理语句和查询结果
共享资源策略
关于类型和 schema 的存放位置:
- 服务端优先:schema 从后端 API 契约出发
- 客户端优先:根据 UI 需求定义数据结构
- 独立仓库:类型作为独立的包管理
- monorepo shared:在同一仓库的共享目录
实际应用场景
待办事项应用特点
- CRUD 操作的标准模式
- 简单但实用的数据模型
- 涵盖常见的验证需求
- 适合演示跨层级类型安全
真实世界的复杂性
虽然示例简单,但涵盖了生产环境中的关键模式:
- 表单输入验证
- API 契约验证
- 数据库类型转换
- 错误边界处理
8-adding-types-to-requests-responses
Express 类型系统的现实
默认类型状况
Express 中的关键对象都存在类型安全问题:
// 默认情况下的类型
req.body: any // 请求体
req.params: { [key: string]: string } // 路径参数
req.query: ParsedQs // 查询参数(复杂的联合类型)
req.locals: any // 中间件数据
查询参数的复杂性
req.query 的类型定义特别复杂:
string | string[] | ParsedQs | ParsedQs[] | undefined
这种类型虽然比 any 好,但在实际使用中仍然需要大量类型守卫。
Express 泛型类型系统
Request 和 Response 的泛型结构
Request
Params, // 路径参数类型
ResponseBody, // 响应体类型
RequestBody, // 请求体类型
QueryParams, // 查询参数类型
Locals // locals 类型
>
手动类型注解的局限性
// 可以手动指定类型
interface CreateTaskRequest extends Request {
body: CreateTaskBody;
}
app.post("/tasks", (req: CreateTaskRequest, res) => {
// req.body 现在有正确的类型
const task = req.body; // 类型安全的
});
问题:
- 仍然基于"信任"而非验证
- TypeScript 相信你的类型声明
- 运行时数据可能不符合声明的类型
- 没有实际的数据验证
核心问题分析
信任 vs 验证
当前的类型系统问题:
// 问题:基于假设的类型安全
app.post("/tasks", (req: Request<{}, TaskResponse, CreateTaskBody>, res) => {
// TypeScript 认为 req.body 是 CreateTaskBody
// 但实际可能是任何数据
const task = req.body; // 可能不是我们期望的类型
});
需要解决的边界
- 请求边界:验证传入的数据
- 响应边界:确保返回正确的数据格式
- 数据库边界:处理 SQL 查询结果的类型转换
- 中间件边界:验证中间件添加的数据
解决方案预览
从类型注解到实际验证
下一步将使用 schema 验证替代单纯的类型注解:
// 目标:实际验证而不仅仅是类型注解
app.post("/tasks", (req, res) => {
// 验证并获取类型安全的数据
const taskData = createTaskSchema.parse(req.body);
// 现在我们知道 taskData 确实符合预期格式
});
这种方法将"信任但验证"的原则应用到 Express 应用的每个边界点。
9-api-server-schema-validation
服务端 Schema 验证实践
定义数据契约
首先建立完整的数据模型:
const taskSchema = z.object({
id: z.number(),
title: z.string().min(1),
description: z.string().optional(),
completed: z.boolean().default(false),
});
// 派生其他操作所需的 schema
const createTaskSchema = taskSchema.omit({ id: true });
const updateTaskSchema = taskSchema.partial().omit({ id: true });
const taskListSchema = z.array(taskSchema);
设计原则:
- 从完整的数据模型开始
- 使用
omit、partial等工具派生变体 - 为不同操作定义专门的 schema
请求体验证
app.post("/tasks", async (req, res) => {
// 验证请求体
const taskData = createTaskSchema.parse(req.body);
// 此时 taskData 具有完整的类型安全
const result = await insertTask(taskData);
res.json({ message: "任务创建成功", id: result.insertId });
});
关键优势:
req.body从any转换为具体类型- 验证失败时自动抛出错误
- 后续代码享受完整类型安全
路径参数处理
app.get("/tasks/:id", async (req, res) => {
// 路径参数默认是字符串,需要转换
const id = z.coerce.number().parse(req.params.id);
const task = await getTaskById(id);
const validatedTask = taskSchema.parse(task);
res.json(validatedTask);
});
数据库边界验证
输出数据验证
app.put("/tasks/:id", async (req, res) => {
const id = z.coerce.number().parse(req.params.id);
const updates = updateTaskSchema.parse(req.body);
// 验证数据库查询结果
const existingTask = await getTaskById(id);
const validatedExisting = taskSchema.parse(existingTask);
const updatedTask = { ...validatedExisting, ...updates };
await updateTask(id, updatedTask);
res.json(validatedExisting);
});
数据库类型兼容性处理
SQLite 的特殊情况:
// SQLite 存储布尔值为 0/1,需要强制转换
const taskSchema = z.object({
id: z.number(),
title: z.string(),
description: z.string().optional(),
completed: z.coerce.boolean(), // 自动处理 0/1 转换
});
验证策略和性能考虑
边界验证原则
在这些地方进行验证:
- API 请求进入点
- 数据库查询结果
- 第三方服务响应
不需要验证的地方:
- 应用内部的已验证数据传递
- 类型已经确定的计算结果
错误处理
app.post("/tasks", async (req, res) => {
try {
const taskData = createTaskSchema.parse(req.body);
// 处理逻辑...
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({
error: "数据验证失败",
details: error.errors,
});
}
// 处理其他错误...
}
});
内联 Schema 使用
对于简单验证,可以直接内联使用:
// 简单的内联验证
const id = z.number().parse(+req.params.id);
// 或使用强制转换
const id = z.coerce.number().parse(req.params.id);
这种方法在现有代码库中逐步引入验证特别有用,可以从最关键的端点开始,逐步扩展到整个应用。
10-client-app-schema-validation
客户端验证策略
类型共享的初步方案
首先使用"复制粘贴"方法将服务端的 schema 同步到客户端:
// 从服务端复制相同的 schema 定义
const taskSchema = z.object({
id: z.number(),
title: z.string().min(1),
description: z.string().optional(),
completed: z.boolean().default(false),
});
const taskListSchema = z.array(taskSchema);
type Task = z.infer<typeof taskSchema>;
临时性解决方案:
- 快速验证概念可行性
- 为后续的共享策略建立基础
- 暴露同步维护的问题
API 响应验证
export async function fetchTasks(): Promise<Task[]> {
const response = await fetch("/api/tasks");
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
// 验证 API 响应格式
const tasks = taskListSchema.parse(data);
return tasks; // 现在具有完整类型安全
}
关键改进:
response.json()不再返回any- 运行时验证确保数据格式正确
- 验证失败时提供明确的错误信息
从信任到验证的转变
之前的问题:
// 基于信任的方法
async function fetchTasks(): Promise<Task[]> {
const response = await fetch("/api/tasks");
return response.json(); // any 类型,纯粹的信任
}
现在的解决方案:
// 验证后信任的方法
async function fetchTasks(): Promise<Task[]> {
const response = await fetch("/api/tasks");
const data = await response.json();
return taskListSchema.parse(data); // 验证后确保类型安全
}
验证失败处理策略
主动失败 vs 静默错误
// 主动失败:快速发现问题
try {
const tasks = await fetchTasks();
// 确保是正确的数据格式
} catch (error) {
if (error instanceof z.ZodError) {
// API 返回了意外的数据格式
console.error("API 数据格式错误:", error.errors);
// 显示友好的错误信息给用户
}
throw error; // 重新抛出,让上层处理
}
优势:
- 数据格式问题立即暴露
- 提供详细的错误上下文
- 防止静默的数据损坏
类型推导的自动化
// 不需要显式声明返回类型
export async function fetchTasks() {
// TypeScript 自动推导为 Promise<Task[]>
const response = await fetch("/api/tasks");
const data = await response.json();
return taskListSchema.parse(data);
}
测试友好的副作用
自动化测试的好处
schema 验证为测试提供了额外的保障:
- 测试数据必须符合 schema
- API 模拟也必须返回正确格式
- 重构时类型不匹配立即暴露
开发时的快速反馈
- 后端 API 变更立即在前端暴露
- 数据格式不一致时快速失败
- 减少调试时间和生产环境错误
下一步优化方向
当前的复制粘贴方法暴露了几个需要解决的问题:
- 同步维护负担:schema 变更需要手动同步
- 版本不一致风险:前后端 schema 可能出现分歧
- 代码重复:相同的 schema 在多处定义
后续课程将介绍更优雅的共享策略,包括:
- 共享包/库的方案
- 自动生成和同步机制
- 基于 OpenAPI/Swagger 的合约优先开发