翻 Effect 的 PR 列表翻到 #9252 的时候,我差点划过去。改动小得不像话:给 Refinement 的第一个类型参数套了个 NoInfer。起因是有用户把一个输入为 unknown 的类型守卫传进 catchIf,错误通道就被拓宽成 unknown 了。
顺着这条线往回挖,才摸到 nyaomaru/is-kit。
一个只干一件事的 TypeScript 类型守卫库。不碰 schema,不做校验框架,就给你一批能反复插进去组合的收窄函数。作者 10 月 21 日发了篇东西,说这库到 100 星了。
100 星搁今天真不算什么信号。我点进去看,纯粹是因为上面那个 PR。
先说我最在意的地方。refineKey、refineDefinedKey、refineIndex 这三个函数,是把已有的小守卫往上提一层,作用到对象的某个属性、或者数组的某个元素上。听着平平无奇,但它们分开了必选属性和可选属性,也分开了数组索引和对象键。这个区分才是要命的地方。可选属性在类型层跟必选属性压根是两回事,手写的组合辅助函数最爱在这里偷懒一刀切,一碰上 foo?: string 就开始出岔子。
作者的想法很直白:让小的守卫能复用,不必每次堆出一个大校验器。这个我认。手写守卫真正的问题从来不是写不出来——写个 (x): x is Foo 谁不会——是写完之后没人会再回来看它。半年后类型改了,守卫还杵在原地,编译器一声不吭。
v1.15.0 里的 discriminatedUnion 就是冲这个来的。它对可辨识联合类型做穷尽式运行时校验,同时把漏分支、多分支、守卫不兼容、可辨识字段元组不完整这几种情况在编译期标出来。翻成人话:你给 switch 加了个 case,守卫没跟上,编译器先叫。类型漂移最典型的破口就在这儿,能在这个位置补一刀,比再堆十个工具函数实在。
还有一串被收紧或修掉的行为:recordOf 的字面量键收窄、原生 Error 的品牌检查、覆盖迭代器的数组守卫、符号键、跨 realm 的结构 schema。这一串我挺喜欢。它一看就不是提前拍脑袋列的功能清单,是被真实 issue 一条一条逼出来的。跨 realm 这种坑,没人报 bug 的话谁想得到。
配套还有包级测试,跑 ESM 和 CJS 两类消费者装上发布包之后的行为,另有 Deno / JSR 的冒烟测试。这说明点事:作者被发布包层面的问题坑过,而且坑得够疼,才会专门补这层。
回到开头那个事故。一位叫 Evangeline Rome 的用户在 Bluesky 发帖,说自己拿 isObject、refineDefinedKey、equalsKey 拼了个可复用的操作符,结果把流式错误通道搞出了 unknown。作者自己也承认,这类问题光测 is-kit 本身发现不了:单测全绿,一旦被组合进别人的类型推断链条里,就可能出岔子。修法落在 Effect 那边,nikelborm 提的 PR,10 月 15 日合了,Channel 和 Stream 两个 API 一起覆盖,补了类型测试,TypeScript 5.9.3 和 6.0.3 下都过。
我写这个不是要说这库有多厉害。我想说的是:一个库能悄悄钻进别人的类型推断里,还能让对方为它改一行签名——这比 100 星这个数字有说服力得多。一个库的功能列表我半小时就能看完,但它有没有被别人真的拿去拼过、拼出问题之后有没有人修,这个骗不了人。
反过来说,这也是这类组件型库的命:平时没人看得见它,出事了才知道它在那儿。
上手这块没什么可说的,零依赖,装起来就是一行 npm 的事。但定位得先搞清楚,不然会失望得很彻底。作者自己明说了,这库不打算取代 Zod、Valibot、ArkType,也不做把运行时值转成 schema 那条路。它管的是另一层:值已经进到你函数里了,schema 那关早过了,你现在只是想在控制流中间把类型收窄一下,并且把这段收窄逻辑留下来复用。这两件事不是一回事,拿它替 schema 库是搞错场景,反过来也一样。
不适合谁也得讲明白:校验点就那么一两处、手写两行守卫就完事的,为了这个装个库、再学一套组合子,纯属给自己加一层抽象。这库的价值随复用次数涨,用得少就是负收益。
顺带一句,作者在评论区说,那篇公告的封面 GIF 做得比文章本身还费时间。我信。
候选池里还压着一个同方向的,迭代节奏没这个稳,再观察一阵。这个我就不继续等了,先装进日常项目,试着把那几个反复手写的守卫换掉,跑一阵再说。能不能打,你自己拉下来用两天最准。