- 后端
【免费下载链接】io-ts
Runtime type system for IO decoding/encoding
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 中导出了一整套内建类型与组合子,下表完整列出:
| 类型 | TypeScript | codec / 组合子 |
|---|---|---|
| null | null | t.null或t.nullType |
| undefined | undefined | t.undefined |
| void | void | t.void或t.voidType |
| string | string | t.string |
| number | number | t.number |
| boolean | boolean | t.boolean |
| unknown | unknown | t.unknown |
| unknown 数组 | Array<unknown> | t.UnknownArray |
| 类型数组 | Array<A> | t.array(A) |
| unknown 记录 | Record<string, unknown> | t.UnknownRecord |
| 类型记录 | Record<K, A> | t.record(K, A) |
| 函数 | Function | t.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 \| B | t.union([ A, B ]) |
| 交叉 | A & B | t.intersection([ A, B ]) |
| 键枚举 | keyof M | t.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.3 | 3.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
相关推荐
io-ts源码解析:深入理解运行时类型系统的实现机制
io ts源码解析:深入理解运行时类型系统的实现机制 io ts是一个强大的 运行时类型系统 ,专门用于IO解码和编码操作。作为TypeScript生态中的重要
后端Moya进阶指南:6种Task类型全覆盖,自定义参数编码一次讲透
Moya进阶指南:6种Task类型全覆盖,自定义参数编码一次讲透 Moya 是一个用 Swift 编写的轻量级 网络抽象层 ,它基于 Alamofire 封装,
后端Svelte 共享运行时错误系统详解:从 shared-errors 错误码到源码定位与修复指南
Svelte 共享运行时错误系统详解:从 shared errors 错误码到源码定位与修复指南 本文以 Svelte 仓库的共享错误定义文件 errors.m
前端Web框架编译器
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考