本文进阶篇:用 TS 实现一个类型完备的事件通讯器居然有两种方式
我们先假设存在这么一个用于描述特定业务错误的类,就叫它 SomeError 吧,它拥有:type 和 data 两个属性。
于是当你抛出该异常的时候,你是这么写的:
1 | new SomeError('someType1', { someTypeProp1: 1 }) |
就在你要将它 throw 出去的前 1 秒,类型敏感的你突然对着 someType1
微微一笑,tsser 如何能容忍魔法字符串的存在?于是你:
1 | enum SomeErrorTypes { |
1 | new SomeError(SomeErrorTypes.SomeType1, { someTypeProp1: 1 }) |
你刚想露出满意的微笑,类型敏感的你又突然对着 { someTypeProp1: 1 }
虎躯一震!万一有人在里面瞎写可咋办?不行,我得再给 SomeError 整一个泛型来约束约束:
1 | class SomeError<T> { |
1 | interface SomeErrorType1Data { |
1 | new SomeError<SomeErrorType1Data>(SomeErrorTypes.SomeType1, { |
到了这一步,一个类型敏感且自觉超过了 tsser 平均水准的你不禁开始思考……我接下来该对着啥再来个啥呢。。。
有了!于是开始对着 SomeErrorType1Data
。。。邪魅一笑?心道:一旦别人不引入你的类型约束,那么所有的类型约束都是纸老虎;反过来说能主动意识到某个类可能会提供泛型约束且能够从一堆代码里专门去找到你的类型约束的人大概率也不需要约束(他需要的其实只是代码提示。
现在现实成了使用 SomeError
这个类,开发者需要知道:
- 使用
SomeErrorTypes
作为type
- 使用
SomeErrorType1Data
及将来可能有的 SomeErrorType2Data、SomeErrorType3Data… 作为data
的类型约束
那么问题就来了,这种设计对开发者的心智负担相比魔法字符串又强在哪里?
痛定思痛后,你重新理了一下思路:魔法字符串为什么让开发者排斥?原因有二:
- 它很可能会写错,因为没有代码提示。所以一般需要开发者去源码里找到之前用过它的地方复制粘贴
- 它如果是个可变的值,开发者需要从整个源码里查找一遍后才能确定它一共有多少个值
所以枚举类型就成了以上问题的很好的解决方案之一。本质上枚举类型是将若干个魔法字符串包装成了一个。是的,开发者依然需要记住枚举类型的名称,好在枚举名称本身在大多数 ts 友好的编辑器里也可以拥有代码输入提示,且之后其包装的魔法字符串都可以通过代码提示直接引用到。
没错,你又突然悟到了解决开发者心智负担的一个通用方法论:将松散的变成收敛的,用引用来替代输入。
接下来就好办了,类似的解法,为了不让开发者去记住每一个 SomeErrorTypeXData,你可以像封装 SomeErrorTypes
那样再给 SomeError 的泛型提供一个主类型入口,更进一步地,你可以将其索引名称和 SomeErrorTypes
里的值一一对应,这样可以又可以让开发者少记一些引用值了,且引用起来更符合直觉:
1 | interface SomeErrorData { |
1 | new SomeError<SomeErrorData[SomeErrorTypes.SomeType1]>( |
到此结束了么?不!你除了类型敏感,你还是一个对重复代码深恶痛绝的代码洁癖患者。你望着上面的 SomeErrorTypes.SomeType1
坐立不安。
有办法的,一定有办法的,在程序里面,只要是重复的,就一定有办法优化的。
上面的改造,开发者依然需要记住 SomeErrorData
和 SomeErrorTypes
这两个主类型, 而且还是两个有映射关系的类型,那么。。。终于要点题了:我都传了 type 了,能不能给我自动推导出 data 类型啊?
能! 代码如下:
1 | // 首先泛型变成了 SomeErrorTypes |
于是开发者终于可以体面地去 new SomeError 了!(附在线 Demo)
1 | new SomeError(SomeErrorTypes.SomeType1, { someTypeProp1: 1 }) |
原本文章到了这里就可以结束了,但是你控制不住你寄几,类型体操会上瘾。你脑海里突然又灵光一闪,对着 SomeErrorTypes
。。。嫣然一笑?
虽然你干掉了调用者对类型的重复引用,但是你的类型声明里依然存在重复引用:之后你每在 SomeErrorTypes
增加一个 type,就需要同步地给 SomeErrorData
增加一个同名的 data type。所以这里可不可以将它们。。。合并?
1 | interface SomeErrorTypeData { |
1 | // 泛型变成了 SomeErrorTypeData 的 key 值 |
于是开发者 new SomeError 就可以这样(附在线 Demo):
1 | new SomeError('someType1', { someTypeProp1: 1 }) |
到了这里,你会神奇地发现上面这一行代码竟然又回到了文章开始的地方,难道这就是传说中的大道至简,看山还是山?看似优化了个寂寞,实则同样的调用方式却拥有了完备的类型提示。
且本文里的这个场景其实在真实开发中并不少见,比如你有没有好奇过某事件库为啥可以根据我传入的不同事件名,callback 不同的参数?
1 | someEvent.on('ok', (params) => { |
(当然,上面这个例子还可以对 someEvent.on 用函数重载来实现,感兴趣的可以试试)
另,枚举只是解决魔法字符串的手段之一,你当然还可以通过联合类型、keyof 等方式做输入约束,去掉枚举开发者还可以少记住一个主类型名称,new 完扩号一下编辑器就可以立刻给到你所有的 type
字符串提示;对于本文的场景,SomeError 接受方如果是 try catch 也可以在 instanceof 后自动推导出类型相关属性,比如:
1 | try { |
不过多声明一个枚举的好处也是有的,比如它可以方便地被之后非 SomeError 的数据去引用以及享受枚举类型的相关特性(比如上面的 e.type === 'someType1'
不被其他开发者认为是在使用魔法字符串)。
然而话又说回来了,不单独声明枚举就不能方便地将某些「私有的」魔法字符串导出来了么?比如我如何拿到所有的 SomeErrorTypes?(假设 SomeErrorTypeData 是私有类型,你没办法直接对着它 keyof)可以这样:
1 | type SomeErrorTypes = ConstructorParameters<typeof SomeError>[0] |
还可以:
1 | type SomeErrorTypes = any extends SomeError<infer P> ? P : never |
还可以。。。类型体操爱好者自行发挥吧!对体操感兴趣的也推荐下我的另一篇文章:
所以上面这段看似跑题的类型体操的意义是什么?难道就是为了装 x 么?(是,还有给上面那篇文章打广告。。。
开个玩笑,其实我是想来个总结的:作为 ts 库的使用者,意识到哪些地方可能提供了泛型且需要你导入并显式地传入泛型来获得更好的类型提示要比你掌握了多少个工具泛型更重要;作为 ts 库的提供者,意识到哪些地方可以通过类型体操来帮助开发者减少心智负担要比你掌握了多少个类型体操更重要。
说到这里又不得不再给另一篇文章也打个广告了,ts 大法好,好在哪里?其实就是为了代码提示,仅此而已: