本文记录 io-ts 2.2.21 适配仓颉(Cangjie)的完整过程。适配目标是得到一个可以被仓颉项目直接依赖的运行时类型解码与编码库,并在本地终端中通过真实仓颉程序验证基础类型解码、结构体解码、嵌套对象、数组、可选字段、联合类型、编码、错误路径、运行时守卫和结构等值。
项目地址: AtomGit/nutpi_cj/io-ts
上游仓库: gcanti/io-ts.git
开发工具: 华为云码道
一、背景
1.1 为什么适配 io-ts
io-ts 是 TypeScript 生态中用于运行时类型检查、解码和编码的库。原版通过 Type、Decoder、Codec、Encoder 和 Guard 等抽象,把外部不可信数据转换为静态类型可以使用的值。JavaScript/TypeScript 版本依赖动态值、泛型推导和函数组合,而仓颉强调显式类型、代数数据类型和编译期接口检查。
直接把 TypeScript 源码逐行翻译成仓颉,无法保留 io-ts 的 TypeScript 推导能力,也会把动态输入边界隐藏起来。因此本项目重新设计了仓颉侧的 Value、DecodeResult<T> 和 DecodeError,保留解码、编码、守卫和等值比较的核心使用方式,让调用方能看到每一个运行时结果和错误路径。
1.2 跨端三方库的适配边界
当前项目提供的是纯仓颉跨端库,不绑定某一个 UI 框架、网络框架或操作系统。适配边界可以概括为:
| 边界 | 仓颉实现 |
|---|---|
| 包管理 | cjpm.toml、cjpm build、cjpm test 和静态库输出 |
| 输入模型 | Value 枚举表示 string、integer、boolean、null、array 和 object |
| 解码结果 | 泛型 DecodeResult<T>,用 Decoded 或 Failed 显式表达结果 |
| 结构解码 | decodeUser 组合必填字段、数组、嵌套 Address 和可选 email |
| 编码能力 | encodeUser 将 User 编码回 Value |
| 守卫与比较 | isString、isInt、isBool、isArray、isObject 和递归 valuesEqual |
| 错误模型 | DecodeError 保存 path、expected、actual 和可读 message |
| 交付形态 | 仓颉静态库及 .cjo 接口文件 |
| 验证方式 | src/main_test.cj 的仓颉单元测试和 example/example.cj 终端示例 |
这里没有 TypeScript 类型推导、JavaScript 任意对象兼容层、异步解码或 HAP/ArkTS 桥接。当前版本面向可以使用仓颉标准库和 cjpm 的跨端项目;目标平台只要提供兼容的仓颉 SDK,就可以按同一套源码和配置构建并消费这个库。
1.3 适配后的能力
当前导出的仓颉 API 共 21 个顶层公开名称,覆盖以下能力:
- 运行时值:
Value、DecodeError、DecodeResult<T>、Address和User; - 基础解码:
decodeString、decodeInt、decodeBool和decodeArray; - 组合解码:
decodeUser支持字段查找、嵌套对象、字符串数组和可选 email; - 编码和联合:
encodeUser、decodeStringOrInt; - 可观察结果:
actualType、pretty、错误 path 和userSummary; - 守卫与等值:五个
is*守卫和递归valuesEqual。
二、实现路线图
text
第 1 阶段:盘点 API ── 对照 io-ts 解码生命周期,确定仓颉值模型和适配边界
第 2 阶段:建立 cjpm 工程 ── 固定包名、版本、源码目录和静态库输出
第 3 阶段:类型映射 ── 用 enum、struct、ArrayList 和泛型结果替换动态值
第 4 阶段:实现解码 ── 完成基础、数组、嵌套对象、可选字段、编码和联合分支
第 5 阶段:测试与核对 ── 编译单元测试入口,覆盖成功、失败、错误路径和往返结果
第 6 阶段:终端演示 ── 输出真实 Value、解码结果、编码结果、守卫和等值比较
每个阶段都有可检查的产物:工程阶段得到 cjpm.toml,实现阶段得到 .cjpm-target/io-ts 中的静态库,验证阶段得到仓颉测试通过结果,最终示例则输出一组来自真实库调用的解码和编码结果。
三、逐步实现过程
第 1 阶段:盘点原库 API 和仓颉工程边界
1.1 识别动态 API
io-ts 的 TypeScript API 接受任意运行时输入,decoder 负责检查值是否满足类型,codec 同时提供 decode 和 encode,guard 则提供布尔型运行时判断。仓颉没有与 JavaScript unknown 完全等价的动态容器,因此本适配先定义一个封闭但可递归的 Value 枚举,再为每个公开解码器返回 DecodeResult<T>。
基础值由 VString、VInt、VBool 和 VNull 表示;容器由 ArrayValue 和 ObjectValue 表示。解码失败不依赖异常文本,而是返回 DecodeError,其中 path 可以定位到 $.age 或 $.tags[1]。这样动态输入仍然可以进入库,但每个边界都被仓颉类型显式表达。
1.2 固定仓颉项目结构
以下目录树按照当前 io-ts 项目的仓颉适配文件整理,只展示仓颉相关文件;原始 io-ts 的 TypeScript 源码、测试、文档和其他目录保持原位置:
text
io-ts/
├── cjpm.toml
├── cjpm.lock
├── README.Cangjie.md
├── README.Cangjie_CN.md
├── src/
│ ├── io_ts.cj
│ └── main_test.cj
├── test/
│ └── main.cj
└── example/
├── cjpm.toml
├── cjpm.lock
└── example.cj
源码集中在 src/io_ts.cj,按运行时值、错误、基础 decoder、组合 decoder、encoder、guard 和 equality 的顺序组织。src/main_test.cj 接入仓颉标准测试框架,test/main.cj 保留可读的行为检查入口,示例工程位于 example/。示例使用 src-dir = ".",但 target-dir 放在 example/ 外部的 .cjpm-target/io-ts-example,构建输出不会被再次当作源码扫描。
第 2 阶段:创建 cjpm 工程
根目录 cjpm.toml 使用静态库输出类型,并将源码目录和构建目录固定下来:
toml
[package]
cjc-version = "1.1.3"
name = "io_ts"
organization = "io_ts"
description = "Runtime type decoding and encoding utilities for Cangjie"
version = "2.2.21"
target-dir = ".cjpm-target/io-ts"
src-dir = "src"
output-type = "static"
[dependencies]
示例工程使用本地路径依赖,并把构建目录放在 example 外部:
toml
[package]
cjc-version = "1.1.3"
name = "io_ts_example"
organization = "io_ts"
description = "Visible io-ts Cangjie feature demonstration"
version = "2.2.21"
src-dir = "."
target-dir = "../.cjpm-target/io-ts-example"
output-type = "executable"
[dependencies]
"io_ts::io_ts" = { path = ".." }
根模块的静态库和 .cjo 接口文件会写入 .cjpm-target/io-ts/release/io_ts@io_ts/。示例产物写入 .cjpm-target/io-ts-example,验证完成后清理这些目录,不提交构建产物。
第 3 阶段:把 JavaScript 动态值映射为仓颉类型
3.1 输入值和解码结果
仓颉侧用 Value 表达外部输入,用泛型 DecodeResult<T> 表达解码结果:
cangjie
let value = VObject(ArrayList<(String, Value)>([
("name", VString("Ada")),
("age", VInt(36)),
("active", VBool(true))
]))
match (decodeString(VString("hello"), "$.greeting")) {
case Decoded(text) => println(text)
case Failed(error) => println(error.message())
}
Decoded 携带目标类型的值,Failed 携带包含 path、expected 和 actual 的 DecodeError。调用方不需要依赖异常字符串来判断失败原因。
3.2 用 ArrayList 和字段数组表达容器
JavaScript 对象和数组可以任意嵌套,仓颉适配使用 ArrayList<(String, Value)> 保存对象字段,使用 ArrayList<Value> 保存数组元素:
cangjie
let tags = ArrayValue(ArrayList<Value>([
VString("compiler"), VString("types")
]))
let address = ObjectValue(ArrayList<(String, Value)>([
("city", VString("London")),
("country", VString("UK"))
]))
字段查找会把缺失字段报告为 $.field: expected field, got missing;数组 decoder 在每个元素上追加索引,例如第二个非字符串 tag 会报告 $.tags[1]。pretty 将同一套 Value 重新渲染为可读的 JSON 风格文本,方便终端验证。
3.3 用 DecodeError 表示失败
io-ts 的核心价值是把不可信输入转换为可检查的结果。仓颉版本保留这一使用习惯:
cangjie
match (decodeUser(value)) {
case Decoded(user) => println(userSummary(user))
case Failed(error) => println(error.message())
}
decodeUser 按顺序检查 name、age、active、tags 和 address,email 是可选字符串。示例中的错误输入会输出 $.age: expected integer, got string,而不是只打印一个"断言失败"。
第 4 阶段:实现源码
4.1 文件 src/io_ts.cj
源码按解码生命周期组织:先定义 Value、DecodeError 和 DecodeResult<T>,再实现基础 decoder 和 guard;随后由 decodeAddress、decodeTags 和 decodeUser 组合出结构解码;最后提供 encodeUser、decodeStringOrInt、valuesEqual 和 userSummary。
decodeUser 对字段逐个执行类型检查,任何失败都会保留最具体的路径。成功后返回类型化的 User,其中包含 Address、ArrayList<String> 和可选 email。encodeUser 重新生成 ObjectValue,示例通过 valuesEqual 比较原始输入和编码结果。
4.2 独立终端示例
example/example.cj 通过路径依赖导入 io_ts::io_ts.*,构造有效和无效的 Value,直接调用库的公开 API。示例同时打印基础解码、错误路径、结构解码、编码、联合分支、五个守卫和结构等值比较,输出来自库调用本身,不是预先写死的结果。
4.3 动态 JSON 和平台边界
当前版本不把文本 JSON 解析器伪装成 io-ts 功能。调用方需要先把外部数据映射为 Value,再调用 decoder;这让适配保持无额外依赖,也避免在没有标准 JSON API 的目标平台上产生不一致行为。字符串、整数、布尔值、null、数组和对象的递归模型已经覆盖当前示例需要的运行时类型场景。
第 5 阶段:建立自动化测试
src/main_test.cj 接入 std.unittest,覆盖以下行为:
| 测试组 | 示例 |
|---|---|
| 基础解码 | string、integer、boolean 和 array 的成功结果 |
| 结构解码 | User 的必填字段、嵌套 Address、tags 和可选 email |
| 编码 | User 编码为 Value,并与原始输入进行结构等值比较 |
| 错误 | 错误输入被拒绝,并保留 $.name 等具体路径 |
| 联合 | string 分支和 integer 分支返回统一的字符串结果 |
| 守卫与比较 | 五个运行时守卫和递归 valuesEqual |
test/main.cj 保留同样的行为检查并逐项打印结果;cjpm test 负责执行仓颉测试框架中的用例,示例工程负责把每个功能的真实值和错误信息展示在终端。两者共同覆盖编译期 API 和运行时解码行为。
第 6 阶段:核对 io-ts 适配 API
按照 src/io_ts.cj 中的顶层 public 声明核对文档列出的公开入口:
text
io-ts Cangjie public API: 21
Missing documented entries: 0
这 21 个入口包括 5 个公开类型和 16 个公开函数,覆盖 Value 建模、错误结果、基础 decoder、组合 decoder、encoder、union、guard、pretty 输出和结构等值。这个检查只说明当前仓颉适配的公开入口与本文一致,不代表它已经覆盖 TypeScript io-ts 的所有高阶类型构造和编译器推导语义。
四、完整验证步骤
以下命令均以仓库根目录 io-ts/ 为当前目录。先确认本机已经安装仓颉 SDK:
bash
which cjc
cjc --version
cjpm --version
先更新锁文件并构建静态库:
bash
cjpm update
cjpm build
执行仓颉单元测试:
bash
cjpm test
再构建并运行独立的终端示例工程:
bash
cd example
cjpm check
cjpm run
示例会调用真实的 io_ts::io_ts 包并打印基础 decoder、结构 decoder、错误路径、encoder、union、guard 和 equality 结果,而不是打印预先写死的文本。示例使用 target-dir = "../.cjpm-target/io-ts-example",因此运行 cjpm run 时不会出现把构建输出目录当作源码扫描目录的警告。验证结束后执行:
bash
cjpm clean
cd ..
cjpm clean
并确认 .cjpm-target 等构建目录未加入 Git。
五、运行效果
下面的效果图来自 example/example.cj 的实际终端运行,包含静态库构建后的 io-ts Cangjie 值解码、错误路径、编码结果、运行时守卫和结构等值比较。

这张图可以作为文章或项目说明中的验收证据:终端同时展示了 primitive decoder 的成功与失败、嵌套 User 的成功结果、$.age 错误路径、编码后的 Value、union 分支、五个 guard 和结构等值比较。示例程序真实调用仓颉库并打印返回值,因此效果图不是模拟页面或只显示断言通过。
示例的关键输出如下:
text
primitive string decoder => success: hello
primitive string error => failure: $.greeting: expected string, got integer
primitive integer decoder => success: 36
primitive boolean decoder => success: true
primitive array decoder => success: [1, 2]
struct + nested + array + optional decoder => success: Ada (36) @ London, tags=2, email=ada@example.test
detailed path error => failure: $.age: expected integer, got string
encoder output: {name: "Ada", age: 36, active: true, tags: ["compiler", "types"], address: {city: "London", country: "UK"}, email: "ada@example.test"}
encode/decode value equality: true
union rejected boolean => failure: $: expected string or integer, got boolean
guards: string=true, int=true, bool=true, array=true, object=true
structural equality (same): true
structural equality (different): false
六、关键决策说明
6.1 为什么使用显式 Value 和 DecodeResult
仓颉适配的价值在于让调用方在编译期知道输入、成功值和失败值的形状。若所有内容都退化成字符串,虽然更像终端日志,却会失去 User、Address、ArrayList<String> 和错误字段检查。因此项目优先使用 enum、struct、泛型结果和显式错误,让常见解码调用保持可读、可检查和可预测。
6.2 为什么保留 io-ts 的 decoder、encoder 和 guard 语义
保留 decode、encode、guard 和 equality 的名称与职责,可以降低熟悉 io-ts 的开发者迁移成本。调用方只需要适应仓颉的 Value 构造、显式 path 参数和 match 分支,不必重新理解一套完全不同的运行时类型流程。TypeScript 编译器推导无法在仓颉中一比一复制,因此适配把可运行的行为边界写成公开类型和文档。
6.3 为什么把测试和示例分开
测试程序负责可重复执行的行为检查,适合 cjpm test;示例工程负责展示,适合在终端中观察真实输入、成功值和错误详情。两者都使用同一个 io_ts::io_ts 包,因此效果图不是模拟数据,而是库实际运行输出的可视化结果。
七、兼容边界和遗留问题
TypeScript io-ts 与仓颉的运行时类型模型不同,以下限制需要在接入时提前了解:
- 当前输入需要由调用方构造为
Value,没有内置 JSON 文本解析器;网络、文件或系统 JSON API 的结果需要先映射到 Value。 - 当前组合 decoder 以
User和Address为完整示例,没有实现 TypeScript io-ts 全部的泛型 combinator、递归 codec、品牌类型和静态TypeOf推导。 DecodeResult目前在遇到第一个错误时返回,尚未实现一次解码收集多个字段错误的报告器。- 对象字段使用字符串键和值数组表达,当前没有独立的 HashMap 输入 API、未知字段策略或大小写不敏感字段查找。
User.email以可选字符串表示;当前适配没有把所有 JavaScriptundefined、symbol、function、Date、Map、Set 和 bigint 值映射成 Value 构造器。- 当前库不提供异步 decoder、流式 decoder、浏览器 adapter、网络请求或 UI 桥接;这些能力依赖目标平台的 API,应在平台层另行实现。
这些不是构建错误。只要 cjpm build 成功、cjpm test 通过、example/cjpm run 输出解码/编码结果,就说明当前版本已经完成本地编译和行为验收。
八、接入方式
在另一个仓颉项目中,可以将本仓库作为本地依赖使用。cjpm.toml 的依赖名需要与模块包名一致:
toml
[dependencies]
"io_ts::io_ts" = { path = "../io-ts" }
调用代码可以直接导入包并匹配解码结果:
cangjie
package demo::main
import std.collection.*
import io_ts::io_ts.*
main(): Int64 {
let value = ObjectValue(ArrayList<(String, Value)>([
("name", VString("Ada")),
("age", VInt(36)),
("active", VBool(true)),
("tags", ArrayValue(ArrayList<Value>([VString("types")]))),
("address", ObjectValue(ArrayList<(String, Value)>([
("city", VString("London")),
("country", VString("UK"))
])))
]))
match (decodeUser(value)) {
case Decoded(user) => println(userSummary(user))
case Failed(error) => println(error.message())
}
return 0
}
如果使用 cjpm 管理依赖,则由业务工程统一构建;如果使用底层 cjc,则使用根模块构建产生的 .cjo 接口搜索路径和静态库路径。跨端项目可以复用相同的 Value、解码结果和错误处理思路,具体的 JSON 输入转换、异步任务和平台数据源由宿主平台负责。
九、总结
本次适配完成了 io-ts 运行时类型模型到仓颉显式类型模型的迁移:外部输入使用 Value,解码结果使用 DecodeResult<T>,结构体使用 User 和 Address,错误使用 DecodeError,编码使用 encodeUser,联合和运行时判断通过专用 API 表达。
验证链路包括 cjpm build、cjpm test、路径依赖示例、基础值解码、嵌套结构、数组、可选字段、错误路径、编码往返、联合类型、守卫和递归等值比较。最终效果图来自真实的仓颉可执行程序,能够直观看到三方库在成功和失败场景中的输出,而不是只显示断言通过。对于暂时无法一比一映射 TypeScript io-ts 的动态值、类型推导和高阶 codec 语义,项目通过静态接口和文档边界保持行为可解释,方便后续继续扩展。
适配过程 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抓取运行日志; - 优先复现最小用例,确认是否为平台差异或时序问题。