news 2026/10/7 2:24:26

io-ts 运行时类型系统实战指南:从 Codec 定义到解码、编码与错误报告

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
io-ts 运行时类型系统实战指南:从 Codec 定义到解码、编码与错误报告
  • 后端

【免费下载链接】io-ts

Runtime type system for IO decoding/encoding

项目地址:https://gitcode.com/gh_mirrors/io/io-ts
点击查看免费下载

io-ts 是一个面向 IO 解码/编码场景的 TypeScript 运行时类型系统:开发者用一套 TypeScript 代码同时得到「编译期静态类型」与「运行期校验逻辑」,无需重复定义。本文以仓库根目录的 index.md 为主线,结合 src/index.ts 与 src/Type.ts 的源码实现,系统讲解 Codec 的核心模型、全部内建组合子、解码/编码/错误报告机制,以及递归类型、Branded 类型、Exact 类型、自定义类型与 Piping 等进阶用法,帮助你在 API 边界处安全地校验不可信数据。

基本用法:一次定义,双重收益

io-ts 的核心思路是:用运行时对象(Codec)描述静态类型。最典型的入口是t.type,它把一组属性描述编译成一个结构化的运行时类型:

import * as t from 'io-ts' const User = t.type({ userId: t.number, name: t.string })

这等价于手动定义一个 TypeScript interface/type:

type User = { userId: number name: string }

使用 io-ts 定义运行时类型的优势在于:可以在运行时校验类型,同时又能从中提取对应的静态类型,因此不需要把同一个模型写两遍。随后可用该运行时类型去校验/解码不可信数据(例如来自网络请求、用户输入的数据):

import * as t from "io-ts"; import { PathReporter } from "io-ts/PathReporter"; import { isLeft } from "fp-ts/Either"; const User = t.type({ userId: t.number, name: t.string, }); const data: unknown = { userId: 123, name: "foo" }; // 看起来像 User,但来自不可信来源 const decoded = User.decode(data); // Either<Errors, User> if (isLeft(decoded)) { throw Error( `Could not validate data: ${PathReporter.report(decoded).join("\n")}` ); // 例如:Could not validate data: Invalid value "foo" supplied to : { userId: number, name: string }/userId: number } type UserT = t.TypeOf<typeof User>; // 编译期类型 const decodedUser: UserT = decoded.right; // 此刻已被安全地收窄为正确类型 console.log("decoded user id:", decodedUser.userId);

io-ts 还支持更复杂的编码/解码(序列化/反序列化)场景——decode()的输出类型不必与输入类型相同。例如你可以把Date对象编码为字符串,再解码回Date对象(见下文「自定义类型」一节)。仓库中的 docs/modules/Decoder.ts.md、docs/modules/Encoder.ts.md 分别给出了解码器与编码器维度的完整 API 文档,可作为进阶参考。

内建类型与组合子一览

io-ts 在 src/index.ts 中导出了一整套内建类型与组合子,下表完整列出:

类型TypeScriptcodec / 组合子
nullnullt.null或t.nullType
undefinedundefinedt.undefined
voidvoidt.void或t.voidType
stringstringt.string
numbernumbert.number
booleanbooleant.boolean
unknownunknownt.unknown
unknown 数组Array<unknown>t.UnknownArray
类型数组Array<A>t.array(A)
unknown 记录Record<string, unknown>t.UnknownRecord
类型记录Record<K, A>t.record(K, A)
函数Functiont.Function
字面量's't.literal('s')
可选属性Partial<{ name: string }>t.partial({ name: t.string })
只读Readonly<A>t.readonly(A)
只读数组ReadonlyArray<A>t.readonlyArray(A)
类型别名type T = { name: A }t.type({ name: A })
元组[ A, B ]t.tuple([ A, B ])
联合A \| Bt.union([ A, B ])
交叉A & Bt.intersection([ A, B ])
键枚举keyof Mt.keyof(M)(仅支持字符串键)
递归类型t.recursion(name, definition)
Branded 类型 / 细化✘t.brand(A, predicate, brand)
整数✘t.Int(内建 branded codec)
Exact 类型✘t.exact(type)(不允许未知多余属性)
strict✘t.strict({ name: A })(t.exact(t.type({ name: A }))的别名)

从源码看,基础类型(如 src/index.ts 中的StringType、NumberType、BooleanType)都是Type的具体子类,通过typeof检查实现is守卫,再据此实现validate;t.Int则是brand(number, Number.isInteger, 'Int')的产物,见 src/index.ts。t.keyof的实现使用string.is(u) && hasOwnProperty.call(keys, u)判断值是否属于给定键集合,见 src/index.ts。

核心模型:Codec 与 Either

一个Type<A, O, I>类型的值(即「codec」)是静态类型A的运行时表示。一个 codec 可以:

  • 通过decode解码I类型的输入
  • 通过encode编码O类型的输出
  • 通过is作为自定义类型守卫使用
class Type<A, O, I> { constructor( /** 该 codec 的唯一名称 */ readonly name: string, /** 自定义类型守卫 */ readonly is: (u: unknown) => u is A, /** 当 I 类型的值能解码为 A 类型的值时成功 */ readonly validate: (input: I, context: Context) => Either<Errors, A>, /** 将 A 类型的值转换为 O 类型的值 */ readonly encode: (a: A) => O ) {} /** `validate` 的带默认 context 版本 */ decode(i: I): Either<Errors, A> }

在 src/index.ts 中,Type类同时实现了Decoder<I, A>与Encoder<A, O>两个接口:decode内部以[{ key: '', type: this, actual: i }]作为默认 context 调用validate,且构造函数里对decode做了bind(this),保证方法被解构取出后this依然正确。另外还提供了pipe、asDecoder()、asEncoder()等方法,便于类型层面的双向投影。

decode返回的Either类型定义于 fp-ts——一个包含 TypeScript 常见代数类型实现的库。Either表示「两个可能类型之一的值」(不相交联合),其实例要么是Left要么是Right:

type Either<E, A> = | { readonly _tag: 'Left' readonly left: E } | { readonly _tag: 'Right' readonly right: A }

约定俗成:Left表示失败,Right表示成功。

手写一个 codec:以 string 为例

一个表示string的 codec 可以这样定义:

import * as t from 'io-ts' const string = new t.Type<string, string, unknown>( 'string', (input: unknown): input is string => typeof input === 'string', // `t.success` 和 `t.failure` 是构造 `Either` 实例的辅助函数 (input, context) => (typeof input === 'string' ? t.success(input) : t.failure(input, context)), // 这里 `A` 与 `O` 相同,因此 `encode` 就是恒等函数 t.identity )

使用方式如下:

import { isRight } from 'fp-ts/Either' isRight(string.decode('a string')) // true isRight(string.decode(null)) // false

更一般地,decode的结果可以借助fold与pipe(类似管道运算符)来处理:

import * as t from 'io-ts' import { pipe } from 'fp-ts/function' import { fold } from 'fp-ts/Either' // 失败处理函数 const onLeft = (errors: t.Errors): string => `${errors.length} error(s) found` // 成功处理函数 const onRight = (s: string) => `No errors: ${s}` pipe(t.string.decode('a string'), fold(onLeft, onRight)) // => "No errors: a string" pipe(t.string.decode(null), fold(onLeft, onRight)) // => "1 error(s) found"

源码中的success即right、failure即「把错误包装进left」,见 src/index.ts;Validation<A>被定义为Either<Errors, A>(src/index.ts)。

我们还可以通过组合子把这些 codec 组合成复合类型,用来表示领域模型、请求载荷等应用实体。

TypeScript 集成:内省与自动推断

codec 是可内省的(introspection):

这个库重度使用 TypeScript,其 API 被设计为能根据 schema 自动推断产出的类型(且注释会保留):

注意上图中并不需要显式类型注解——TypeScript 会根据 schema 自动推断类型。对应的类型推断能力来源于t.Type上的幻影类型字段_A、_O、_I(见 src/index.ts),配合TypeOf/InputOf/OutputOf类型运算(src/index.ts)实现。

静态类型可以用TypeOf运算符从 codec 中提取:

type User = t.TypeOf<typeof User> // 等价于 type User = { userId: number name: string }

TypeScript 版本兼容性

稳定版本针对 TypeScript 3.5.2 进行测试:

io-ts 版本要求的 TypeScript 版本
2.x+3.5.2+
1.6.x+3.2.2+
1.5.33.0.1+
1.5.2-2.7.2+

注意:本库的设计、测试与使用前提是开启 TypeScript 的strict标志。

注意:如果你运行在< typescript@3.0.1环境下,必须为unknown提供 polyfill。可以使用 unknown-ts 作为 polyfill。

错误报告器(Error Reporters)

报告器(reporter)实现如下接口:

interface Reporter<A> { report: (validation: Validation<any>) => A }

该接口在 src/Reporter.ts 中定义。本包导出一个默认的PathReporter报告器。

示例:

import { PathReporter } from 'io-ts/PathReporter' const result = User.decode({ name: 'Giulio' }) console.log(PathReporter.report(result)) // => [ 'Invalid value undefined supplied to : { userId: number, name: string }/userId: number' ]

PathReporter的实现(src/PathReporter.ts)在内部把 context 拼成key: type.name用/连接的路径,并用JSON.stringify序列化出错的值(对NaN、Infinity、函数等做了特殊处理);成功时返回['No errors!']。

你可以定义自己的报告器。Errors具有以下类型:

interface ContextEntry { readonly key: string readonly type: Decoder<any, any> } interface Context extends ReadonlyArray<ContextEntry> {} interface ValidationError { readonly value: unknown readonly context: Context } interface Errors extends Array<ValidationError> {}

在 src/index.ts 中可以看到这些接口的完整定义:ContextEntry还包含可选字段actual(输入数据),ValidationError还包含可选字段message(自定义错误消息),这两个可选字段正是「自定义错误消息」与后续错误定位的基础。

示例——自定义报告器输出错误路径:

import { pipe } from 'fp-ts/function' import { fold } from 'fp-ts/Either' const getPaths = <A>(v: t.Validation<A>): Array<string> => { return pipe( v, fold( (errors) => errors.map((error) => error.context.map(({ key }) => key).join('.')), () => ['no errors'] ) ) } console.log(getPaths(User.decode({}))) // => [ '.userId', '.name' ]

自定义错误消息

你可以给failure提供message参数来设置自己的错误消息:

示例:

import { either } from 'fp-ts/Either' const NumberFromString = new t.Type<number, string, unknown>( 'NumberFromString', t.number.is, (u, c) => either.chain(t.string.validate(u, c), (s) => { const n = +s return isNaN(n) ? t.failure(u, c, 'cannot parse to a number') : t.success(n) }), String ) console.log(PathReporter.report(NumberFromString.decode('a'))) // => ['cannot parse to a number']

从 src/PathReporter.ts 可以看到,getMessage会优先采用e.message;只有当message为undefined时才回退到默认的Invalid value ... supplied to ...格式。你也可以使用 io-ts-types 中的withMessage辅助函数来附加错误消息。

递归类型

递归类型无法被 TypeScript 推断,因此你必须提供静态类型作为提示:

interface Category { name: string categories: Array<Category> } const Category: t.Type<Category> = t.recursion('Category', () => t.type({ name: t.string, categories: t.array(Category) }) )

recursion的源码实现(src/index.ts)采用惰性求值:第一次访问时才调用definition(Self)生成实际 codec,并把结果缓存,后续访问直接复用缓存;RecursiveType的type属性也通过 getter 延迟展开,从而支持自引用结构。

相互递归类型

interface Foo { type: 'Foo' b: Bar | undefined } interface Bar { type: 'Bar' a: Foo | undefined } const Foo: t.Type<Foo> = t.recursion('Foo', () => t.type({ type: t.literal('Foo'), b: t.union([Bar, t.undefined]) }) ) const Bar: t.Type<Bar> = t.recursion('Bar', () => t.type({ type: t.literal('Bar'), a: t.union([Foo, t.undefined]) }) )

Branded 类型 / 细化(Refinements)

你可以用brand组合子对任意codec 进行品牌化/细化:

// 正数的唯一品牌 interface PositiveBrand { readonly Positive: unique symbol // 使用 `unique symbol` 确保跨模块/包唯一 } const Positive = t.brand( t.number, // 要被细化的类型对应的 codec (n): n is t.Branded<number, PositiveBrand> => 0 < n, // 使用内建辅助类型 `Branded` 的自定义类型守卫 'Positive' // 名称必须与 brand 中的 readonly 字段一致 ) type Positive = t.TypeOf<typeof Positive> /* 等价于 type Positive = number & t.Brand<PositiveBrand> */

Branded codec 可以用t.intersection合并:

// t.Int 是内建的 branded codec const PositiveInt = t.intersection([t.Int, Positive]) type PositiveInt = t.TypeOf<typeof PositiveInt> /* 等价于 type PositiveInt = number & t.Brand<t.IntBrand> & t.Brand<PositiveBrand> */

从源码看,brand本质上是对refinement的封装(src/index.ts),Branded<A, B>定义为A & Brand<B>(src/index.ts),t.Int则是用Number.isInteger作为谓词的内建整数 codec(src/index.ts)。

Exact 类型

你可以用exact组合子让 codec 变「严格」(意味着多余属性会被剥离):

const ExactUser = t.exact(User) User.decode({ userId: 1, name: 'Giulio', age: 45 }) // ok,结果是 right({ userId: 1, name: 'Giulio', age: 45 }) ExactUser.decode({ userId: 1, name: 'Giulio', age: 43 }) // ok 但结果是 right({ userId: 1, name: 'Giulio' })

exact的底层实现(src/index.ts)会先通过getProps提取 codec 的属性表,再构造ExactType;校验通过后调用stripKeys把不在属性表中的键剔除。而t.strict(props)就是exact(type(props))的别名(src/index.ts)。

混合必填与可选属性

可以用 intersection 混合必填与可选属性:

const A = t.type({ foo: t.string }) const B = t.partial({ bar: t.number }) const C = t.intersection([A, B]) type C = t.TypeOf<typeof C> // 等价于 type C = { foo: string } & { bar?: number | undefined }

你也可以通过props字段把partial应用到一个已由type定义的 codec 上:

const PartialUser = t.partial(User.props) type PartialUser = t.TypeOf<typeof PartialUser> // 等价于 type PartialUser = { name?: string age?: number }

InterfaceType、PartialType、StrictType等 codec 都携带props字段(相关类型定义见 src/Type.ts),getProps甚至可以从RefinementType、ReadonlyType、IntersectionType中递归提取属性表,这也是exact、partial(User.props)等能力的基础。

自定义类型

你可以定义自己的类型。看一个例子:

import { either } from 'fp-ts/Either' // 表示「由 ISO 字符串构成的 Date」 const DateFromString = new t.Type<Date, string, unknown>( 'DateFromString', (u): u is Date => u instanceof Date, (u, c) => either.chain(t.string.validate(u, c), (s) => { const d = new Date(s) return isNaN(d.getTime()) ? t.failure(u, c) : t.success(d) }), (a) => a.toISOString() ) const s = new Date(1973, 10, 30).toISOString() DateFromString.decode(s) // right(new Date('1973-11-29T23:00:00.000Z')) DateFromString.decode('foo') // left(errors...)

注意:你可以在校验的同时反序列化。仓库中 docs/modules/Codec.ts.md 与 src/Codec.ts 还提供了更现代的Codec建模方式,把Decoder、Encoder与类型守卫合并为单一对象,适用于 decode/encode 双向类型不对称的复杂场景。

泛型类型(Generic Types)

多态 codec 用函数来表示。例如下面的 TypeScript:

interface ResponseBody<T> { result: T _links: Links } interface Links { previous: string next: string }

可以表示为:

// 其中 `t.Mixed = t.Type<any, any, unknown>` const responseBody = <C extends t.Mixed>(codec: C) => t.type({ result: codec, _links: Links }) const Links = t.type({ previous: t.string, next: t.string })

使用方式:

const UserModel = t.type({ name: t.string }) functionThatRequiresRuntimeType(responseBody(t.array(UserModel)), ...params)

t.Mixed在 src/index.ts 中被定义为Type<any, any, unknown>,t.Any则是Type<any, any, any>——二者分别代表「输入未知的混合类型」与「任意 codec」,是编写泛型组合子时的常用约束。

Piping(管道连接)

只要两个 codec 的类型参数对齐,就可以把其中一个管道连接到另一个:

const NumberCodec = new t.Type<number, string, string>( 'NumberCodec', t.number.is, (s, c) => { const n = parseFloat(s) return isNaN(n) ? t.failure(s, c) : t.success(n) }, String ) const NumberFromString = t.string.pipe(NumberCodec, 'NumberFromString')

pipe是Type类上的实例方法(src/index.ts):解码时先运行this.validate,失败直接短路返回;成功则把right值继续交给ab.validate,从而把「字符串 → 数字」的转换过程组合起来。编码方向则按相反顺序把ab.encode与this.encode串起来(当两者都是恒等函数时直接复用恒等函数以省去多余调用)。

社区生态

  • io-ts@2.x
    • io-ts-types —— 与 io-ts 配套使用的 codec 与组合子集合
    • io-ts-reporters —— 面向 io-ts 的错误报告器
    • io-ts-promise —— 在基于 Promise 的 API 中使用 io-ts 的便利库
  • io-ts@1.x
    • geojson-iots —— 基于 io-ts 实现的、符合 rfc7946 定义的 GeoJSON codec
    • graphql-to-io-ts —— 从 graphql schema 生成 TypeScript 及对应的 io-ts 类型

技巧与提示

字符串字面量联合:用 keyof 而不是 union

定义字符串字面量联合时,优先使用keyof而不是union:

const Bad = t.union([ t.literal('foo'), t.literal('bar'), t.literal('baz') // 等等... ]) const Good = t.keyof({ foo: null, bar: null, baz: null // 等等... })

收益:

  • 免费获得唯一性检查
  • 性能更好,O(log(n))对比O(n)

注意:keyof设计用于「包含字符串键的对象」。如果你想定义数字枚举,必须使用数字字面量的union:

const HttpCode = t.union([ t.literal(200), t.literal(201), t.literal(202) // 等等... ])

之所以keyof更快,是因为其实现直接做hasOwnProperty哈希查找(src/index.ts),复杂度为常数级;而union需要对成员逐一尝试校验,属于线性扫描。从源码结构看,t.union还会对带有字面量判别字段(tag)的联合自动构建索引以加速匹配(见 src/Type.ts 的getIndex逻辑),因此对于带判别字段的联合类型,io-ts 也能保持较高效率。

相关文档导航

  • docs/index.md —— 与根目录index.md内容对应的官网首页版本
  • docs/modules/index.ts.md —— 主模块 API 文档(Codec、Type、combinators 等)
  • docs/modules/Decoder.ts.md —— Decoder 维度 API
  • docs/modules/Encoder.ts.md —— Encoder 维度 API
  • docs/modules/PathReporter.ts.md —— PathReporter 报告器 API
  • docs/modules/Reporter.ts.md —— Reporter 接口 API
  • test/Type.ts、test/Decoder.ts、test/Codec.ts —— 对应的测试用例,可验证本文所述各组合子的实际行为
  • perf/index.ts、perf/typescript-runtime-type-benchmarks.ts —— 性能对比与基准测试

仓库还提供Decoder、Encoder、Guard、TaskDecoder、Codec、Schemable、Kleisli、FreeSemigroup等多个独立模块(见 src 目录与 docs/modules),可在此基础上继续深入。

  • 后端

【免费下载链接】io-ts

Runtime type system for IO decoding/encoding

项目地址:https://gitcode.com/gh_mirrors/io/io-ts
点击查看免费下载

相关推荐

上一篇:GriddyCode:如何从零打造个性化代码编辑神器?终极配置指南揭秘
下一篇:如何快速掌握KS-Downloader:终极快手无水印视频下载指南

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/7 2:22:49

Ubuntu 24.04安装搜狗输入法:Fcitx配置与Xorg切换全指南

1. Ubuntu 24.04 输入法架构的变化&#xff1a;为什么这次装搜狗比旧版本更折腾1.1 从 IBus 到 Fcitx&#xff1a;两套输入法框架并存时的冲突逻辑如果你是从 Ubuntu 20.04 或 22.04 一路升级到 24.04 的老用户&#xff0c;应该能明显感觉到这次安装搜狗输入法的手感完全不一样…

作者头像 李华
网站建设 2026/10/7 2:19:19

低代码赋能传统ERP:快速补丁模块与系统对接实战指南

做企业管理软件这块十多年&#xff0c;我经手的ERP项目少说也有几十个。每次听到"低代码赋能ERP"这种说法&#xff0c;第一反应往往是&#xff1a;又是个概念炒作的标题。但这两年风向真的变了&#xff0c;我亲眼看着一家年产值过亿的制造企业&#xff0c;用低代码平…

作者头像 李华
网站建设 2026/10/7 2:18:57

Agent技能体系搭建实战:从工具调用到稳定技能输出

1. agent-skills到底在解决什么问题如果你过去半年一直在折腾各种Agent项目&#xff0c;一定见过这个标题&#xff1a;agent-skills。仓库里躺着一堆技能描述文件&#xff0c;页面打开是密密麻麻的YAML或JSON配置&#xff0c;乍一看像是给大模型写使用说明书。但真正动手试过之…

作者头像 李华