实现TypeScript运行时类型检查

文摘   2024-11-01 15:22   新加坡  

在与后端开发同事对接API时, 同事问我:

你们前端是如何对JSON 数据进行encode/decode 的?

这个问题对一个纯前端工程师来说是有些"奇怪"的.

因为前端并不需要对JSON 进行encode/decode , 只需要对JSON string 进行parse.

parse 之后的数据便是JavaScript 中的数据结构, 这也是JSON 名字的由来: JavaScript Object Notation.

但由于JavaScript 的数据结构与其他编程语言并不一致, 比如JavaScript 中主要用number 类型代表数字, 但在Golang 中, 根据存储空间的不同, 将数字分为:

uint8uint16uint32uint64int8int16int32 , 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 userUser = 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呢?

尤其是在AE使用同一种类型进行表示的时候, 会更加难以分辨和处理.

对此, 我们将通过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{
  xnumber;
  ynumber;
}

此外, TypeScript 还实现了类型理论中的union type, intersect type:

type Union = A | B;
type Intersect = A & B;

在余下篇幅中, 我们会一一实现这些类型对应的Parser.

组合子

在实现这些类型的Parser 之前, 让我们先来了解一个概念 -- 组合子.

组合子, 顾名思义, 就是对某种抽象的组合操作, 在本文中, 特指为对解析器的组合操作.

如上是示例所示, 在TypeScript 中, 我们也是经常使用"组合" 的方式组合类型:

type Union = A | B;
type Intersect = A & B;

在这个例子中, 我们使用 | 和 & 作为组合子, 将类型AB组合成新的类型.

同样的, 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 | numberstringstring | number>

intersect 组合子

该组合子类似于and运算:

type Intersect = <LIRI, E, LARA>(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抽象同样符合这种运算:

  1. 1. 当Either处于成功的分支Right时, 对其进行进一步的运算.

  2. 2. 当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 中, 有一个常用的函数 -- pipecompose函数与其类似, 不同之处在于函数的组合顺序:

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 fromStructFromStruct;  
declare const p2Parser<numberstringstring>;  
const v = fromStruct({a: p2})

其中v被推断为: Parser<{a: number}, string, {a: string}>.

在实现层面上, 我们可以将其类型简化为RecordLike<ParserLike<A>> => ParserLike<RecordLike<A>>, 即:

t (f a) -> f (t a)

fromStructfromArray一样, 其实现最终导向了这个"奇怪"的类型转换, 接下来我们就深入这个类型签名, 讲讲其背后蕴含的理论.

sequenceA和Applicative

我们再来看这个类型签名:

t (f a) -> f (t a)

这个类型的特征是转换后, tf的位置发生了变化, 即, "里外翻转".

其实这种转换在JavaScript我们早已使用到了, 例如Promise.all方法:

all<T>(valuesArray<Promise<T>>): Promise<Array<T>>;

让我们从Promise.all这个特例推导出这个函数的普遍性抽象.

Promise.all的执行逻辑(示例所用, 并非node底层实现)如下:

  1. 1. 创建一个空的Promise r, 并将其值设定为空数组: Promise.resolve([])

  2. 2. 尝试将values数组中的Promise的值一个个通过Promise.then串联concatPromise r.

  3. 3. 返回Promise r

代码实现如下:

const all = <A>(valuesArray<Promise<A>>): Promise<A[]> => values.reduce(  
    (r, v) => r.then(as => v.then(a => as.concat(a))),  
    Promise.resolve([] as A[]),  
);

这个实现中使用了Promise的一些操作, 罗列如下:

  • • Promise.resolve

  • • Promise.then

其中的Promise.then其实是兼具了Fuctor.mapMonad.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.mapMonad.chain:

  • • then<A, B>(f: (a:A) => B): Promise<B> 对应Functor.map

  • • then<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>(ffabPromise<(a: A) => B>, faPromise<A>): Promise<B> => fa.then(a => ffab.then(fab => fab(a)));

在函数式编程中, FunctorMonadApplicative这样的类型构造器的类型约束称为type class, 而Promise这样的实现了某种type class的类型称为instance of type class.

如代码示例所示, ap可以通过Monad.chain实现, 那么其意义是什么?

答案是Monad是比Applicative更加"强大", 但也更加严格的约束.

一个函数, 对其依赖的类型拥有更加宽松的类型约束, 其使用场景也会更加广泛, 例如:

type Move = (o: Animal) => void;

就比

type Move = (o: Dog) => void;

使用场景更加广泛, 也更加合适, 即最小依赖原则.

MonadApplicative更加"强大"的点在于:

Applicative能够对一系列上下文进行串联并且收集其中的值. MonadApplicative的基础上, 能够基于一个上下文中的值, 灵活地创建另外一个包裹在上下文中的值. -- 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)等, 覆盖面之广, 是仅仅一篇博客无法讲完的.

有兴趣的读者推荐这篇教程.


Tecvan
All or nothing, now or never 👉