ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

TypeScript运行时校验:从手写if到Zod声明式Schema

TypeScript运行时校验:从手写if到Zod声明式Schema 做了几年前端我把自己写过最多的代码粗算了一下不是业务逻辑而是散落在各个文件里的校验判断。早年写表单老大要求必填、长度、格式三层if十几个字段写下来代码一眼望去全是return。后来换了TypeScript类型系统确实帮我挡掉了不少低级错误可真正把接口连上之后才发现运行时数据依然不受控制后端随时可能返回一个你类型定义里根本不存在的字段。直到我在项目里引入Zod把校验这件事从手写一堆if变成了声明一份Schema这套体系才算稳下来。这篇内容适合所有用TypeScript但还在靠手写判断做运行时校验的人也适合想搞清楚Zod怎么跟表单、服务端配合的开发者。文章不会绕太远直接按我对Zod的理解和使用路径来讲。1. 为什么我把手写if校验换成了Zod1.1 手写校验代码的三宗罪先还原一段很常见的用户资料校验代码function validateUser(input: any) { if (!input.name) return name不能为空; if (typeof input.name ! string) return name必须是字符串; if (input.name.length 30) return name不能超过30个字符; if (!/^[\w.-][\w-]\.[\w.-]$/.test(input.email)) return email格式不对; return null; }这段代码看着没毛病逻辑也直白。可项目一复杂起来问题就全暴露了。第一规则和实现混在一起没法声明式地描述这个字段到底应该长什么样每次换人接手都得从头读一遍if块。第二错误信息靠字符串返回前端想定位具体是哪个字段出错只能靠解析文案或者约定一套错误码再switch翻译一遍维护成本极高。第三TypeScript在这里毫无参与感入参是any整个函数等于把自己隔离在类型系统之外。这三个痛点叠加在一起导致项目里越是核心的数据入口代码越像补丁摞补丁。而Zod解决这些问题的方式很直接把这个字段应该是什么结构、什么类型、什么格式写成一份Schema用一份声明同时完成校验、类型推导和错误聚合。1.2 类型系统是编译期的安全带不是运行时的守门员很多刚接触Zod的朋友会有一个误解觉得我有TypeScript字段定义得明明白白还需要运行时校验吗这里必须把话说透TypeScript的类型在编译完就没了运行时根本不存在这套类型。接口返回的数据、用户填的表单、URL上的查询参数、localStorage里存的对象全都绕过了类型系统属于运行时校验的地盘。我自己踩过一个很典型的坑有次联调后端在某个异常分支里把用户昵称返回成了数字接口文档里明明写着string前端渲染时直接崩。这种问题类型定义帮不了忙因为类型只约束你写代码时怎么认为约束不了后端运行时往你手里塞了啥。Zod的思路说白了就是既然类型在运行时不可靠那就在数据进入业务代码前用一份Schema把结构和规则都验一遍。1.3 理顺校验这个词应用层校验和完整性校验是两码事搜索校验相关话题的时候会冒出来一堆词CRC16、CRC32、文件Hash、魔数校验、完整性校验算法。这些跟Zod完全是两个维度的东西。底层那些校验核心是完整性保障确保一段字节流转到接收方时没被篡改、损坏或传输丢位比如下载软件时官方提供的SHA256值比如串口协议里随包发送的CRC校验和。Zod做的是语义校验关心的是这条数据是不是我期望的结构、类型、长度、格式。两者不是替代关系。文件下载完算个Hash确认没坏是完整性校验下载的配置文件里某个字段该是数字却写成了字符串这是语义校验该交给Zod这一类工具。千万别拿CRC去检查字段格式也别指望Zod能保证文件流没受损。把这两层边界搞清楚读任何校验库文档都不会迷糊。2. Zod的核心设计一份Schema就相当于一份契约2.1 最基本的Schema怎么读Zod的一切都从Schema开始。说得直白点Schema就是一个描述对象该长什么样的声明式定义import { z } from zod; const UserSchema z.object({ id: z.number().int().positive(), name: z.string().min(1, 姓名不能为空).max(30, 姓名太长了), email: z.string().email(邮箱格式不对), age: z.number().min(0, 年龄不能为负).max(150, 年龄不合法).optional(), });这段代码几乎可以当自然语言读id必须是正整数name是1到30个字符的字符串email要符合邮箱格式且报错文案是邮箱格式不对age是0到150的数字且允许不传。z.string()后面直接跟.min()、.max()、.email()这种链式调用写起来非常顺。这里最值钱的不是少写了几行if而是Schema变成了数据可以被组合、被复用、被单独维护。把Schema放一个独立文件里前端表单、后端接口、中间处理逻辑都能引用同一份定义规则只要有变化就改一处。2.2 z.infer一份定义类型自动长出来Zod给了我不小的惊喜是z.infer一行代码直接把Schema变成TypeScript类型type User z.infertypeof UserSchema; // 等价于 // { // id: number; // name: string; // email: string; // age?: number | undefined; // }从此不用再手动维护一套TypeScript类型、再写一套运行时校验了。Schema改了类型自动跟着变反过来用这个类型约束代码的时候编辑器也会提示你哪些字段可空、哪些必填写错补全都提醒你。一个需要适应的细节optional()推导出来的是age?: number | undefined而不是你直觉里的number。所以用age之前还是得做存在性判断这是TypeScript的合理行为也是新手第一次用推断类型最容易懵的地方。2.3 数组、枚举、联合类型复合结构怎么声明真实业务里没有几个校验是单字段的Zod对复合结构的支持非常成熟。数组z.array(z.string())表示字符串数组配合.min(1)可以表达至少一个元素。枚举z.enum([draft, published, archived])把状态限定在三个值里。联合z.union([z.string(), z.number()])表示字段既可以是字符串也可以是数字。默认值z.string().default(anonymous)在字段缺失时直接补默认值parse出来的结果不会缺这个字段。推荐的玩法是先把常用模式抽成独立Schema然后像积木一样拼接const BaseMeta z.object({ page: z.number().int().min(1).default(1), pageSize: z.number().int().min(1).max(100).default(20), }); const ListResponse z.object({ list: z.array(UserSchema), meta: BaseMeta, });这比在每个接口里手写page取不到就置1、pageSize上限100要舒服得多。规则集中定义改动一处全链路生效而且Meta这个结构在任何列表接口里都能复用。3. 自定义校验从正则到跨字段的superRefine3.1 预置校验规则够用但总有内置库管不到的格式Zod预置了email、url、uuid、cuid这些常用格式校验大部分场景开箱即用。但业务里总有那么几个格式是内置覆盖不了的最典型的就是中国身份证号码。身份证校验本身就是一套完整算法前17位是数字最后一位可能是数字也可能是X还要对前17位加权求余算出校验位。这已经不是一个简单正则能搞定的事情了。先用正则定框架const idCardSchema z.string().regex(/^\d{17}[\dXx]$/, 必须是18位身份证格式);正则只能挡住12345678901234567a这种明显错误挡不住格式合法但校验位算不对的号码。业务里如果对身份证有强校验要求就得把完整算法写进去这就轮到Zod的自定义校验登场了。3.2 refine给字段加自定义规则最直接的入口refine是Zod提供的最基本的自定义校验口子接收一个返回boolean的函数函数内随便写算法const idCardSchema z.string() .regex(/^\d{17}[\dXx]$/, 必须是18位身份证格式) .refine((value) { const weights [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]; const checkCodes [1, 0, X, 9, 8, 7, 6, 5, 4, 3, 2]; let sum 0; for (let i 0; i 17; i) { sum Number(value[i]) * weights[i]; } return checkCodes[sum % 11].toUpperCase() value[17].toUpperCase(); }, 身份证号码校验不通过); const result idCardSchema.safeParse(110101199003071234);把正则和refine串起来格式校验和语义校验一步到位。身份证只是举例子其他场景比如车牌号、银行卡Luhn校验、IPv6地址格式全都能用refine塞进去。refine这个API够简单理解成本低团队成员接手也容易。3.3 superRefine处理依赖字段的交叉校验refine有个天然短板它在单个字段内部做判断看不了同一条数据里的其他字段。表单里最常遇到的密码和确认密码要一致就是典型的跨字段规则用refine写会很别扭superRefine就是为解决这个场景存在的const RegisterSchema z.object({ password: z.string().min(6, 密码至少6位), confirmPassword: z.string(), }).superRefine((data, ctx) { if (data.password ! data.confirmPassword) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: [confirmPassword], message: 两次输入的密码不一致, }); } });superRefine的回调能拿到整条数据通过ctx.addIssue把错误挂在指定path上。这样密码不一致会精确出现在confirmPassword字段下面而不是记在整条数据头上。前端拿error.path和error.message就能准确渲染。还有一个常被忽略的能力同一个Schema里可以叠加多个issue。一个对象可以同时报出密码不一致、邮箱格式不对、用户名格式错等多个错误而不是像传统校验那样一次只报一个字段改完再冒烟式报下一个。配合error.issues数组一次性展示全部问题用户体验会好很多。4. 表单校验实战Zod与react-hook-form的组合拳4.1 zodResolver让Schema直接变成表单规则前端业务里校验最大的战场就是表单。react-hook-form是React社区的主流表单库而它和Zod的搭配已经成为官方推荐的标准打法import { useForm } from react-hook-form; import { zodResolver } from hookform/resolvers/zod; const FormSchema z.object({ username: z.string().min(2, 用户名至少2个字符), email: z.string().email(邮箱格式不对), phone: z.string().regex(/^1[3-9]\d{9}$/, 手机号格式不对), }); function RegisterForm() { const { register, handleSubmit, formState: { errors }, } useForm({ resolver: zodResolver(FormSchema), }); return ( form onSubmit{handleSubmit((data) console.log(data))} input {...register(username)} / {errors.username span{errors.username.message}/span} input {...register(email)} / {errors.email span{errors.email.message}/span} input {...register(phone)} / {errors.phone span{errors.phone.message}/span} /form ); }zodResolver帮我们做了一件很关键的事把Zod的错误信息映射成react-hook-form的errors对象。errors.username.message直接就是Schema里写的那句用户名至少2个字符。这套组合的优势不只是少写代码。表单提交时resolver会在onSubmit之前自动触发校验校验通过才调用handleSubmit里的回调等于表单层面已经有一道完整关卡。字段的规则、文案、顺序全部集中在FormSchema里后面要做动态表单验证比如某个字段根据另一个字段的值决定是否必填改Schema就行不用动组件逻辑。4.2 错误文案的统一管理和多语言处理真实项目跑起来错误文案逃不开多语言。很多人会把i18n key直接写在Schema里const FormSchema z.object({ username: z.string().min(2, errors.username.min), });翻译交给渲染层处理这个方案可行。但要注意Zod的setErrorMap是全局修改默认消息如果只想改单个字段的文案还是在每个ZodType上直接传message更可控别顺手把全局的错误消息全换了。另一个高频需求是带参数的文案拼接比如长度必须在2到10之间。我推荐封装一个helper函数function lengthError(min: number, max: number) { return 长度必须在${min}到${max}之间; } const nicknameSchema z.string() .min(2, lengthError(2, 10)) .max(10, lengthError(2, 10));这样文案改起来、翻译起来都方便也不会出现min写2、message里写成3这种离谱的错位。团队里一旦形成这种习惯后期做多语言版本就不再是一件痛苦的事。4.3 parse会抛异常safeParse才是生产环境首选这个坑我见很多人踩过。Zod的parse()在数据不合规时会直接抛ZodError外面必须包try/catch而safeParse永远不抛异常返回一个带success标志的联合类型const result UserSchema.safeParse(someUnknownData); if (result.success) { // result.data 是安全类型 console.log(result.data); } else { // result.error 是 ZodError console.log(result.error.issues); }生产环境里凡是数据有可能不合法的场景比如接口响应、用户输入、localStorage里的老数据我都直接推荐safeParse。合法就走成功分支拿类型安全的数据不合法就走失败分支拿issues列表代码路径清晰不需要try/catch这种控制流。提一句Zod版本的事。Zod 4相比3在体积、性能和Tree-shaking上改善不少运行时错误信息也更可读。如果你的项目还在用Zod 3绝大部分代码升级无需改动只有少量API做了调整改起来不费劲。我的建议是尽早升级长期维护的收益很值。5. 进阶玩法Transform、Schema组合与服务端复用5.1 transform校验完之后顺手把数据整形业务里有一类高频场景数据格式校验通过后还需要做一次转换。比如把字符串2025-05-20确认是合法日期后转成时间戳把用户提交的字符串数字转成真正的数字。这些操作Zod的transform可以一步搞定const FormattedDateSchema z.coerce.date(); // z.coerce.date() 会尝试把字符串/数字强制转换成 Date const TimestampSchema FormattedDateSchema.transform((date) date.getTime()); const result TimestampSchema.parse(2025-05-20);这段代码的完整流程是数据进来先做类型强制转换再校验是否合法日期最后通过transform输出时间戳。业务层拿到的直接是可用的number不用先parse再map。把一个转换链路写进Schema里规则和转换集中一处调用方只需要关心输出类型。需要特别注意transform的执行顺序Zod会先把所有校验规则跑完全部通过以后才执行transformrefine、superRefine的规则也会在transform之前执行。所以别在transform里再塞一轮校验逻辑那是职责重叠也容易把自己绕晕。5.2 派生Schema把公共定义抽出来组合出业务Schema管理大量Schema的经验里最值得推荐的习惯是先建基础Schema再派生业务Schemaconst BaseUserSchema z.object({ id: z.string().uuid(), createdAt: z.iso.datetime(), }); const UserDetailSchema BaseUserSchema.extend({ name: z.string().min(1), profile: z.object({ avatarUrl: z.string().url().optional(), bio: z.string().max(200).optional(), }), }); const UserListItemSchema BaseUserSchema.extend({ summary: z.string(), });.extend()会保留基础Schema的全部规则再追加新字段。列表页和详情页的公共字段规则就不用写两遍后面给id加一条必须以usr_开头的规则两个派生Schema通通生效。Zod还支持.pick()、.omit()、.partial()、.required()形态跟TypeScript的工具类型很像熟悉TS原生工具类型的同学上手会非常快。这些组合能力让Schema不是一堆孤岛而是一张能生长的网。当需要同步几十个接口之间的字段定义时一处定义、四处派生能省下大量重复劳动。5.3 前后端共用同一份Schema把校验口径统一前端校验过了后端为什么还要再校验一遍这个问题新人特别爱问。答案很直接前端的校验本质上只是体验优化用户随时可以通过命令行直接构造请求打给后端。所以后端必须独立做一次完整校验不能假设数据一定来自前端的表单。Zod恰好是前后端共用Schema的完美选择它是纯TypeScript逻辑Node服务端直接用同一份定义// src/schemas/order.ts export const OrderSchema z.object({ goodsId: z.string().uuid(), quantity: z.number().int().min(1).max(99), receiverAddress: z.string().min(5).max(200), note: z.string().max(500).optional(), });前端打包时引用这份Schema做表单校验提示后端在路由处理器里再safeParse一遍两边跑的是同一个定义规则永远不会出现前端要求必填、后端允许为空这种分裂。用Monorepo管理前后端项目时这个模式的收益会放到最大。前后端共用Schema之后按钮重复提交的问题也顺带好处理了前端在请求发出后置灰按钮、加防抖后端在入口做幂等校验和重复请求拦截。前端校验挡的是用户手滑后端校验挡的是绕过前端的恶意请求两者缺一不可。6. 边界要分清Zod不负责的校验由谁承担6.1 Zod管语义CRC/Hash/魔数管完整性我在前面反复强调过搜索校验会出现一大批底层校验相关的技术词。很多同学会迷茫CRC16、CRC32、文件Hash、魔数校验这些跟Zod到底什么关系一句话总结Zod校验的是数据应该长成什么样Hash/CRC校验的是数据是否和出发时一致。CRC16/CRC32常用于通信、存储、网络传输的差错检测。比如串口协议、工业采集设备的数据包校验发送方算出一个简短校验和随数据一起传输接收方重新计算对比就能快速发现数据在链路里是否被干扰。文件Hash校验下载大型软件时官方给出的SHA256值。比对哈希可以确认下载的文件没有被替换或损坏。文件魔数读取文件开头的几个字节判断真实文件类型。比如PNG文件前8个字节是固定魔数靠这个能识别文件是不是真的图片而不是改了个后缀名糊弄人。Zod不碰字节流它只关心数据在结构、类型、范围上是否符合业务预期。让Zod去处理二进制流的完整性校验它既没这个能力也没这个必要让CRC去检查字段格式它根本没有字段的概念。理解了这个分工再看各种校验技术就不会混在一起。6.2 文件上传场景里两类校验怎么协同文件上传是这两类校验碰面最频繁的场景。前端需要做的是语义校验MIME类型是不是image/png、文件大小是否在限制范围。而服务端需要额外确认文件内容在传输中没被篡改。实际项目里我一般分三层// 第一层前端体验校验确认基本信息合法 const fileSchema z.object({ name: z.string().min(1), type: z.string().includes(image/), size: z.number().max(10 * 1024 * 1024), });第二层服务端收到文件后先按这份Schema做语义校验确认name、type、size都合理。第三层做完整性校验常用的手段是计算文件的SHA256跟前端算好的值比对或者读取文件头魔数确认它真是图片而不是伪装成图片的可执行文件。这三层每层都干自己擅长的事脏数据还没进业务逻辑就被拦住了。6.3 一套完整校验体系的设计思路最后把我手头项目里的校验体系拆开说说算是一份可以直接抄的分工方案。中等体量的系统目前一共分了四层入站数据语义校验前端表单、后端API请求体、外部接口响应全部用Zod统一safeParse。规则按业务模块拆细分文件用的时候从对应模块import。前后端规则一致性Schema放在共享包里前端做体验校验服务端做强制校验引用同一份定义绝不允许前后端各写一套。传输与存储完整性校验需要防篡改、防损坏的场景该上CRC16/CRC32就上该算SHA256就算SHA256。这部分逻辑独立成工具函数跟业务校验完全隔离。格式识别校验文件上传场景里的魔数识别、类型探针单独写一个模块不做任何业务逻辑只回答这到底是什么类型的文件。几层各干各的活互不越界也没有重复劳动。数据从用户输入到写入数据库每一层都在替下一层把关脏数据被拦下的概率就非常低了。做这行久了我的体会是校验代码的质量决定了一个系统的底线。Zod把我从大量手写if判断里解放出来让我更愿意把规则写清楚、写集中但真正让系统稳住的是搞清楚哪一层的校验该由谁来做。语义校验交给Zod完整性校验交给CRC/Hash格式识别交给魔数逻辑这套分工想明白了比多堆一百行校验代码都管用。
返回列表