2020-10-01
TypeScript
TemplateType
Parser
如何利用 Template Type 来做 Parser
最近 Hejlsberg 提交了一个 PR 让 TS 支持了 Template Type:
https://github.com/microsoft/TypeScript/pull/40336
这个特性目前还没正式发 npm 包,但可以在: TypeScript Playground 上体验到。

合着这个新特性在 TS 里就可以愉快的将形如
${XX}
当成一种新的类型来做类型抽象了, 简单玩玩:
00type HexNumber0x123 = `0x${123}`;
01
02type DOMEvent<Name extends string> = (
03  `on${Name}`
04);
05
06// => 'onClick'
07type ClickEvt = DOMEvent<'Click'>;
08
09// 0 ~ F
10type FROM_0_TO_F = (
11    '0' | '1' | '2' | '3' |
12    '4' | '5' | '6' | '7' |
13    '8' | '9' | 'A' | 'B' |
14    'C' | 'D' | 'E' | 'F'
15);
16
17type Hex = `${FROM_0_TO_F}${FROM_0_TO_F}`;
18
19// 形如 0xAABBCC
20type CharHex = `0x${Hex}`;
21
22const Char_A: CharHex = `0x41`;
23const Char_A_Err: CharHex = `0xGG`; // type error
Template Type 配合条件类型就可以来做一些匹配字符串的判断,如果再加上条件类型的递归即可实现一些文法处理器了,比方说有大神已经写出了加法计算器了,还有些人写出了 URL Parser,总之… 本文主要探讨这些 Parser 的写作手法。
在本文进一步之前,我们先从几个常用 type 来进一步熟悉 template type 这个特性

# Numberic :: Template + Union Type

temaplte 结合 union 可以用来做判断数字的逻辑
00// 长度为 1 的字符串数字
01type Numberic = (
02    '0' | '1' | '2' | '3' | '4' |
03    '5' | '6' | '7' | '8' | '9'
04);
上述 Numberic 可以用来限制一位长度的字符串,但不能限制 n 位长度的字符串类型,如果要实现 n 位的判读,需要引入递归操作:
00// 判断某字符串是不是数字
01type IsAllNumberic<S extends string> = (
02    S extends `${Numberic}${infer Rest}`
03        // S 满足上面的 template type 的时候
04        // 如果 Rest 是 '' 则返回 true 否则递归返回 IsAllNumberic<Rest>
05        ? Rest extends `` ? true : IsAllNumberic<Rest>
06        // 不满足返回 false
07        : false
08)
09
10type testOneNumberic_1 = IsAllNumberic<'aaaa'>; // false
11type testOneNumberic_2 = IsAllNumberic<'12345'>; // true

# HeadOf 类型 :: Template infer

00// 长度为 1 的字符串数字
01type Numberic = (
02    '0' | '1' | '2' | '3' | '4' |
03    '5' | '6' | '7' | '8' | '9'
04);
05
06// 取头部
07// HeadOf<'1234'> ==> '123'
08type HeadOf<S extends string> = S extends `${infer Before}${Numberic}` ? Before : never;
取出头部 HeadOf 是很常用的工具,比方说实现加法里面 HeadOf 就是数字的除十操作。

# Tail 类型 :: Template 条件 infer 递归

00// 取尾部
01// Tail<'1234'> ===> '4'
02type Tail<S extends string> = (
03    S extends `${Numberic}${infer Rest}`
04        // 满足上面的情况下且 Rest 是 `` 的时候 说明已经到底了 返回 S 即可,否则递归处理
05        ? Rest extends `` ? S : Tail<Rest>
06        : never
07);
取出尾部 Tail 也是很常用的工具,比方说实现加法里面 Tail 就是数字的对十取模操作 (求个位数)。

# Trim 类型 :: Template 条件 infer 递归

按照 js 的语意实现对字符串类型的 trim :
00// 工具函数
01// Trim<" var Eczn   "> ===> "var Eczn"
02// 先递归 TrimRight 再 TrimLeft
03type Trim<S extends string> = (
04    S extends `${infer Before} `
05        ? Trim<Before>
06        : (
07            S extends ` ${infer After}`
08                ? Trim<After>
09                : S
10        )
11);
12
13type testTrim = Trim<'  var Eczn '>;
14// => 'var Eczn'
引入 trim 是为了处理字符串两端中多余的空格

# KVPair 的定义和拍平

在处理复杂类型问题的时候总要引入点中间类型来方便转换,比方这里介绍的 KVPair, 他长这样:
00type KVP<K, V, Rest> = [K, V, Rest];
01
02// 递归定义了一下
03type test = KVP<'key1', 1, KVP<'key2', 2, null>>;
04// 上面这个通过某种递归转换可以拍平为 { key1:1, key2:2 }
05// 这个变换后文会提到
这个结构其实跟 Scheme 里的 cons 结构是类似的,可以用来做函数式数据结构,额 … 总之这个结构可以不需要下标即可遍历,在 TS 里做遍历很重要的一种结构

上面应该已经提过了中间结构 KVP,我们需要利用这种结构生成对象类型出来,比如:
00// 下面这个可以拍平为 { k1: 123 }
01type p1 = ["k1", "123", null];
02
03// 下面这个可以拍平为 { k1: 123, k2: "2" }
04type p2 = [
05    "k1", "123",
06    ["k2", '"2"', null]
07];
08
09// 下面这个可以拍平为 { k1: 123, inner: { k3: 3 }, k2: "2" }
10type p3 = [
11    "k1", "123",
12    ["inner", ["k3", "3", null], ["k2", '"2"', null]]
13];
那么如何从 KVPair 里生成对象呢 ? 我们看到 KVPair 是递归的,可以通过递归遍历的形式来遍历操作,在遍历的过程中即可实现转对象的操作
00// 判断 V 是不是 KVP 结构,是的话用 KVPairsToObj 处理,否则返回其自身
01type MaybeKVP<V> = V extends [string, string, any] ? KVPairsToObj<V> : V;
02
03// 将递归的 KVParis 压平 ...
04type KVPairsToObj<P> = P extends [infer K, infer V, infer Rest] ? (
05    // 先确保 K 可以作为 Key ... 否则我们返回 P 自己
06    K extends (string | number | symbol) ? (
07        Rest extends null
08            // 如果说 Rest 是 null ... 则说明递归到底了, 直接返回 { [K]: V } 即可, 但 V 也有可能是 KVP 因此要 MaybeKVP<V> 递归一下
09            ? { [INNERKEY in K]: MaybeKVP<V> }
10            // 如果说 Rest 有东西则说明我们需要递归进一步处理
11            // 注意 INNERKEY 的取值为当前的 K 以及 keyof KVPairsToObj<Rest>, 是的需要在递归里面用到递归的结果
12            // 然后判断 INNERKEY 是不是 K 如果是的话返回 MaybeKVP<V>
13            // 否则返回 KVPairsToObj<Rest>[INNERKEY]
14            : { [INNERKEY in K | keyof KVPairsToObj<Rest>]: INNERKEY extends K ? MaybeKVP<V> : KVPairsToObj<Rest>[INNERKEY] }
15    ) : never
16) : never;
注意上面的 INNERKEY 那块,仔细揣摩 KVPairsToObj<Rest> 的含义以及 KVPairsToObj<Rest>[INNERKEY] 代表什么就能看懂了。另外推荐在 Eczn’s TypeScript Playground :: KVPairs 里查看上述代码,KM 这里代码显示不好看。

# JSON Parser 的实现

实现 JSON Parser 关键是从从 raw json string 生成 KVPair,然后借助前文提到的拍平 KVPair 的工具类型去拍平为对象类型:
00// 从 "  { XXXX }   " 中取出 XXXX 来 (即取出 json kv string map)
01type SplitKVStringFrom<S extends string> = S extends `${infer AnyBlank}{${infer INNER}}${infer AnyBlank}` ? KVPairs<INNER> : Trim<S>;
02
03// 从 json kv string map 生成一种递归的结构 Pair = [K, V, Pair<string, any>] 表达一个对象的信息
04type KVPairs<S extends string> = (
05    S extends `${infer AnyBlank}"${infer Key}"${infer AnyBlank}:${infer Rest}`
06        ? Rest extends `${infer Value},${infer Rest}` ? [Key, SplitKVStringFrom<Value>, KVPairs<Rest>] : [Key, SplitKVStringFrom<Rest>, null]
07        : Trim<S>
08)
09
10// 原始信息
11type myjson = '{ "k1"  :"v1" , "wow": { "eczn": "喵" }, "k2":   "v2","k3":333, "rrr": { "inner": "abc" }  }';
12
13// 中间 AST 表达
14type myparis = KVPairs<myjson>;
最后一步将将 KVPair 转对象类型,这里需借助 前文的 KVPairsToObj 即可实现,详见在线 DEMO: Eczn’s TypeScript Playground :: Type Parser For JSON
本文目的不是实现无懈可击的 json parser 而是介绍 template 写 parser 的思路,上述实现并不完整还差数字和数组没有处理,只实现了一个子集,有兴趣的同学可以自行补全这块。
enter image description here
enter image description here
enter image description here
enter image description here

# 实现加法算数运算

算数运算主要还是半加器、进位等这套来做的,当然也可以利用 Church Encoding 来实现,我再实现的过程中用了前者的思路:
首先要描述个位数,半加器要用
00// 描述个位数
01type Numberic = '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9';
然后需要定义个位数的加法,也就是定义好半加器,而定义需要下点小手段,穷举定义的话会比较多代码,简化一下利用递归自增的形式来定义个位数的加法:
00// 定义 0 ~ 18 自增规则
01// 如 IncOne<'9', '1'> ===> '10'
02type IncOne<A extends string> = (
03    A extends '0' ? '1'  :   // 0 + 1 = 1
04    A extends '1' ? '2'  :   // 1 + 1 = 2
05    A extends '2' ? '3'  :   // 2 + 1 = 3
06    A extends '3' ? '4'  :   // 3 + 1 = 4
07    A extends '4' ? '5'  :   // 4 + 1 = 5
08    A extends '5' ? '6'  :   // 5 + 1 = 6
09    A extends '6' ? '7'  :   // 6 + 1 = 7
10    A extends '7' ? '8'  :   // 7 + 1 = 8
11    A extends '8' ? '9'  :   // 8 + 1 = 9
12    A extends '9' ? '10' :   // 9 + 1 = 10
13    A extends '10' ? '11' :  // 9 + 1 = 10
14    A extends '11' ? '12' :  // 9 + 1 = 10
15    A extends '12' ? '13' :  // 9 + 1 = 10
16    A extends '13' ? '14' :  // 9 + 1 = 10
17    A extends '14' ? '15' :  // 9 + 1 = 10
18    A extends '15' ? '16' :  // 9 + 1 = 10
19    A extends '16' ? '17' :  // 9 + 1 = 10
20    A extends '17' ? '18' :  // 9 + 1 = 10
21    A extends '18' ? '19' :  // 9 + 1 = 10
22    never
23);
24
25// 由 IncOne 定义 0 ~ 9 范围内的任意加法 [0, 9] + [0, 9]
26// 如 IncX<'9', '9'> ===> '18'
27type IncX<A extends string, B extends string> = (
28    B extends '0' ? A  :  // 0 + 1 = 1
29    B extends '1' ? IncX<IncOne<A>, '0'>  :  // 1 + 1 = 2
30    B extends '2' ? IncX<IncOne<A>, '1'>  :  // 2 + 1 = 3
31    B extends '3' ? IncX<IncOne<A>, '2'>  :  // 3 + 1 = 4
32    B extends '4' ? IncX<IncOne<A>, '3'>  :  // 4 + 1 = 5
33    B extends '5' ? IncX<IncOne<A>, '4'>  :  // 5 + 1 = 6
34    B extends '6' ? IncX<IncOne<A>, '5'>  :  // 6 + 1 = 7
35    B extends '7' ? IncX<IncOne<A>, '6'>  :  // 7 + 1 = 8
36    B extends '8' ? IncX<IncOne<A>, '7'>  :  // 8 + 1 = 9
37    B extends '9' ? IncX<IncOne<A>, '8'>  :  // 9 + 1 = 10
38    A
39);
然后取出个位数和十位数的这些可以使用本文一开始的 HeadOf 和 Tail,最后整理一下:
00// 加法: A B 为加数, C 是进位, 默认为 0
01type Add<A extends string, B extends string, C extends string = '0'> = (
02    A extends ''  ? C extends '1' ? Add<B, C> : B :
03    B extends ''  ? C extends '1' ? Add<A, C> : A :
04    A extends '0' ? C extends '1' ? Add<B, C> : B :
05    B extends '0' ? C extends '1' ? Add<A, C> : A :
06    // @ts-ignore 这块递归比较多 TS 会有警告, 故加了个 ts-ignore
07    `${Add< HeadOf<A>, HeadOf<B>, HeadOf<IncX<Tail<A>, Tail<B>>> >}${Tail< IncX<IncX<Tail<A>, Tail<B>>, C> >}`
08);
enter image description here
enter image description here
乘法的计算可以利用加法递归定义;求余其实就是 Tail 取模其实是 HeadOf … 总之其他的运算都还算可以做。

# 实现 URL Parser

根据 URL 的定义可以很快写出:
00type MakeUrl<Protocol, Domain, Port, Pathname, Search, Hash> = {
01    protocol: Protocol,
02    domain: Domain,
03    port: Port,
04    pathname: Pathname,
05    search: Search,
06    hash: Hash
07};
08
09type URLParse<U extends string> = (
10    U extends `${infer Protocol}://${infer Domain}:${infer Port}/${infer Pathname}` ? 'AA' : 'BB'
11    // 这里停顿一下... Port 是可省略的 ... 这里又必填了
12)
写到这里发现 URL 里有些字段是可以不用填的,因此上面的做法很局限,不好搞全全部情况。
斟酌之后考虑用 KVPair 的结构来解析:
00// 解析 URL 为对象类型
01type URLParse<U> = KVPairsToObj<URLKVPairs<U>>;
02
03// 解析 URL 为 KVPairs
04type URLKVPairs<U> = (
05    U extends `${infer Protocol}://${infer Rest}` ? ['protocol', Protocol, ParseDomain<Rest>] :
06    U extends `//${infer Rest}` ? ['protocol', null, ParseDomain<Rest>] :
07    never
08);
09
10// 解析 Domain
11type ParseDomain<U> = (
12    U extends `${infer Domain}:${infer Port}/${infer Rest}` ? ['domain', Domain, ['port', Port, ParsePathname<Rest>]] :
13    U extends `${infer Domain}:${infer Port}` ? ['domain', Domain, ['port', Port, null]] :
14    U extends `${infer Domain}/${infer Rest}` ? ['domain', Domain, ['port', null, ParsePathname<Rest>]] :
15    ['domain', U, null]
16)
17
18// 解析 Pathname
19type ParsePathname<U> = (
20    U extends `${infer Pathname}?${infer Search}#${infer Hash}` ? ['pathname', Pathname, ['search', Search, ['hash', Hash, null]]] :
21    U extends `${infer Pathname}?${infer Search}` ? ['pathname', Pathname, ['search', Search, null]] :
22    ['pathname', U, null]
23)
总之我的思路就是想办法得到 KVPair 最后 pair 转 obj
00https://eczn.local/a.html?aaa=bbb#hash
01=> ['protocol', 'https', ParseDoamin<'eczn.local/a.html?aaa=bbb#hash'>]
02
03ParseDoamin<'eczn.local/a.html?aaa=bbb#hash'>
04=> ['domain', 'eczn.local', ['port', null, ParsePathname<'a.html?aaa=bbb#hash'>]]
05
06ParsePathname<'a.html?aaa=bbb#hash'> =>
07['pathanme', 'a.html', ['search', 'aaa=bbb', ['hash', 'hash', null]]]
08
09最后是一个 [K, V, [K2, V2, [K3, V3, null]]] 的递归结构,将其转为 obj 即可(前面 JSON Parser 已经实现了)

# 实现 QueryString Parser

上面 URL Parser 并没有帮忙 parse querystring,这里也补一个, 道理也是类似的,从字符串生成 KVP 再转 object
00// Query 字符串转 KVP 结构
01type QueryParseToKVP<S extends string> = (
02    S extends `${infer Key}=${infer Value}&${infer Rest}`
03        ? [Key, Value, QueryParseToKVP<Rest>]            // 这种情况直接解析
04        : S extends `${infer Key2}=${infer Value2}`
05            ? [Key2, Value2, null]                  // 这种情况代表到最后一项了
06            : null                                  // 否则返回 null
07);

# 实现 UUID Type Check

校验某字符串是否满足 uuid 的规则:
00type StrHex = '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | '0' | 'A' | 'B' | 'C' | 'D' | 'E' | 'F' | 'a' | 'b' | 'c' | 'd' | 'e' | 'f';
01
02// 返回长度, 如 StrHexCons<'1234'> ===> '4'
03type StrHexCons<S extends string, ACC extends keyof AddOneMap = '0'> = (
04    S extends `${StrHex}${infer Rest}`
05        ? StrHexCons<Rest, AddOneMap[ACC]>
06        : ACC
07);
08
09type AddOneMap = {
10    '0': '1',
11    '1': '2',
12    '2': '3',
13    '3': '4',
14    '4': '5',
15    '5': '6',
16    '6': '7',
17    '7': '8',
18    '8': '9',
19    '9': '10',
20    '10': '11'
21    '11': '12'
22    '12': '13',
23    '13': never, // 12 对应 13 ... 会导致 AddOneMap[keyof AddOneMap] 出现问题
24}
25
26type IsUUID<S extends string> = (
27    S extends `${infer One}-${infer Two}-${infer Three}-${infer Four}`
28        ? (
29            '8' extends StrHexCons<One> ?
30                '4' extends StrHexCons<Two> ?
31                    '4' extends StrHexCons<Three> ?
32                        '12' extends StrHexCons<Four> ?
33                            true
34                        : false
35                    : false
36                : false
37            : false
38        )
39        : false
40);
41
42type __a = IsUUID<'12345678-1234-1234-15123456789ABC'>; // false
43type __b = IsUUID<'12345678-1234-1234-123456789ABC'>; // true
44type __c = IsUUID<'123456**-1234-1234-123456789ABC'>; // false
45type __d = IsUUID<'12345678-1&24-1234-123456789ABC'>; // false

# Template Type 小结

私以为的 Template Type 的几个关键点 :
  1. 静态的 Template Pattern 产生了变化的 string 字面量
  2. 利用 KVP 结构来做 Parser / 遍历事半功倍
  3. 类型系统完全可以当成单独的语言系统来看待, 可以被形式化验证




回到顶部