
最近我在重构一个订单模块最头疼的不是业务逻辑本身而是订单状态这类数据在系统里到处漂移前端存字符串接口传字符串数据库还是字符串谁都能改改了没人知道。为了把这条链路理顺我把 Jev 的 TypeSafe System One 模型引入到了项目里。这套以类型安全为核心的设计思路让我把类型定义、运行期校验和数据转换收敛到了同一份模型上整个模块的防御式校验代码几乎删了个干净。Jev 不算社区里的大热门但它的设计思路对我启发挺大。这篇文章不打算讲什么高深理论就从一个实际项目怎么上手、怎么用、踩过哪些坑的角度把 TypeSafe System One 这套模型掰开揉碎。刚开始接触类型安全的同学可以当入门指南已经在用类似方案的朋友也可以看看我在边界处理上的一些取舍。1. Jev 到底在解决什么问题类型安全的真实成本与收益1.1 没有类型约束的数据流长什么样先看一个很常见的场景。订单模块里有个状态字段前端从下拉框拿到的是字符串pending接口返回的也是字符串数据库里存的还是字符串。这个值可以出现在任何地方但没有任何一处能保证它的取值一定是合法的那几个。// 没有类型约束的典型写法 interface Order { id: string; status: string; // 谁都能传 PENDING、pending 、127.0.0.1 total: number; } function canShip(order: Order) { // 这个判断在编译期不会帮你发现任何拼写问题 return order.status padid; // 少打了个 a运行时才发现 }我在老代码里最常看到的不是这种一眼假的拼写错误而是同一份数据在多个模块里被反复加工A 模块说status是paidB 模块非要把已支付塞回同一个字段C 模块又开始用数字1表示已支付。类型定义又散落在各个文件里改一个字段名要全局搜索替换漏掉一个就爆一个线上事故。TypeSafe System One 要解决的就是这种数据形状失控的问题。它的思路不是让你多写几个if判断而是把一个字段可以取哪些值、结构长什么样变成一套全系统通用的、机器可检查的约束在数据进入业务逻辑之前就把它拦住。1.2 TypeSafe System One 的核心承诺一份定义两处生效传统做法里一份数据通常有三份描述TypeScript 的interface、手动写的校验函数、以及给后端同学看的接口文档。三份内容经常不一致尤其是字段加加减减的时候总有一两处被忘记同步。Jev 的 TypeSafe System One 模型把这三件事合并成了一件事只写一份 schema 定义编译期自动得到 TypeScript 类型运行期自动得到校验器和转换器。这就是System One的含义——系统里只有一个数据模型来源其他所有地方都从它派生。import { defineType, JevType } from jev; export const Order defineType(Order, { id: { kind: string }, status: { kind: string, enum: [pending, paid, shipped, cancelled] }, total: { kind: number, min: 0 }, }); // 编译期类型从 schema 自动推导 export type Order JevTypetypeof Order; // 等价于 { id: string; status: pending | paid | shipped | cancelled; total: number }这里的关键在于defineType返回的对象同时具备两种身份在类型空间里JevTypetypeof Order把配置对象推导成精确的 TypeScript 类型在值空间里同一个对象带有parse、safeParse、is等运行期方法。你不需要再为了一个字段同时维护类型和校验规则因为两者是同一次定义的两个侧面。2. TypeSafe System One 模型的三层结构声明、校验、转换2.1 声明层用 defineType 描述数据形状声明层解决的是数据长什么样的问题。Jev 描述字段的方式不是用 TypeScript 的 type 语法而是用一份可被运行期执行的配置对象。原因很简单TypeScript 的类型在编译之后就不存在了运行期校验器拿不到任何类型信息所以必须先有一份可以被 JavaScript 读取的描述。一个订单实体的完整声明可以参考下面这样export const OrderItem defineType(OrderItem, { sku: { kind: string }, qty: { kind: int, min: 1 }, price: { kind: number, min: 0 }, }); export const Order defineType(Order, { id: { kind: string }, status: { kind: string, enum: [pending, paid, shipped, cancelled] }, items: { kind: array, element: OrderItem }, total: { kind: number, min: 0 }, createdAt: { kind: datetime }, });字段声明里有几个值得注意的点。kind决定标量类型enum做枚举约束min/max做数值边界element描述数组元素。datetime类型会在运行期把合法的 ISO 字符串解析成Date对象也会在序列化时转回字符串这个行为是声明层就确定好的。选择配置对象而不是链式 API最大的好处是结构一目了然能像看 JSON 一样看出一棵数据树的形状也方便后续做代码生成和模型对比。团队里新同学接手的时候打开一个 schema 文件就能知道整个模块的数据边界。2.2 校验层运行时更严格的安检声明层画好了图纸校验层负责在数据进入系统时执行检查。Jev 的校验入口主要有三个parse是拿到合法数据非法时直接抛异常safeParse返回一个带 success 标志的结果对象适合需要优雅处理失败的场景is是纯粹的布尔守卫适合在分支里做窄化。const raw { id: SO-1024, status: paid, items: [{ sku: KB-01, qty: 2, price: 299 }], total: 598, createdAt: 2024-06-01T10:00:00Z, }; const result Order.safeParse(raw); if (result.success) { // 这里 result.data 已经是精确推导过的 Order 类型 const order: Order result.data; } else { // 校验失败errors 里是所有字段错误的聚合 console.log(result.errors); }safeParse返回的是一个可辨识联合成功时是{ success: true, data }失败时是{ success: false, errors }。TypeScript 能根据success字段自动窄化类型不需要手动断言。这一点我很看重因为很多校验库的失败分支只丢一个字符串你还要自己 parse 错误信息Jev 直接给出了结构化错误列表。2.3 转换层数据从外部到内部的搬运工校验通过的合法数据很多时候还不能直接拿给业务组件用。外部 API 返回的字段名是created_at内部代码习惯用createdAt数据库里的枚举值可能是数字业务层希望看到语义字符串某些字段需要从外部格式转成内部对象。这些都属于转换层的工作。const statusMap { pending: 待支付, paid: 已支付, shipped: 已发货, cancelled: 已取消, } as const; const OrderView Order.transform((order) ({ ...order, createdAt: order.createdAt.toISOString(), statusText: statusMap[order.status], })); const view OrderView.parse(raw); // view.statusText 已支付transform接收一个纯函数输入是校验通过的合法数据输出是你需要的任何形状。关键在于它仍然保持类型安全输入类型由前面的 schema 决定输出类型由函数返回值自动推导。这样转换层不会被 poke 成不可控的any每个字段在跨边界时都经过了类型检查。3. 上手准备与第一个类型模块3.1 安装与编译环境Jev 的使用环境要求不算苛刻Node.js 18 和 TypeScript 4.7 基本覆盖了大部分项目。安装就是一个 npm 命令的事npm install jev # 或者用 pnpm pnpm add jev我强烈建议把 tsconfig 里的strict打开。类型安全体系的收益完全建立在严格模式之上如果strictNullChecks是关的JevType推导出来的类型可能会带进null/undefined的隐患等于自己拆掉了护栏。另外打开noUncheckedIndexedAccess也会让数组访问更小心这对运行期校验的结果使用很有帮助。项目结构上我习惯把所有 schema 集中到一个schemas/目录按业务子域分文件。比如订单模块就是schemas/order.ts、schemas/orderItem.ts领域模型之间互相引用时直接 import 对方的 schema。这样做的目的是让数据长什么样成为项目里的一等公民而不是散落在业务代码里。3.2 从订单实体开始写第一个 schema第一个 schema 建议从一个足够简单但有代表性的实体开始。订单就很合适有基础类型、有枚举、有嵌套对象、有数组覆盖了大部分常见场景。// schemas/order.ts import { defineType, JevType } from jev; export const OrderStatus defineType(OrderStatus, { kind: union, values: [pending, paid, shipped, cancelled], }); export const OrderItem defineType(OrderItem, { sku: { kind: string }, qty: { kind: int, min: 1 }, price: { kind: number, min: 0 }, }); export const Order defineType(Order, { id: { kind: string }, status: OrderStatus, items: { kind: array, element: OrderItem }, total: { kind: number, min: 0 }, createdAt: { kind: datetime }, }); export type Order JevTypetypeof Order;注意我把OrderStatus单独定义成了一个 union schema。这样状态的定义是独立的后续状态机可以直接复用也避免在多个 schema 里重复写枚举列表。JevType会把这个 union 推导成pending | paid | shipped | cancelled的字面量联合类型。3.3 一次定义三种使用方式同一个 schema在实际代码里通常有三种不同的使用方式我总结一下它们的分工类型层面type Order JevTypetypeof Order用于函数参数、变量声明、类成员标注。这是编译期静态检查的基础。校验层面Order.parse(raw)、Order.safeParse(raw)、Order.is(raw)用于在边界拦截脏数据。这是运行期的关卡。转换层面Order.transform(fn)用于在合法数据上做形状变换。这是连接外部数据和内部领域模型的桥梁。用个生活化的比喻schema 是模具编译期类型是图纸上的标注运行期校验是安检员transform 是翻译官。模具一次成型标注、安检、翻译都从同一个模具派生不会出现图纸和实物不一致的问题。4. 实战用 Jev 重构订单状态机4.1 旧代码里最常见的状态问题订单状态是典型的状态机场景pending可以支付变成paidpaid可以发货变成shipped也可以退款变成refunded。老代码里最常见的写法是手动维护一个类型和一个数组// 旧代码类型和数组各写一份 type OrderState pending | paid | shipped | cancelled | refunded; const ORDER_STATES: string[] [pending, paid, shipped, cancelled, refunded]; function isOrderState(value: string): value is OrderState { return ORDER_STATES.includes(value); }这段代码有几个隐患。ORDER_STATES是string[]而不是OrderState[]所以类型系统根本不会检查数组内容和OrderState是否一致。哪天有人删掉了refunded状态数组里忘了删编译期毫无反应运行期却会放过一个幽灵状态。这就是典型的状态定义与状态校验脱节。4.2 用联合类型定义订单状态用 Jev 重写之后状态定义和校验规则合二为一// schemas/orderState.ts import { defineType, JevType } from jev; export const OrderState defineType(OrderState, { kind: union, values: [pending, paid, shipped, cancelled, refunded], }); export type OrderState JevTypetypeof OrderState; export const OrderEvent defineType(OrderEvent, { kind: union, values: [pay, ship, cancel, refund], }); export type OrderEvent JevTypetypeof OrderEvent;以 union 声明之后OrderState在运行期就是一个校验器在编译期就是一个联合类型。外部接口传过来的字符串要变成合法的状态直接OrderState.parse(raw)即可。如果raw是refunded就能通过如果传进来的是refudned一个错别字也会被校验层按在门口。状态转换的逻辑则放在一个纯函数里用Record定义每个状态允许的事件const transitions: RecordOrderState, OrderEvent[] { pending: [pay, cancel], paid: [ship, refund], shipped: [refund], cancelled: [], refunded: [], }; function canTransition(from: OrderState, event: OrderEvent) { return transitions[from].includes(event); }这一步的收益是状态和事件本身自带运行期校验外部数据不可能污染状态机内部流转内部又靠OrderState联合类型保证状态值的合法性。4.3 穷尽性检查是如何逼出遗漏分支的状态机的核心函数是一个switch。Jev 的联合类型推导配合 TypeScript 的never技巧可以实现漏分支就编译不过的效果function nextState(state: OrderState, event: OrderEvent): OrderState { switch (event) { case pay: if (state pending) return paid; break; case ship: if (state paid) return shipped; break; case cancel: if (state pending) return cancelled; break; case refund: if (state paid || state shipped) return refunded; break; } // 如果所有合法分支都已覆盖代码永远到不了这里 return assertNever(event); } function assertNever(value: never): never { throw new Error(不合法的状态事件: ${value}); }关键在于nextState的返回类型是OrderState如果以后有人给OrderEvent增加了一个reopen事件而nextState没有新增对应的case最终会落到assertNever(event)TypeScript 会在这里报一个类型错误string不能赋值给never。你连分支遗漏都用肉眼看到编译器直接拦下来了。这套写法的价值在于状态枚举被改动时所有处理该状态的分支都会在编译期被点名而不是等到线上出现未知状态的报错。我用下来之后的感受是类型系统在这种场景下最能体现投入产出比。5. 边界数据处理的完整链路API、表单与存储5.1 safeParse 错误聚合像表单校验器一样工作实际项目里数据边界不只是外部 API还有前端表单提交、消息队列消费、定时任务读取配置。这些场景下一次输入往往有多个字段同时非法如果校验器只抛出第一个错误你就要反复提交才能凑齐所有的错误提示。Jev 的safeParse会把一次校验里的所有问题聚合在一起返回错误列表长这样{ success: false, errors: [ { path: [items, 1, qty], message: qty 必须大于等于 1 }, { path: [total], message: total 必须大于等于 0 }, { path: [status], message: status 必须是 pending/paid/shipped/cancelled 之一 }, ], }path数组直接指向嵌套字段的位置这比第 3 行字段错误这种文案好用太多。前端拿到这份错误列表后可以直接扁平化成表单字段到错误消息的映射后端拿到的则是结构化的校验报告可以转成统一的接口错误体。5.2 宽容模式与严格模式的取舍很多校验工具面对多出来的未知字段只有一种策略Jev 则允许你按数据来源选择三种模式。默认是strip即删掉未声明的字段passthrough会保留这些字段原样传下去strict则把它们当作错误处理。模式未知字段处理典型应用场景strip删除未声明字段外部 API 响应前端只关心自己需要的字段passthrough保留未知字段与老系统接口兼容两边字段版本不一致时兜底strict直接校验失败配置文件、数据库记录任何多出来的字段都需要警惕const StrictOrder Order.options({ unknown: strict }); const CompatOrder Order.options({ unknown: passthrough });我对这三种模式的取舍是读外部 API 的时候用strip因为外部系统未来加字段是常态直接报错会让集成变得僵化读自己数据库的记录用strict因为数据库结构是我们自己控制的多出来的字段说明代码和表结构不同步应该报警而不是默默忽略过渡期和老系统联调用passthrough但要在代码里留下 TODO等老系统下线后立刻切回strip。5.3 递归类型树形菜单怎么写递归结构是类型安全体系里比较难啃的一环最常见的是树形菜单、评论楼中楼、组织架构这类数据。Jev 对递归类型提供了lazy支持可以这么写export const MenuNode defineType(MenuNode, { id: { kind: string }, label: { kind: string }, children: { kind: array, element: { kind: lazy, get: () MenuNode }, }, });lazy的get回调晚到运行期才执行避免了定义阶段的自引用死循环。这个模式在用的时候有一个不起眼但很实用的点递归深度不要放开限制否则恶意构造一个上万层的嵌套对象可能把递归校验栈搞爆。我在项目里会额外做一个深度检查超过 50 层就当成非法数据处理算是一个简单的防御措施。6. 我在几个项目里踩过的 Jev 的坑6.1 泛型函数把校验器推断成了 any写通用工具函数的时候我踩过一个很隐蔽的坑泛型参数没有在 schema 上正确挂钩导致返回类型被推断成了unknown甚至any。// 错误示范T 没有来源实际返回结果是 unknown function loadT(schema: JevSchema, raw: unknown): T { return schema.parse(raw); } // 正确示范schema 的泛型参数携带类型信息 function loadconst S extends JevSchema(schema: S, raw: unknown): JevTypeS { return schema.parse(raw); } const order load(Order, raw); // order 被推导成 Order第一种写法里T在函数体里没有任何可以推断的来源调用时如果不显式传入TypeScript 就会退化成unknown。第二种写法用JevTypeS从 schema 的类型参数反推出结果类型整个过程无需手动标注也守住了类型安全。6.2 可辨识联合需要显式声明 discriminator处理类似{ type: text, content: string }和{ type: image, url: string }这种可辨识联合时我一开始没用discriminator校验结果虽然也对但报错信息不够精确性能也差一截。const Message defineType(Message, { kind: union, discriminator: type, // 告诉 Jev 用哪个字段区分联合成员 variants: { text: { content: { kind: string } }, image: { url: { kind: string } }, }, });声明了discriminator之后Jev 会直接读取type字段的值快速定位到对应的 variant不需要把所有成员挨个试一遍。这个例子说明 TypeSafe System One 并不只是有类型就行它还要求你按照真实的数据形态去建模。可辨识联合就该显式说出辨识字段这既提高了校验效率也让 schema 的语义更清晰。6.3 运行期校验不是越多越好这是我最想提醒的一点。刚上手 Jev 的时候我一度给项目里每个内部函数都加上了schema.parse觉得这样最保险。结果在一次压测里发现大部分 CPU 耗在了同一份数据被反复校验上接口层校验一次Service 层校验一次Repository 层又校验一次每次都要遍历整个订单对象GC 压力一下子大了起来。之后我调整了策略只在数据进入系统的地方做运行期校验系统内部不再重复 parse完全依赖编译期类型保证。具体来说我在代码里定了一个规矩代码位置是否做运行期校验原因API 入口 / controller必须校验外部输入完全不可信数据库读写层必须校验老数据和表结构可能有偏差消息队列消费必须校验生产者可能是另一个系统内部 Service 函数不校验类型已经由编译期保证重复校验是浪费这个策略跑了大半年既没出现数据形状错误性能也稳得住。类型安全的钱要花在边界上而不是花在内部管道里。7. 什么时候可以不上 Jev类型安全体系的使用边界7.1 原型阶段我通常不引入如果你在做一个两天就要见用户的原型字段天天变这时候上完整的类型安全体系反而是负担。每改一个字段就要重新设计 schema、重新推导类型、同步改造业务代码效率确实不如直接写 TypeScript interface 来得快。我的经验是原型阶段用原生类型外加少量手写守卫先把流程跑通等数据形态基本稳定了再一次性迁移到 Jev。类型安全体系适合的是会长期维护、有明确边界、字段形态相对稳定的权益系统比如订单、用户、权限这些核心领域模型而不适合实验性的、随时推倒重来的临时逻辑。7.2 渐进式落地先从边界开始就算决定引入也没必要把整个项目一次性翻新。Jev 支持 schema 之间的自由组合你完全可以先挑一个模块试点比如把对外 API 的请求和响应先迁移到 Jev其他模块保持原样。等团队把parse/safeParse/transform这几个核心动词用熟了再逐步覆盖数据库读写和领域模型。试点时我建议定两条约定schema 文件集中放置命名和业务子域对齐任何跨模块的数据结构变更先改 schema再改业务代码让 schema 成为评审和讨论的对象。这比在代码 review 里争论这个字段该叫 status 还是 state要高效得多因为 schema 本身就是唯一的权威定义。最后说一个我个人使用上的体会。刚开始用 Jev 的时候我几乎给所有内部函数都加了一道 parse觉得这样最安全。直到有一次压测发现大量耗时都花在重复校验同一个对象上。后来我把策略调整为只在系统边界和状态机入口做运行期校验内部数据流完全靠编译期类型保证。这个组合用了大半年既没出过数据形状错误性能也稳住了。如果你正打算引入 TypeSafe System One 这套思路我的建议是先拿一个订单、配置项这类数据形态明确的模块试试把 parse、safeParse、transform 这几个核心动词用顺再去碰递归结构和高频路径的优化。类型安全的收益不是一朝一夕能看出来的但半年后回头看你会庆幸自己在数据最容易乱的地方提前设了一道闸。