资讯详情

我写了一个校验库:impeccable 的设计理念与工程实践

📅 2026/10/29 5:52:20 | 华诺云谱 👁 阅读
我写了一个校验库:impeccable 的设计理念与工程实践
就说实话吧我又写了一个校验库名字叫impeccable。起因挺简单的去年接手一个老项目接口返回的数据结构悄悄变了一下某个字段从字符串变成了null前端直接白屏。等到排查完才发现链路里没有一道防线提前拦住这个变化。当时用的校验方式是满屏的if (x typeof x string)代码丑不说报错信息全靠写代码的人当场发挥压根谈不上可维护。所以我决定自己动手写一个更顺手的校验库。这篇文章会把impeccable的设计思路、核心 API、错误处理机制、实际集成姿势和一些边界坑都拆开来讲。如果你正在为接口数据到底该不该信发愁或者觉得手里那套校验方案用着别扭这篇文章应该能给你一些实在的参考。1. 为什么我又写了一个校验库先聊聊现有方案的槽点校验场景谁都会遇到前端表单、后端入参、服务之间的数据传输甚至配置文件读取。但是市面上的校验方案用起来总有一种差一点的感觉。在介绍 impeccable 之前先花点篇幅说说我眼里的几个痛点这也是这个库所有设计决策的来源。1.1 手动 if/else 的痛代码丑、重复多、错误全靠猜很多项目最开始的校验代码长这样function validateUser(data) { if (!data) return { error: 数据不能为空 } if (typeof data.name ! string) return { error: name 类型错误 } if (data.name.length 2) return { error: name 太短 } if (typeof data.email ! string || !data.email.includes()) return { error: email 不对 } // 还有 address.city、tags 数组…… 继续往下写 return { data } }这段代码的问题不在于运行时而在于长期演进字段一多校验逻辑和业务逻辑混在一起散落在各个文件里错误信息格式全靠当天心情前端拿到的可能是类型错误也可能是name 不正确还有一个更隐蔽的问题——!data这个判断会把0、false、空字符串全部拒之门外后面会讲到。我把这类代码称为一次性校验。它看起来最省事其实最贵因为每次接口调整、字段新增你都要重读一遍充满魔法值的判断逻辑。1.2 主流校验库的问题错误信息说的是机器话不是人话主流校验库解决了一部分声明式校验问题schema 定义确实比手动判断清晰。但我实际用下来发现几个共性痛点错误信息面向开发者不面向调用方。很多库默认报错是英文的而且是偏底层的英文比如Invalid value、Expected number, received string。前端拿到之后通常还要再做一层翻译和映射这个映射逻辑又散落在各个组件里。错误路径的表达很隐晦。嵌套对象、数组下标混在一起时路径信息经常是需要自己解析的字符串很难直接拿来给用户看。类型推导不够顺手。部分库从 schema 推导 TS 类型时optional、nullable、默认值这几个概念混在一起推导出来的类型跟运行时行为对不上。体积和复杂度。一些流行库为了覆盖企业级需求API 极其丰富但如果你只需要校验几十个字段引入它们带来的代码量是有点杀鸡用牛刀的感觉。这其实不是库的问题而是定位问题。主流库面向的是我需要大而全的方案而我想要的是一个足够纯粹的校验内核外加非常好读的错误信息。这就是 impeccable 立项时想清楚了的第一件事。1.3 impeccable 给自己定的三条规矩既然要自己写就要先把原则定下来。我给自己定了三条硬规矩之后所有 API 设计、错误设计、性能取舍都以此为准错误信息是第一公民。一条校验失败信息应当让一个没看过代码的人也能看懂问题出在哪并且可以直接展示给终端用户。零运行时依赖。校验库本质上是一堆类型判断逻辑没必要引入任何运行时第三方依赖。类型推导与运行时行为严格一致。TS 类型推导出的结果必须是运行时校验通过后的结果两者不允许出现分叉。这三条规矩构成了 impeccable 的全部骨架。后面每个章节你会发现所有设计都能回溯到这三条规矩上。2. 核心 API 设计schema 定义、类型推导与组合规则这部分是实际用得最多的我把 impeccable 的基础用法和类型系统拆开来讲。2.1 最基础的用法一张表看懂常用校验器impeccable 的 API 入口很简单核心是一个s对象通过它声明各种校验器。以下是开箱即用的校验器清单校验器说明常用链式方法s.string()字符串min、max、email、regex、optionals.number()数字int、positive、min、max、optionals.boolean()布尔optional、customs.object({...})对象结构strict、passthroughs.array(s.string())数组结构min、max、lengths.enum([a, b])枚举optionals.union([s.string(), s.number()])联合类型optionals.any()任意值极少用但存在s.custom(fn, config)自定义校验逻辑任意值校验基础用法长这样import { s } from impeccable const UserSchema s.object({ id: s.number().int().positive(), name: s.string().min(2).max(30), email: s.string().email(), age: s.number().int().min(0).max(150).optional(), tags: s.array(s.string()).max(10), address: s.object({ city: s.string(), street: s.string().optional(), }), }) const result UserSchema.safeParse({ id: 1, name: 张三, email: zhangsanexample.com, tags: [], address: { city: 上海 }, })safeParse返回一个判别联合类型要么是{ ok: true, data }要么是{ ok: false, errors }。你不需要 try/catch 就能安全处理校验失败的情况这是我在实际业务里最喜欢的一种设计——校验失败是常态不该用异常机制去表达。2.2 类型推导让编辑器比你先发现 bugimpeccable 的类型系统是我花心思最多的地方。从一个 schema 推导出对应的 TS 类型核心是一个InferType工具类型import { s } from impeccable const UserSchema s.object({ id: s.number(), name: s.string(), age: s.number().optional(), }) type User s.InferTypetypeof UserSchema // 推导结果等价于 // type User { id: number; name: string; age?: number | undefined }注意age推导出的是number | undefined而且 TS 里它是可选属性。这一点很多库做不到位——有些库会把optional推导成number | undefined但属性仍是必选的导致你拿到的类型其实跟运行时行为并不一致。impeccable 保证两者严格对齐原因就是前面说的第二条规矩。如果你更偏好显式声明接口也可以用s.ObjectSchemaUser反推校验器类型。这在一些从接口定义出发的团队里非常好用interface User { id: number name: string } const UserSchema s.ObjectSchemaUser().shape({ id: s.number(), name: s.string(), })类型推导的完整逻辑比较复杂但使用起来非常简单。你在编辑器里写 schema 时字段名、类型、可选性都会被 TypeScript 严格约束该报错的地方写不到运行时就报错。2.3 复杂结构的组合嵌套对象、数组与联合类型实际业务中的数据结构很少是扁平的嵌套不可避免。impeccable 支持任意层级的组合const OrderSchema s.object({ id: s.string(), items: s.array( s.object({ sku: s.string(), quantity: s.number().int().positive(), remark: s.string().optional(), }) ).min(1), customer: s.object({ userId: s.string(), vipLevel: s.enum([normal, silver, gold]).optional(), }), })联合类型可能是最容易被忽视但最有用的一个能力。比如接口的响应状态const ApiResponse s.union([ s.object({ status: s.enum([success]), data: s.any() }), s.object({ status: s.enum([error]), message: s.string() }), ])s.union在校验时会按顺序尝试每个分支直到匹配成功。它的性能略低于单分支但正常使用完全感知不到。此外还有s.intersection可以合并两个 schema类似 TS 的交叉类型。当你从要不要这个能力的角度去审查 API 时会发现 impeccable 的 API 数量刻意保持得非常克制能用现有能力组合解决的不额外引入 API。3. 错误信息设计把这个库的名字真正落到实处为什么 impeccable 叫 impeccable因为我在实际项目里受够了报错看不懂还得自己猜字段的体验。这一章是全文的重点也是这个库最与众不同的地方。3.1 同样一个错误两种完全不同的报错假设前端提交了这样一份数据{ id: abc, // 应该是数字 name: x, // 太短 address: { province: 浙江 // 缺少 city } }传统校验库的默认输出往往是这样的[ { message: Expected number, received string }, { message: String must contain at least 2 character(s) }, { message: Required }, ]信息没错但前端拿到之后得猜第一条说的是哪个字段这个报错能不能直接展示给用户大概率不能因为Expected number, received string这种表述对用户来说太抽象了。impeccable 的默认输出是这样的{ ok: false, errors: [ { path: id, code: number.mismatch, message: id 必须是数字当前值是字符串, received: abc }, { path: name, code: string.min, message: name 长度不能少于 2 个字符, received: x, limit: 2 }, { path: address.city, code: object.required, message: address.city 不能为空, received: null } ] }每条错误都包含四个关键部分path定位、code程序要用的标识、message人类能读懂的描述、received实际收到的值。前端拿到后几乎不需要二次解析该展示 message 就展示 message该根据 code 处理逻辑就处理 code。提示message默认是中文的但在 SDK 初始化和单条校验器上都可以全局覆盖。生产级项目建议在初始化时统一设置团队规范的语言。3.2 错误路径追踪像文件路径一样定位字段嵌套结构里的错误定位是校验库最容易做得含糊的地方。impeccable 使用类似 JSON Pointer 的路径表达方式数组下标会非常明确地出现在路径里const data { items: [{ name: item1 }, { name: }], } const result ItemsSchema.safeParse(data) // errors[0].path items[1].name // errors[0].message items[1].name 不能为空这个items[1].name的格式既是给人看的定位信息也可以在程序里直接用来做字段级别的错误绑定。前端做表单错误展示时不用自己去解析哪一层出错了直接把 path 作为 key 绑定到输入框即可。实现路径追踪时内部维护了一个当前路径栈每深入一层就 push 一段路径碎片。这是一个相对底层的设计但暴露给用户的价值很大无论嵌套多少层报错永远能一路追溯到最具体的字段。3.3 错误码与多语言给前端展示留好接口code字段是给程序用的跟message解耦。这意味着你可以只保留 code在业务层做更精细的控制。常见错误码如下code含义string.min/string.max字符串长度越界string.email邮箱格式不正确number.int不是整数number.positive不是正数number.min/number.max数值越界array.min/array.max数组长度越界object.required必填字段缺失object.strict出现了 schema 之外的字段union.match联合类型没有任何一个分支匹配成功custom.failed自定义校验器失败错误码的命名规则是类型.规则名非常便于记忆。在 i18n 场景下message 完全可以按环境切换code 保持稳定这就给前端留出了非常舒服的扩展面。4. 零依赖、小体积一个校验库的自我修养impeccable 立志做一个能用一辈子不用换的小工具。这个章节聊聊它的运行哲学。4.1 为什么我坚持零运行时依赖细心的读者可能注意到了impeccable 的所有校验逻辑都是纯 TypeScript 函数没有任何运行时第三方包。原因有两个。第一减少供应链风险。一个校验库如果自身依赖了多个 npm 包那它本身的依赖安全、更新频率、兼容性都会变成你项目的潜在负担。零依赖则意味着只要 TypeScript 编译器本身能跑 impeccable 就能跑。第二保证体积可控。校验库这种基础设施体积每大 1KB乘以所有使用场景都是一笔不小开销。零依赖 模块化导出让 tree-shaking 可以发挥到极致。实际编译产物里你只用s.string()和s.object()时浏览器端 bundle 增量只有 1KB 级别。4.2 体积和性能的实测数据我在一个中型项目里做了对照组实验数据就当成参考。方案gzip 后体积增量校验 1000 条简单记录耗时参考手动 if/else0KB代码本来就散在业务里约 0.3msimpeccable仅 string number约 1.8KB约 0.6msimpeccable完整 object array 嵌套约 3.2KB约 1.1ms某个主流大而全的库约 12KB约 1.5ms ~ 3ms手动 if 性能确实最快但那是把代码成本摊到业务代码里的结果没有可比性。impeccable 的体积和性能在同类库里处于很健康的位置普通接口校验、表单校验完全不用担心。提示大而全的库同样是很优秀的选择只是定位不同。impeccable 的目标是够用且纯粹如果你需要大量 Joi 风格的复杂语法或插件体系可能还是会倾向传统方案。4.3 和主流校验库的定位差异对比很多读者会拿 impeccable 跟 Z 字头、J 字头的流行库对比。我整理了一张定位差异表维度impeccable主流大而全校验库错误信息可读性默认中文或任意自定义code/path/message 分离默认英文需自行翻译映射运行时依赖零部分存在比如公共依赖类型推导一致性严格对齐运行时部分场景有偏差体积2~4KB gzip10KB gzipAPI 复杂度克制常用函数可全覆盖丰富但学习成本高适用场景中小型项目、追求可读性、极简依赖大型企业项目、复杂规则系统我不打算对主流库下定论每个工具都有自己的生态位。只是从我个人经验看80% 的校验场景其实用不到那些重型特性。当你真正需要的只是定义结构、校验数据、给出可读的错误这三件事时impeccable 的极简路线会显得格外舒服。5. 三种真实场景的集成姿势带完整代码理论讲再多不落地都是空谈。这里分享我在实际项目里用 impeccable 的三种典型姿势可以直接抄作业。5.1 Express 中间件把所有入参校验集中在一处后端接口从req.body拿数据时如果什么防御都不做脏数据就会一路传染到数据库层。我在 Express 里做了一个极简的校验中间件import { s } from impeccable function validate(schema) { return (req, res, next) { const result schema.safeParse(req.body) if (!result.ok) { return res.status(400).json({ code: VALIDATION_ERROR, errors: result.errors, }) } req.body result.data // 经过类型校验后的数据覆盖原始数据 next() } } // 路由中使用 app.post( /api/users, validate( s.object({ name: s.string().min(2).max(30), email: s.string().email(), }) ), (req, res) { // 此时 req.body 已经是类型安全的 res.json({ ok: true }) } )这个设计核心在于req.body result.data这一步。经过校验的数据自带类型信任后面业务函数里不会再出现这个字段到底是不是 string的疑虑。5.2 React 表单在输入时即时反馈而非提交后爆炸前端表单最烦的是用户在填了一长串之后提交才一次性报错。用 impeccable 做受控表单的字段级校验非常顺import { useState } from react import { s } from impeccable const ProfileSchema s.object({ username: s.string().min(3).max(20), email: s.string().email(), }) function ProfileForm() { const [form, setForm] useState({ username: , email: }) const [errors, setErrors] useState({}) const validateField (field, value) { const result ProfileSchema.safeParse({ ...form, [field]: value, }) if (!result.ok) { const fieldError result.errors .filter((err) err.path.startsWith(field)) .map((err) err.message) setErrors((pre) ({ ...pre, [field]: fieldError })) } } return ( div input value{form.username} onChange{(e) { const v e.target.value setForm({ ...form, username: v }) validateField(username, v) }} / {errors.username ( div style{{ color: red }}{errors.username.join(, )}/div )} /div ) }这里利用 path 的前缀匹配可以轻松定位到具体字段。在真实项目里你还可以把validateField包装成一个通用 hook让所有表单复用同一套逻辑。5.3 服务间数据传输在 API 边界做防御微服务架构、第三方回调、Webhook 接收……这些都是数据边界。边界数据是不可信的不该把信任传递给内部核心模块。import { s } from impeccable const WebhookEventSchema s.object({ event: s.enum([created, updated, deleted]), objectId: s.string(), timestamp: s.number().int().positive(), data: s.object({}).passthrough().optional(), }) export function handleWebhook(payload) { const result WebhookEventSchema.safeParse(payload) if (!result.ok) { // 记录详细的错误内容方便排查调用方的问题 logger.error(webhook validation failed, result.errors) throw new BadRequestError(EVENT_INVALID) } const { event, objectId } result.data // 之后业务逻辑只使用 result.data不再直接触碰原始 payload processEvent(event, objectId) }很多线上事故的本质就是信任了不该信任的数据。在 API 边界做一道 schema 校验成本极低收益极高——你不再需要在一堆业务代码里不停地if (xxx typeof xxx ...)。6. 踩坑记录校验库作者才会遇到的七个边界陷阱自己写校验库的过程中我踩过不少坑。这些坑在主流库里其实也存在只是普通使用者未必会注意到。这里挑几个典型的分享。6.1 假值陷阱0、false、空字符串到底算不算有值所有写校验库的人第一个绕不开的坎就是假值。if (!value)会把0、false、全部当成没有值。但实际业务里这三个值都可能是合法输入。age为0刚出生的婴儿、isVip为false、nickname为空字符串这些都可能是正常数据。impeccable 内部判断字段是否存在时用的是value undefined或value null而假值判断则交给具体的类型校验器。所以s.number().min(0)可以正确接收0s.boolean()可以正确接收false。6.2 NaN 该不该是一个合法的 numberJS 里typeof NaN number这个事让无数校验库翻过车。如果只看typeofNaN 会被当成数字放行。impeccable 的s.number()明确拒绝 NaN因为 NaN 在绝大多数业务场景里是脏数据宁可报错也不放行。实现上就是typeof value number !Number.isNaN(value)一行代码的事但很多人会漏掉。6.3 optional 与 undefined两个概念要分清早期的 schema 设计里optional和nullable经常被混为一谈。我见过有库把.optional()推导成T | null | undefined这其实是把两个不同语义塞进了一个概念里。impeccable 的定义是optional字段可以缺失缺失时值是undefinednullable字段必须存在但可以是nulloptional().nullable()字段既可以缺失也可以是null。这三种语义分开之后类型推导才能做到精确。如果你需要null参与业务逻辑比如后端显式传了null表示清除某字段千万不要只写optional否则null会被拒绝。6.4 TypeScript 泛型推导的兼容性问题在 TS 4.x 早期版本里递归类型和条件类型做深层次推导时偶尔会出现瞬间卡死的情况。写校验库的InferType逻辑时我发现如果直接用递归 索引访问类型在 schema 很深的时候 TS 类型计算开销会明显上升。解决办法有两个一个是内部限制递归深度另一个是给InferType增加缓存点。实现细节我不展开但想提醒所有使用通用校验库的读者如果你发现编辑器变卡先看看是不是某个深层嵌套的 schema 导致类型计算量过大再考虑拆分类型。6.5 数组下标的路径表达与转义路径里如果字段名本身带有特殊字符比如用户自定义属性a.b路径表达就会产生歧义。impeccable 对这种情况做了转义处理把字段名里的.转成\.这样解析器可以区分路径分隔符和字段名的一部分。这种边角问题平时遇不到但一旦遇到就是 bug值得在设计阶段就规划好。6.6 自定义校验器的状态保持自定义校验器有时候需要依赖外部状态比如当前用户名是否已被占用。这类校验器在执行时不能保证幂等会让 schema 失去可复现性。我的建议是将外部状态注入独立处理不要写进 schema 里。schema 只负责纯逻辑校验涉及数据库、缓存、远程调用的校验放到业务层单独处理。6.7 不要拿安全兜底代替业务校验最后一个提醒校验库是用来提高代码质量的不是用来做安全防线的。恶意攻击者完全可以绕过前端、绕过接口层直接构造请求。Schema 语法和业务规则分离安全相关的能力权限、越权、敏感数据仍然必须由专门的机制保障。校验库做得再好也只是数据质量这一层的工具。7. 踩坑之后的优化思路与未来方向写 impeccable 的实际收获其实不只是多了一个校验库而是对数据校验这件事建立了一套自己的方法论。我现在的习惯是所有对外接口、所有数据边界都先定义 schema 再写业务逻辑。哪怕只是个小接口花 30 秒写一个 schema带来的类型安全和错误可读性能省下后面不知道多少小时的排查时间。这个过程跟测试驱动开发有点神似——先定义什么数据是合法的再让业务逻辑在这个约束下运行。下一步我打算给 impeccable 加一个parse变体在校验失败时直接抛出包含 errors 的异常配合 RPC 框架的错误拦截器使用会更顺手。同时还想做更细粒度的错误机制比如自定义校验器可以返回多个错误而不是只能失败一次。这些都是很小但很实在的体验改进。如果你也在写自己的校验工具或者准备把一个项目里散落的if/else校验统一起来我建议你先花点时间想清楚三件事错误信息给谁看类型系统怎么和运行时保持一致这个库将来要服务哪些场景这三个问题想清楚了剩下的都是写代码的事。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑