当前位置:网站首页>实现TypeScript运行时类型检查
实现TypeScript运行时类型检查
2022-06-24 16:45:00 【林集团】
在与后端开发同事对接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,
}
interface User {
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';
readonly left: E;
}
interface Right<A> {
readonly _tag: 'Right';
readonly right: A;
}
type Either<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 = <MS extends Parser<any, any, any>[]>(ms: MS) =>
Parser<InputOf<MS[number]>, ErrorOf<MS[number]>, OutputOf<MS[number]>>;
type InputOf<P> = P extends Parser<infer I, any, any> ? I : never;
type OutputOf<P> = P extends Parser<any, any, infer A> ? A : never;
type ErrorOf<P> = P extends Parser<any, infer E, any> ? E : never;
类型看起来有些复杂, 让我们自己看看这个类型的效果:
declare const union: Union;
declare const p1: Parser<string, string, number>;
declare const p2: 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 extends Record<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.resolve
Promise.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.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>(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
)等, 覆盖面之广, 是仅仅一篇博客无法讲完的.
有兴趣的读者推荐这篇教程.
边栏推荐
- [tke] whether to configure SNAT when the container accesses services outside the node
- MD5 verification based on stm32
- Mathematics in machine learning -- point estimation (IV): maximum posteriori probability (map)
- How important is it to document the project? I was chosen by the top 100 up leaders and stood up again
- Principle analysis of robot hardware in the loop system
- One article combs multi task learning (mmoe/ple/dupn/essm, etc.)
- Private domain defense in the cotton Era
- Ps\ai and other design software pondering notes
- What can Lu yuanjiu Jiao buy?
- [golang] Introduction to golang (I) establishment of running environment
猜你喜欢
There are potential safety hazards Land Rover recalls some hybrid vehicles
Daily algorithm & interview questions, 28 days of special training in large factories - the 15th day (string)
A survey on dynamic neural networks for natural language processing, University of California
Ui- first lesson
[go] concurrent programming channel
A survey on model compression for natural language processing (NLP model compression overview)
Some adventurer hybrid versions with potential safety hazards will be recalled
C. K-th not divisible by n (Mathematics + thinking) codeforces round 640 (Div. 4)
Ps\ai and other design software pondering notes
[leetcode108] convert an ordered array into a binary search tree (medium order traversal)
随机推荐
Goby+awvs realize attack surface detection
How important is it to document the project? I was chosen by the top 100 up leaders and stood up again
Applet wxss
[web] what happens after entering the URL from the address bar?
Batch BOM Bapi test
Popular explanation [redirection] and its practice
Is Shanjin futures safe? What are the procedures for opening futures accounts? How to reduce the futures commission?
One article combs multi task learning (mmoe/ple/dupn/essm, etc.)
Teach you to write a classic dodge game
Video intelligent analysis platform easycvr derivative video management platform menu bar small screen adaptive optimization
Applet - use of template
In those years, I insisted on learning the motivation of programming
Virtual machine virtual disk recovery case tutorial
Customized Tile Map cut - based on Tencent map
Istio FAQ: sidecar stop sequence
MD5 verification based on stm32
Talk about some good ways to participate in the project
Script design for automatic login and command return
Is Guotai Junan Futures safe? How to open a futures account? How to reduce the futures commission?
AI video structured intelligent security platform easycvr realizes intelligent security monitoring scheme for procuratorate building