如何实现TypeScript运行时类型检查?

在与后端开发同事对接 API 时, 同事问我:
你们前端是如何对 JSON 数据进行 encode/decode 的?
这个问题对一个纯前端工程师来说是有些”奇怪”的。
因为前端并不需要对 JSON 进行 encode/decode,只需要对 JSON string 进行 parse。
parse 之后的数据便是 JavaScript 中的数据结构, 这也是 JSON 名字的由来:JavaScript Object Notation.
但由于 JavaScript 的数据结构与其他编程语言并不一致, 比如 JavaScript 中主要用number 类型代表数字,但在 Golang 中,根据存储空间的不同,将数字分为:
uint8,uint16,uint32,uint64,int8,int16,int32,int64等
所以在将 JSON 转换为对应的编程语言的数据结构时,需要声明 JSON 与编程语言数据结构的对应关系,然后再进行转换,这个过程称为encode。
TypeScript 中的类型
TypeScript 在设计之初便以兼容 JavaScript 为原则,所以 JSON 也可以直接转换为 TypeScript 中的类型。
比如有以下 JSON 数据:
{
"gender": 0
}
该 JSON 可以对应到 TypeScript 类型:
enum Gender{
Female=0,
Male=1,
}
interfaceUser{
gender:Gender;
}
对应的 parse 代码为:
const user: User = JSON.parse(`{ "gender": 0 }`);
由于
JSON.parser返回类型为any, 故在我们需要显示地声明user变量为User类型.
但是如果 JSON 数据为:
{
"gender": 2
}
这个时候我们的 parse 代码还是会成功运行,但这个时候如果程序中我们还是按照类型声明那样将gender字段当做0 | 1的枚举,那么便有可能导致严重的业务逻辑缺陷。
根本原因在于,TypeScript 不会对数据的类型进行运行时的检验,TypeScript 的类型基本上只存在于编译时。
这是众多 BUG 的源头,想以下以下场景:
- 后端的接口定义里将一个字段声明数组,但实际上有的时候返回 null,前端没有对这个 case 进行处理,导致前端页面崩溃。
- 后端接口定义里,将一个字段声明为 required,但实际上有的时候返回 undefined,前端没有对中 case 进行处理,页面上直接显示
username: undefined。 - 后端说接口开发完了,前端进行联调,结果很多字段都与接口定义里不符合,QA 的同事打开页面时,页面直接崩溃了,前端开发人员在群里被批评教育…
所以在有些场景下,我们需要为 IO(Input/Output,比如网络请求,文件读取)数据进行类型检验。
io-ts
社区上有很多库提供了”对数据进行校验”这个功能, 但我们今天重点讲讲 io-ts。
io-ts 的特殊点在于:
- io-ts 的校验是与 TypeScript 的类型一一对应的, 完备程度甚至可以称为 TypeScript 的运行时类型检查。
- io-ts 使用的是
组合子(combinator)作为抽象模型, 这与大部分validator generator有本质上的区别。
本文会着重带领读者实现 io-ts 的核心模块,是对”如何使用组合子进行抽象”的实战讲解。
基础抽象
作为一个解析器(或者称为校验器),我们可以将其类型表示为:
interface Parser<I, A> {
parse: (i: I) => A;
}
这个类型用I表示解析器的输入, A表示解析器的输出。
但这么设计有一个问题:对于解析过程中的报错,我们只能通过副作用(side effect)进行收集。
最直接的方式是抛出一个异常(Error),但该方式会导致整个解析被终止。
我们希望能够将一个个”小”解析器组合成”大”解析器,所以不希望”大”解析器中的某一个”小解析器”的失败,导致整个”大”解析器被终止。
只有赋予解析器更灵活地处理异常的能力,我们才能实现更加灵活的组合方式和错误日志的收集。
此处可能有些抽象,如果有所疑惑是正常现象,结合下文理解会更加容易些。
因此,我们希望”能够像处理数据那样处理异常”,这使得我们需要将类型修改为以下形式:
interface Parser<I, E, A> {
parse: (i: I) => A | E;
}
在这次修改中,我们将异常像数据一样由函数返回,类似于 Golang 中的错误处理方式。
但直接通过union type进行抽象有一个弊端:我们将难以分辨解析器返回的数据是属于成功分支的A呢, 还是失败分支的E呢?
尤其是在A和E使用同一种类型进行表示的时候,会更加难以分辨和处理。
对此,我们将通过tagged union type进行抽象,类型声明如下:
interface Left<E>{
readonly_tag:'Left';
readonlyleft: E;
}
interfaceRight<A>{
readonly_tag:'Right';
readonlyright: A;
}
typeEither<E, A>=Left<E>|Right<A>;
通过在 union type 的基础上增加一个标识符tag,我们便能够更加便捷地对其进行区分和处理。
基于 Either,我们可以将 Parser 的类型优化为:
interface Parser<I, E, A> {
parse: (i: I) => Either<E, A>;
}
TypeScript 的类型系统
由于我们的最终目标是实现于 TypeScript 类型系统一一对应的类型检查,所以我们先理一理 TypeScript 类型系统的(部分)基本机制。
首先是 TypeScript 的 primitive 类型:
type Primitive = number | string | boolean;
然后是类型构造器:
type Numbers = number[];
当然,还有最重要的object type:
interface Point{
x: number;
y: number;
}
此外, TypeScript 还实现了类型理论中的 union type, intersect type:
type Union = A | B; type Intersect = A & B;
在余下篇幅中,我们会一一实现这些类型对应的 Parser。
组合子
在实现这些类型的 Parser 之前, 让我们先来了解一个概念 — 组合子。
组合子, 顾名思义,就是对某种抽象的组合操作,在本文中,特指为对解析器的组合操作。
如上是示例所示,在 TypeScript 中,我们也是经常使用”组合” 的方式组合类型:
type Union = A | B; type Intersect = A & B;
在这个例子中,我们使用 | 和 & 作为组合子,将类型A和B组合成新的类型。
同样的, Parser 也有其对应的组合子:
- union: P1 | P2 代表输入的数据通过两个解析器中的一个。
- intersect: P1 & P2 代表输入的数据同时满足 P1 和 P2 两个解析器。
union 组合子
该组合子类似于or运算:
type Union=<MSextendsParser<any,any,any>[]>(ms: MS) => Parser<InputOf<MS[number]>,ErrorOf<MS[number]>,OutputOf<MS[number]>>; typeInputOf<P>= P extendsParser<infer I,any,any>? I :never; typeOutputOf<P>= P extendsParser<any,any, infer A>? A :never; typeErrorOf<P>= P extendsParser<any, infer E,any>? E :never;
类型看起来有些复杂,让我们自己看看这个类型的效果:
declare constunion:Union; declareconstp1:Parser<string,string,number>; declareconstp2:Parser<number,string,string>; const p3 =union([p1, p2]);
p3的类型被 TypeScript 推断为:
Parser<string | number, string, string | number>
intersect 组合子
该组合子类似于and运算:
type Intersect = <LI, RI, E, LA, RA>(left: Parser<LI, E, LA>, right: Parser<RI, E, RA>) => Parser<LI & RI, E, LA & RA>;
map 组合子
串行运算是一种常见的抽象, 比如 JavaScript 中的Promise.then就是串行运算的经典例子:
const inc = n => n + 1; Promise.resolve(1).then(inc);
上面这段代码对Promise<number>进行了inc的串行运算。
既当Promise处于resolved状态时,对其包含的value: number进行inc,其返回结果同样为一个Promise。
若Promise处于rejected状态时,不对其进行任何操作,而是直接返回一个rejected状态的Promise。
我们可以脱离 Promise, 进而得出then的更加泛用的抽象:
对一个上下文中的结果进行进一步计算,其返回值同样包含于这个上下文中,且具有短路(short circuit)的特性。
在Promise.then中,这个上下文既是”有可能成功的异步返回值”。
得力于这种抽象,我们可以摆脱call back hell和对状态的手动断言(GoLang 的r, err := f())。
让我们思考一下, 其实上文中提到的Either抽象同样符合这种运算:
- 当
Either处于成功的分支Right时, 对其进行进一步的运算。 - 当 Either 处于失败的分支
Left时, 直接返回当前的Either。
其实现如下:
const map =<A, E, B>(f: (a: A) => B) =>
(fa:Either<E, A>):Either<E, B>=>{
if(fa._tag==='Left'){
return fa;
}
return{
_tag:'Right',
right:f(fa.right),
};
};
值得注意的是, 这里我们将函数命名为map,而非then,这是为了符合函数式编程的 Functor 定义。
Functor 是范畴论的一个术语,在这里我们可以简单将其理解为”实现了 map 函数”的 interface。
进一步地, Parser 同样符合”串行运算”的特质,为了简洁,我们这里只给出其类型定义:
type map = <I, E, A, B>(f: (a: A) => B) => (fa: Parser<I, A, E>) => Parser<I, B, E>;
compose 组合子
在Ramda 中,有一个常用的函数 — pipe,compose函数与其类似, 不同之处在于函数的组合顺序:
pipe(f1, f2, f3);
等价于:
compose(f3, f2, f1);
即, pipe 是从左到右结合,而 compose 是从右到左结合。
我们的 Parser 也有类似的抽象,为了简洁,我们这里只给出其类型定义:
type compose = <A, E, B>(ab: Parser<A, E, B>) => <I>(ia: Parser<I, E, A>) => Parser<I, E, B>;
fromArray 组合子
对应 TypeScript 的Array 类型构造器,我们的 Parser 也同样需要类似的映射,其类型声明如下:
type FromArray = <I, E, A>(item: Parser<I, E, A>) => Parser<I[], E, A[]>;
从类型推断实现是函数式编程的经典做法,我们不妨根据上述类型推断下fromArray的实现。
fromArray的返回值是Parser<I[], E, A[]>,与此同时我们有参数item: Parser<I, E, A>,那么我们可以对I[]的元素进行item进行 parser 后得到Either<E, A>[],之后将Either<E, A>[]转换成Either<E, A[]>作为最终Parser的返回值。
这个类型转换具有通用性,是函数式编程中的一个重要抽象,在本节中会化一些篇幅对其推导,最终将改抽象对应到 Haskell 的sequenceA函数。
为了Either<E, A>[] => Either<E, A[]>的转换逻辑更加清晰, 我们不妨声明一个type alias并对其进行简化:
type F<A> = Either<string, A>;
然后我们便可以将Either<E, A>[] => Either<E, A[]>简化为Array<F<A>> => F<Array<A>>,为了使其更加泛用,我们可以将Array替换为类型变量T,得到T<F<A>> => F<T<A>>。
我们将伪代码T<F<A>> => F<T<A>>转换成 Haskell 的类型签名,即可得到:
t (f a) -> f (t a)
将此类型输入到 Hoogle,我们看到这样一条类型签名:
sequenceA :: Applicative f => t (f a) -> f (t a) 这段类型签名中的
Applicative f =>是 Haskell 中的类型约束,在余下篇幅中会对其重点讲解,可以暂时对其忽略。
即, Haskell 已经有我们所需要的类型转行的抽象,函数名为sequenceA。
我们先记下有sequenceA这么个东西,还有它是干什么的,在余下篇幅中会进一步阐述。
fromStruct 组合子
fromStruct对应的是 TypeScript 中的interface类型,其类型定义如下:
type FromStruct=<P extendsRecord<string,Parser<any,string,any>>>(properties: P) =>
Parser<{[K in keyof P]:InputOf<P[K]>},string,{[K in keyof P]:OutputOf<P[K]> }>;
为了简化类型声明,上例中将
Parser<I, E, A>中的E固定为string类型。
让我们检验下类型推断:
declare const fromStruct: FromStruct;
declare const p2: Parser<number, string, string>;
const v = fromStruct({a: p2})
其中v被推断为: Parser<{a: number}, string, {a: string}>。
在实现层面上,我们可以将其类型简化为RecordLike<ParserLike<A>> => ParserLike<RecordLike<A>>,即:
t (f a) -> f (t a)
fromStruct和fromArray一样,其实现最终导向了这个”奇怪”的类型转换,接下来我们就深入这个类型签名,讲讲其背后蕴含的理论。
sequenceA 和 Applicative
我们再来看这个类型签名:
t (f a) -> f (t a)
这个类型的特征是转换后, t和f的位置发生了变化,即,”里外翻转”。
其实这种转换在 JavaScript 我们早已使用到了,例如Promise.all方法:
all<T>(values: Array<Promise<T>>): Promise<Array<T>>;
让我们从Promise.all这个特例推导出这个函数的普遍性抽象。
Promise.all的执行逻辑(示例所用, 并非 node 底层实现)如下:
- 创建一个空的
Promise r, 并将其值设定为空数组:Promise.resolve([])。 - 尝试将
values数组中的Promise的值一个个通过Promise.then串联concat进Promise r。 - 返回
Promise r。
代码实现如下:
const all = <A>(values: Array<Promise<A>>): Promise<A[]> => values.reduce( (r, v) => r.then(as => v.then(a => as.concat(a))), Promise.resolve([] as A[]), );
这个实现中使用了Promise的一些操作,罗列如下:
Promise.resolvePromise.then
其中的Promise.then其实是兼具了Fuctor.map和Monad.chain实现。
Functor上文提到过, 让我们简单看看Monad。
interface Monad<F> extends Applicative<F>{
chain: <A, B>(fa: F<A>, f: (a: A) => F<B>) => F<B>;
}
此为伪代码,TypeScript 不支持 higher kinded types,故这段代码在实际的 TypeScript 中会报错。
Promise.then的两种用法分别对应Functor.map和Monad.chain:
then<A, B>(f: (a:A) => B): Promise<B>对应Functor.mapthen<A, B>(f: (a:A) => Promise<B>): Promise<B>对应Monad.chain
Monad相比于Functor,拥有更加”强大”的能力:
对两个嵌套上下文进行合并,即
Promise<Promise<A>> => Promise<A>的转换。
在Monad的类型声明中,Monad还实现了Applicative:
interface Applicative<F> extends Functor<F> {
of: <A>(a: A) => F<A>;
ap: <A, B>(fab: F<(a: A) => B>, fa: F<A>) => F<B>;
}
其中的of很好理解,就是将一个值包裹进上下文中,比如Promise.resolve。
而ap,对于Promise可以将其实现为:
const ap = <A, B>(ffab: Promise<(a: A) => B>, fa: Promise<A>): Promise<B> => fa.then(a => ffab.then(fab => fab(a)));
在函数式编程中,
Functor,Monad,Applicative这样的类型构造器的类型约束称为type class,而Promise这样的实现了某种type class的类型称为instance of type class。
如代码示例所示,ap可以通过Monad.chain实现,那么其意义是什么?
答案是Monad是比Applicative更加”强大”,但也更加严格的约束。
一个函数,对其依赖的类型拥有更加宽松的类型约束,其使用场景也会更加广泛,例如:
type Move = (o: Animal) => void;
就比
type Move = (o: Dog) => void;
使用场景更加广泛, 也更加合适, 即最小依赖原则。
Monad比Applicative更加”强大”的点在于:
Applicative能够对一系列上下文进行串联并且收集其中的值.Monad在Applicative的基础上, 能够基于一个上下文中的值, 灵活地创建另外一个包裹在上下文中的值. — stackoverflow 上的回答
在Promise.all中,我们其实只需要将Promise限定为Applicative:
const all_ =<A,>(values:Array<Promise<A>>):Promise<A[]>=> values.reduce( (r, v) => ap( map((as: A[]) =>(a: A) =>as.concat(a), r), v, ), Promise.resolve([]as A[]), );
这里的Promise.all便是Promise版的sequenceA实现,同样的,我们也可以使用同样的抽象实现Parser版的sequenceA,此处留给读者自己去探索发现。
总结
本文简单讲解了io-ts实现背后的函数式编程原理。
但实际上,io-ts真实的实现运用了更多的设计,比如tag less final,报错类型也使用了其他的代数数据类型(ADT)等,覆盖面之广,是仅仅一篇博客无法讲完的。
文章来源于代码当如是 ,作者林集团。
以上关于如何实现TypeScript运行时类型检查?的文章就介绍到这了,更多相关内容请搜索码云笔记以前的文章或继续浏览下面的相关文章,希望大家以后多多支持码云笔记。
如若内容造成侵权/违法违规/事实不符,请将相关资料发送至 admin@mybj123.com 进行投诉反馈,一经查实,立即处理!
重要:如软件存在付费、会员、充值等,均属软件开发者或所属公司行为,与本站无关,网友需自行判断
码云笔记 » 如何实现TypeScript运行时类型检查?
微信
支付宝