TypeScript类型检查的真相:为什么你的代码在运行时还会出错
深夜加班排查bug,发现一个奇怪的问题:TypeScript明明显示类型正确,但用户看到的价格却显示成NaN。查看日志才发现,有个客户端发送的商品价格是字符串"99.99",而你的代码直接进行了数学运算:
const priceWithTax = productData.price * 1.21; // "99.99" * 1.21 = NaN这个NaN穿过整个系统,最终存入数据库。用户看到"NaN元"的价格,完全不明白发生了什么。
TypeScript不是能防止这种错误吗?为什么检查不出这个问题?
TypeScript的类型在运行时消失了
事实可能让你惊讶:浏览器和Node.js根本看不到你写的TypeScript类型。
当TypeScript代码被编译成JavaScript时,会发生"类型擦除"——所有的interface、type、类型注解都会被删除。
你写的代码:
interface Product {
name: string;
price: number;
}
const data = req.body as Product;编译后变成:
const data = req.body; // 类型信息完全消失类型完全消失了。as Product只是告诉TypeScript编译器:"请相信这个数据符合Product类型"。但在运行时,JavaScript引擎对此一无所知。
为什么这样设计?因为TypeScript的类型系统主要目的是帮助开发者写代码时发现错误,而不是在运行时保护数据安全。这是两个不同的问题。
可以这样理解:类型系统就像代码审查时的检查清单。审查完成后,清单就完成了使命,不会跟着代码进入生产环境。
问题的根源:数据来自不可控的外部世界
关键问题在于:你的应用接收的数据可能来自任何地方,而且这些数据不可控:
客户端JavaScript代码可能有bug
移动端App可能是旧版本
第三方api可能改变了接口
用户可能手动篡改请求
其他团队维护的库可能返回新格式
没有任何数据会在进入系统时说:"我完全符合你的Product类型,请放心使用"。JSON就是JSON,它不会主动告诉你它的类型结构。
TypeScript做不到的事情很简单:它看不到运行时的真实数据。它只能检查你写的代码逻辑是否正确。
解决方案:为数据加上运行时验证
解决办法其实很直接:在数据进入系统时,主动验证它的格式。
这就是Zod这类库存在的意义。Zod是一个数据验证库,可以在运行时检查数据是否符合预期。它的聪明之处在于能从验证规则自动推导出TypeScript类型,这样你就不需要维护两份代码了。
import { z } from 'zod';
// 一份代码,同时定义验证规则和TypeScript类型
const ProductSchema = z.object({
name: z.string(),
price: z.number().positive(),
});
// TypeScript类型自动推导
type Product = z.infer<typeof ProductSchema>;现在,Product类型和验证规则完全一致,不会出现类型说是数字、实际却收到字符串的问题。
两种方式对比
原来的做法(盲目信任TypeScript类型)
interface CreateProductDto {
name: string;
price: number;
}
app.post('/products', (req, res) => {
// 直接相信req.body符合类型
const productData = req.body as CreateProductDto;
// price可能根本不是数字,结果是NaN
const priceWithTax = productData.price * 1.21;
db.products.create(productData);
res.send('OK');
});问题:
数据没有真正验证
错误数据进入系统
数据库被污染
用户得不到明确反馈
使用Zod的做法(验证每个数据)
import { z } from 'zod';
const CreateProductSchema = z.object({
name: z.string().min(1),
price: z.number().positive(),
});
type CreateProductDto = z.infer<typeof CreateProductSchema>;
app.post('/products', (req, res) => {
// 主动验证数据
const result = CreateProductSchema.safeParse(req.body);
// 验证失败,拒绝请求
if (!result.success) {
return res.status(400).json({
error: result.error.issues,
});
}
// 通过验证的数据是安全的
const productData = result.data;
const priceWithTax = productData.price * 1.21; // 保证是数字
db.products.create(productData);
res.send('OK');
});优势:
每个数据都经过检查
无效数据被立即拒绝
客户端收到清晰错误提示
类型和验证规则始终保持同步
流程对比:
信任方式:请求 → TypeScript说"检查过了" → 直接使用 → 可能出问题
验证方式:请求 → Zod检查数据格式 → 检查通过才能用 → 安全
→ 检查失败 → 返回错误信息 → 客户端知道问题所在
实际应用场景
场景1:处理API返回的数据
客户端调用API时,也应该验证返回的数据:
const ProductResponseSchema = z.object({
id: z.number(),
name: z.string(),
price: z.number(),
});
const fetchProduct = async (id: string) => {
const response = await fetch(`/api/products/${id}`);
const data = await response.json();
// 不要盲目信任服务器返回的数据格式
const result = ProductResponseSchema.safeParse(data);
if (!result.success) {
throw new Error('服务器返回的数据格式不对');
}
return result.data;
};这样做的好处是,即使后端改了接口,前端也能立即发现问题,而不是默默处理错误数据。
场景2:表单验证
如果你使用react Hook Form,Zod可以无缝集成:
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
const LoginSchema = z.object({
email: z.string().email('请输入有效邮箱'),
password: z.string().min(6, '密码至少6位'),
});
type LoginForm = z.infer<typeof LoginSchema>;
function LoginForm() {
const { register, handleSubmit, formState: { errors } } = useForm<LoginForm>({
resolver: zodResolver(LoginSchema),
});
const onSubmit = (data: LoginForm) => {
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} placeholder="邮箱" />
{errors.email && <p>{errors.email.message}</p>}
<input {...register('password')} type="password" placeholder="密码" />
{errors.password && <p>{errors.password.message}</p>}
<button type="submit">登录</button>
</form>
);
}Zod和React Hook Form配合使用,验证规则统一,错误提示自动展示。
场景3:数据库查询结果验证
从数据库取出的数据也可能格式不对(比如数据迁移期间字段变化),验证一下更稳妥:
const DbUserSchema = z.object({
id: z.number(),
email: z.string(),
createdAt: z.string().datetime(),
});
const getUser = async (userId: number) => {
const row = await db.query('SELECT * FROM users WHERE id = ?', [userId]);
// 验证数据库返回的数据格式
const result = DbUserSchema.safeParse(row[0]);
if (!result.success) {
throw new Error('数据库数据格式不符');
}
return result.data;
};常见问题解答
会不会影响性能?
验证一个对象的耗时是微秒级别的。一次网络请求或数据库查询通常需要毫秒级甚至秒级的时间。Zod的开销几乎可以忽略不计。相比之下,一个bug导致的加班、处理数据污染的成本要高得多。
定义这么多schema会不会很麻烦?
实际上更简洁。以前你需要写TypeScript接口,再写验证函数,两份代码要维护。现在只需要一个schema:
// 以前的方式(两份代码)
interface User {
id: number;
email: string;
age: number;
}
function validateUser(data: unknown): data is User {
// 手写验证逻辑...
}
// Zod的方式(一份代码)
const UserSchema = z.object({
id: z.number(),
email: z.string().email(),
age: z.number().positive(),
});
type User = z.infer<typeof UserSchema>;代码更少,而且不会出现类型和验证逻辑不同步的问题。
TypeScript的类型注解还不够吗?
类型注解对你写的代码很有帮助。但对于外部数据,TypeScript无能为力。因为外部数据在编译时不存在,TypeScript看不到它们。
简单说:TypeScript保护代码质量,Zod保护数据安全。两者都需要。
实用建议
如果你还没使用Zod或类似的验证库,建议这样开始:
从API端点开始,给每个接收用户数据的端点加上验证
然后是第三方API调用,验证返回的数据格式
逐步扩展到表单、数据库查询等地方
不需要一次性改造整个项目,从最容易出问题的地方开始就行。
这不是否定TypeScript的价值。TypeScript很棒,让我们写代码时更有信心。但它不是万能的。在生产环境中,还需要真正的守卫来检查每个进入系统的数据。
使用Zod之后,深夜修复bug的情况会减少很多。而且当问题出现时,错误信息会很清晰,能快速判断是客户端还是服务端的问题。
这样的工作方式,会让你的开发体验更好,代码也更健壮。
本文内容仅供个人学习/研究/参考使用,不构成任何决策建议或专业指导。分享/转载时请标明原文来源,同时请勿将内容用于商业售卖、虚假宣传等非学习用途哦~感谢您的理解与支持!