Zod 跨端三方库仓颉适配全流程
本文记录 Zod 4.6.5 适配仓颉(Cangjie)的完整过程。适配目标是得到一个可以被仓颉项目直接依赖的运行时 schema 校验库,并在本地终端中通过真实仓颉程序验证基础类型、对象、数组、组合 schema、默认值、转换、错误路径和 JSON Schema 输出。
项目地址: AtomGit/nutpi_cj/zod
上游仓库: colinhacks/zod
开发工具: 华为云码道
一、背景
1.1 为什么适配 Zod
Zod 是 JavaScript 和 TypeScript 生态中使用广泛的 schema 声明与运行时校验库。原版 Zod 通过链式 builder 描述字符串、数字、对象、数组、联合和转换,再由 parse 或 safeParse 对不可信输入进行验证。JavaScript 版本依赖动态值、异常对象和 TypeScript 的静态推导,而仓颉强调显式类型、集合类型和编译期接口检查。
直接把 TypeScript 源码逐行翻译成仓颉,无法保留 z.infer 这类编译器能力,也会把 JavaScript 的动态值边界隐藏起来。因此本项目重新设计了仓颉侧的 Value 数据模型和 Schema 运行时接口,保留 Zod 熟悉的 builder 风格,同时把缺失值、空值、错误路径和转换结果表达为仓颉可检查的类型。
1.2 跨端三方库的适配边界
当前项目提供的是纯仓颉跨端库,不绑定某一个 UI 框架、网络框架或操作系统。适配边界可以概括为:
| 边界 | 仓颉实现 |
|---|---|
| 包管理 | cjpm.toml、cjpm build 和静态库输出 |
| 输入模型 | Value 枚举表示 Missing、Null、Boolean、Number、Text、Items 和 Fields |
| schema | Schema 基类及字符串、数字、对象、数组和组合 builder |
| 错误模型 | ParseResult、Issue、ZodError,保留嵌套字段和数组索引路径 |
| 转换能力 | optional、nullable、defaultValue、prefault、catchValue、refine、transform、pipe 和 coerce |
| 输出能力 | 解析后的 Value、错误详情和 Draft 2020-12 JSON Schema 片段 |
| 交付形态 | 仓颉静态库 libzod_cangjie.a 和接口文件 zod_cangjie.cjo |
| 验证方式 | test/main.cj 独立回归程序和 example/example.cj 终端示例 |
这里没有 TypeScript 类型推导、JavaScript 对象兼容层、异步解析或 HAP/ArkTS 桥接。当前版本面向可以使用仓颉标准库的跨端项目;目标平台只要提供兼容的仓颉 SDK 和 cjpm,就可以按同一套源码和配置构建并消费这个库。
1.3 适配后的能力
当前导出的仓颉 API 覆盖以下能力:
- 基础 schema:
Z.string、Z.number、Z.boolean、Z.nullValue、Z.undefined、Z.unknown、Z.never、Z.literal和Z.enumValues; - 字符串和数值约束:长度、正则、前后缀、包含、大小写转换、email、URL、UUID、ISO 日期、数值范围、整数、正负数和倍数;
- 容器和组合:object、array、tuple、record、union、discriminated union、intersection 和 lazy 递归 schema;
- 处理流程:optional、nullable、nullish、defaultValue、prefault、catchValue、refine、transform、pipe、preprocess 和三种 coerce;
- 错误和输出:safeParse、parse、错误码、错误路径、联合分支错误、flatten 和 JSON Schema 生成。
二、实现路线图
text
第 1 阶段:盘点 API ── 对照 Zod schema 生命周期,确定仓颉值模型和适配边界
第 2 阶段:建立 cjpm 工程 ── 固定包名、版本、源码目录和静态库输出
第 3 阶段:类型映射 ── 用 enum、class、Array、HashMap 和显式结果替换动态值
第 4 阶段:实现校验 ── 完成 primitive、容器、组合、转换、错误和 JSON Schema
第 5 阶段:测试与核对 ── 编译独立测试入口,覆盖成功、失败、边界和不可变性
第 6 阶段:终端演示 ── 输出真实输入、解析结果、错误路径、JSON Schema 和效果图
每个阶段都有可检查的产物:工程阶段得到 cjpm.toml,实现阶段得到 .cjpm-target/zod_cangjie 中的静态库,验证阶段得到 Zod Cangjie: 282 regression checks passed,最终示例则输出 114 项来自真实 schema 调用的结果。
三、逐步实现过程
第 1 阶段:盘点原库 API 和仓颉工程边界
1.1 识别动态 API
Zod 的 JavaScript API 接受任意运行时值,schema builder 通过链式方法添加约束,解析器再返回深拷贝结果或错误对象。仓颉没有与 JavaScript unknown 完全等价的动态值容器,因此本适配先确定一个封闭但可递归的 Value 枚举:Missing 表示字段未提供,Null 表示显式空值,Boolean、Number、Text 表示基础值,Items 和 Fields 表示数组与对象。
所有 schema 都继承 Schema,解析器接收 Value 和 Array<PathPart>,返回 ParseResult。PathPart.Key 和 PathPart.Index 让错误能准确定位到 $["user"]["name"] 或 $[1]。这样动态输入仍然可以进入库,但每个边界都被仓颉类型显式表达。
1.2 固定仓颉项目结构
以下目录树按照当前 zod 项目的仓颉适配文件整理,只展示仓颉相关文件;原始 Zod 的 TypeScript 源码、测试和其他目录保持原位置:
text
zod/
├── cjpm.toml
├── cjpm.lock
├── README.Cangjie.md
├── README.Cangjie_CN.md
├── src/
│ ├── value.cj
│ ├── schema.cj
│ └── builders.cj
├── test/
│ └── main.cj
└── example/
├── cjpm.toml
├── cjpm.lock
└── example.cj
源码按值模型、基础 schema 和 builder 组织。测试入口位于 test/main.cj,示例工程位于 example/,两者都独立于库源码。示例的 src-dir 是当前目录,但 target-dir 放在仓库外的 ../../.cjpm-target/zod_example,因此构建输出不会被当作源码再次扫描。
第 2 阶段:创建 cjpm 工程
根目录 cjpm.toml 使用静态库输出类型,并将源码目录和构建目录固定下来:
toml
[package]
name = "zod_cangjie"
version = "0.1.0"
cjc-version = "1.1.3"
description = "Native Cangjie runtime schema validation inspired by Zod"
src-dir = "src"
target-dir = "../.cjpm-target/zod_cangjie"
output-type = "static"
示例工程使用本地路径依赖,并把构建目录放在 example 外部:
toml
[package]
name = "zod_example"
version = "0.1.0"
cjc-version = "1.1.3"
src-dir = "."
target-dir = "../../.cjpm-target/zod_example"
output-type = "executable"
[dependencies]
zod_cangjie = { path = ".." }
根包构建成功后,重要的库产物是:
text
../.cjpm-target/zod_cangjie/release/zod_cangjie/libzod_cangjie.a
../.cjpm-target/zod_cangjie/release/zod_cangjie/zod_cangjie.cjo
其中 .a 用于链接,.cjo 提供编译器需要的接口信息。example 的构建产物写入仓库外的 .cjpm-target/zod_example,验证完成后不提交这些产物。
第 3 阶段:把 JavaScript 动态值映射为仓颉类型
3.1 Value、Schema 和 ParseResult
仓颉调用方使用辅助函数构造输入,schema 返回统一的结果:
cangjie
let user = Z.object([
("name", Z.string().min(1)),
("age", Z.coerceNumber().integer().nonnegative()),
("role", Z.enumValues(["reader", "writer"]).defaultValue(text("reader")))
])
let result = user.safeParse(obj([("name", text("Ada")), ("age", text("36"))]))
if (result.success) {
println(display(result.value()))
}
parse 在失败时抛出 ZodError;safeParse 返回 ParseResult,成功时提供 data,失败时提供 issues。解析结果中的对象和数组会深拷贝,调用方修改返回值不会反向修改输入。
3.2 用 HashMap<String, Value> 表达对象
JavaScript 对象可以同时包含字符串、数字、数组和嵌套对象,仓颉适配使用 HashMap<String, Value> 表达字段:
cangjie
let input = obj([
("name", text("Ada")),
("tags", list([text("cangjie"), text("zod")]))
])
let schema = Z.object([
("name", Z.string()),
("tags", Z.string().array())
])
let output = schema.parse(input)
println(display(output))
object 默认移除未声明字段,strict() 会把它们报告为 unrecognized_keys,passthrough() 会保留它们,catchall() 则用指定 schema 验证它们。对象字段、数组下标和 record 键都会加入错误路径,便于终端输出直接说明失败位置。
3.3 用 Issue 和 ZodError 表示失败
每个 Issue 包含 code、message、path 和可选的 branches。联合类型失败时,branches 保留每个候选 schema 的错误;ZodError.flatten() 会把错误整理为 formErrors 和 fieldErrors:
cangjie
try {
schema.parse(obj([("name", text("")), ("tags", list([num(1)]))]))
} catch (error: ZodError) {
println(error.message)
println(display(error.flatten()))
}
这种错误模型对应 Zod 的使用习惯,同时避免依赖 JavaScript Error 的动态字段。内置 parser 产生结构化 issue,自定义 refine 可以提供业务消息,回调自身抛出的异常则继续向调用方传播。
第 4 阶段:实现源码
4.1 文件 src/value.cj
value.cj 定义 Value、PathPart、Issue、ParseResult 和 ZodError,并提供 display、deepClone、valuesEqual、get、asText、asNumber 等辅助函数。显示函数会对字符串控制字符进行转义,对数字保留足够精度,方便示例同时展示人类可读结果和真实值。
deepClone 递归复制 Items 和 Fields,默认值也会在每次解析时复制。对象字段的哈希顺序不作为行为契约,比较和测试通过结构相等而不是字符串顺序判断。
4.2 文件 src/schema.cj 和 src/builders.cj
schema.cj 提供基础 Schema、StringSchema、NumberSchema 和 ArraySchema,实现基本类型、字符串/数字约束、转换链、JSON Schema 元数据和数组元素错误路径。builders.cj 提供 object、tuple、record、union、discriminated union、intersection、lazy、literal、enum 以及三种 coerce。
builder 方法始终返回新 schema,原 schema 保持可复用。例如 let constrained = base.min(1) 不会改变 base;defaultValue 在缺失输入时直接返回默认值,prefault 则把默认输入继续送进校验和转换流程。递归结构通过 Z.lazy 延迟取得子 schema,避免初始化阶段的循环依赖。
4.3 JSON Schema 和格式边界
toJSONSchema() 为可表达的输入约束生成 Draft 2020-12 片段,覆盖基础类型、对象、数组、元组、record、枚举、联合、交叉、可选/可空字段、默认值和描述。重复约束使用 allOf 保留,strip 对象的额外字段策略输出为 additionalProperties。
任意 refine、transform、pipe、preprocess、coerce、递归 schema 和没有可靠元数据的自定义 schema 不会被静默转换为宽松 JSON Schema,而是明确抛出"cannot be represented faithfully"错误。这样生成结果不会让调用方误以为 JSON Schema 已经表达了运行时转换逻辑。
第 5 阶段:建立自动化测试
test/main.cj 是直接链接仓颉静态库的独立回归测试入口,覆盖以下行为:
| 测试组 | 示例 |
|---|---|
| 基础 schema | string、number、boolean、null、undefined、unknown、never、literal、enum |
| 格式与边界 | Unicode 长度、正则、email、URL、UUID、闰年日期、UTC datetime、有限数值和安全整数 |
| 容器 | object 字段策略、数组、tuple 可选尾项、tuple 默认尾项、record 和嵌套路径 |
| 组合 | union 分支错误、discriminated union、intersection 和 lazy 递归 |
| 处理流程 | optional、nullable、default、prefault、catch、refine、transform、pipe、preprocess、coerce |
| 契约 | builder 不可变、输入深拷贝、错误聚合、JSON Schema、非法参数和不支持转换 |
断言失败会抛出异常并以非零状态退出;全部通过时输出:
text
Zod Cangjie: 282 regression checks passed
示例工程则负责展示库调用,测试程序负责密集验证,两者都使用同一个 zod_cangjie 包,因此展示结果不是只打印断言通过。
第 6 阶段:核对 Zod 适配 API
除回归测试外,还对示例中的每一类公开入口进行实际调用核对:基础类型、字符串和数字约束、对象策略、容器、组合 schema、缺失/空值处理、转换链、错误输出和 JSON Schema。示例共完成 114 项演示,每一项都比较预期结果;失败案例则检查错误码并打印错误路径。
text
Completed 114 demonstrations with verified results.
这个检查说明当前文档覆盖的是仓颉适配已经实现的公开能力,不代表它覆盖 JavaScript Zod 的所有动态扩展、TypeScript 推导或异步能力。
四、完整验证步骤
以下命令均以仓库根目录 zod/ 为当前目录。先确认本机已经安装仓颉 SDK:
bash
echo "$CANGJIE_HOME"
cjc --version
cjpm --version
然后构建静态库:
bash
cjpm build
编译并运行独立测试入口:
bash
cjc test/main.cj \
--import-path ../.cjpm-target/zod_cangjie/release/zod_cangjie \
-L ../.cjpm-target/zod_cangjie/release/zod_cangjie \
-l zod_cangjie \
--set-runtime-rpath \
-o ../.cjpm-target/zod_tests
../.cjpm-target/zod_tests
--set-runtime-rpath 让 macOS 上的测试程序能够找到仓颉 SDK 运行库。最后构建并运行独立示例:
bash
cd example
cjpm run
示例会直接调用库并打印实际输入、解析后的输出、错误路径和 JSON Schema,不会把预先写死的文本当成验证结果。结尾输出:
text
Completed 114 demonstrations with verified results.
cjpm run finished
示例使用 target-dir = "../../.cjpm-target/zod_example",因此运行 cjpm run 时不会出现把构建输出目录当作源码扫描目录的警告。验证结束后确认 .cjpm-target 没有被 Git 跟踪。
五、运行效果
下面的效果图来自 example/example.cj 的实际终端运行,包含静态库构建后的 Zod Cangjie schema 输入、解析输出、错误路径、JSON Schema 和最终完成标记。

这张图可以作为文章或项目说明中的验收证据:终端同时展示了派生 schema 错误、深拷贝结果、ZodError、JSON Schema 和不可表示转换的明确错误。示例程序真实调用仓颉库并比较结果,因此效果图不是模拟页面或只显示断言通过。
六、关键决策说明
6.1 为什么使用显式 Value 和 Schema
仓颉适配的价值在于让调用方看到输入模型和解析结果的边界。若所有数据都退化成字符串或无结构容器,虽然表面上容易翻译 JavaScript,却会失去 null/missing 区分、嵌套错误路径和编译期 API 检查。因此项目使用 enum 表达值、class 表达 schema、HashMap 表达对象,并把错误拆成可读取的字段。
6.2 为什么保留 Zod builder 名称
保留 Z.object、Z.string、optional、nullable、defaultValue、safeParse 和 ZodError 等名称,可以降低熟悉 Zod 的开发者迁移成本。调用方需要适应的是仓颉的参数标签、Value 构造函数和显式异常,而不是重新学习完全不同的 schema 组织方式。无法表达的 TypeScript 推导和 JavaScript 特殊值,则通过 README 明确列出边界。
6.3 为什么把测试和示例分开
测试程序负责密集断言,适合自动化执行;示例工程负责展示,适合在终端中观察每个输入对应的真实输出和错误。两者都使用同一个 Zod 仓颉包,因此截图展示的是库的实际运行过程,测试则额外覆盖参数边界、深拷贝和 JSON Schema 失败情况。
七、兼容边界和遗留问题
JavaScript Zod 与仓颉的值模型和类型系统不同,以下限制需要在接入时提前了解:
- 当前版本没有 TypeScript
z.infer、品牌类型、TypeScript 泛型推导和原版 npm 导出兼容;解析结果使用仓颉Value。 - JavaScript 的 bigint、symbol、Date、Map、Set、Promise、函数、类实例、File 等特殊值没有对应的 Value 分支。
- 字符串长度按 Unicode 码点计算,不按 JavaScript UTF-16 code unit 计算;trim 和大小写转换采用 ASCII 语义;正则使用仓颉标准库引擎。
- 数值必须有限;
integer()限定在 JavaScript 安全整数范围;multipleOf对浮点商使用1e-12误差容忍,不是任意精度十进制运算。 url()支持受限的 ASCII HTTP/HTTPS 形式,不等同于 JavaScript URL 解析器;ISO datetime 要求 UTCZ和秒,offset/local 选项未实现。- 数值强制转换采用仓颉浮点解析,空字符串和 null 转为零;字符串
"false"的布尔强制结果是 true,因为转换采用真假值语义。 - 输入值必须无环;
Z.lazy支持递归 schema,但不支持循环引用的数据图。回调自身抛出的异常不会自动包装成校验 issue。 - async parse、codec、registry、多语言错误、AOT 编译、mini API 和其他未列入功能表的 Zod 辅助 API 尚未实现。
这些不是构建错误。只要 cjpm build 成功、测试程序输出回归通过标记、示例输出 114 项完成标记,就说明当前版本已经完成本地编译和已实现能力的行为验收。需要完整 JavaScript Zod 语义的项目,应继续使用原版 Zod;需要仓颉静态运行时校验的项目可以按当前边界接入。
八、接入方式
在另一个仓颉项目中,可以将本仓库作为本地依赖或发布包使用。最直接的本地验证方式是在调用方 cjpm.toml 中加入:
toml
[dependencies]
zod_cangjie = { path = "/path/to/zod" }
调用代码只需要导入:
cangjie
import zod_cangjie.*
main(): Int64 {
let schema = Z.object([
("name", Z.string().min(1)),
("age", Z.coerceNumber().integer().nonnegative())
])
let result = schema.safeParse(obj([
("name", text("Ada")),
("age", text("36"))
]))
if (result.success) {
println(display(result.value()))
} else {
for (issue in result.issues) { println(issue.toString()) }
}
return 0
}
如果使用 cjpm 管理依赖,则由业务工程统一构建;如果直接使用 cjc,需要把根包构建产物目录传给 --import-path 和 -L,并链接 -l zod_cangjie。跨端项目可以复用同一套 schema、错误处理和测试思路;具体的 UI、线程、网络和系统能力由宿主平台负责。
九、总结
本次适配完成了 Zod schema 模型到仓颉运行时模型的迁移:输入值使用 Value,schema 使用 Schema 及其 builder,字符串、数字、对象、数组和组合能力通过静态仓颉 API 暴露,缺失值和空值通过 optional/nullable 区分,错误通过 Issue/ZodError 表达,解析结果进行深拷贝,JSON Schema 对可表达约束进行明确转换。
验证链路包括 cjpm build、独立测试入口编译、282 项回归检查、114 项终端示例演示、错误路径输出、深拷贝验证和 JSON Schema 边界检查。最终效果图来自真实的仓颉可执行程序,能够直观看到 schema 校验成功值、失败原因、嵌套路径和生成结果。对于暂时无法一比一映射 TypeScript Zod 的类型推导、异步和 JavaScript 特殊值,项目通过静态接口和文档边界保持行为可解释,方便后续继续扩展。
适配过程 FAQ
以下问题来自仓颉工程创建、
cjpm依赖、与 HarmonyOS 设备联调的实际流程。
Q1:cjpm 依赖解析失败或拉取超时?
- 检查
cjpm.toml中的依赖坐标与版本号; - 配置可用的仓颉包仓库镜像或代理;
- 删除
target、cjpm.lock后重新执行cjpm update/cjpm build。
Q2:编译报找不到仓颉 SDK 或 cjc 版本不匹配?
- 设置
CANGJIE_HOME指向仓颉 SDK 根目录; - 确认
cjc --version、cjpm --version与工程要求的版本一致; - 清理构建缓存后重新生成。
Q3:与 HarmonyOS 设备联调时安装失败?
- 用
hdc list targets确认设备在线; - 确认 HAP 已签名,
bundleName与签名 profile 一致; - 设备需加入调试 profile 的
deviceIds。
Q4:动态库或 FFI 符号找不到?
- 检查导出符号名称、ABI 与打包路径;
- 确认
.so已随 HAP 一起打包到libs/<abi>/; - 用
llvm-readelf -d检查依赖库是否齐全。
Q5:测试用例在设备上失败,但本机通过?
- 对比设备与主机的架构、SDK 版本和环境变量;
- 用
hdc hilog抓取运行日志; - 优先复现最小用例,确认是否为平台差异或时序问题。