Hoi hoi!
好耶!is-kit 刚刚达成了 100 个 GitHub Stars⭐ 🎉🎉🎉
达到 50 Stars 时,我写过一篇文章,介绍我们如何在服务超过 10 万用户的生产级 TypeScript 应用中使用它。👇
我本可以再讲一遍同样的故事,但那就太无聊了。😹
从那以后发生了不少事情。
库增加了新的 API,一些现有 API 变得更加严格,我开始在自己的其他 OSS 项目中使用它,甚至有一个真实使用场景暴露了 Effect 中的 TypeScript 类型推断边界问题。
所以这次不再重复介绍生产环境中的应用,而是想聊聊:
- 从 50 到 100 Stars 有什么变化
- 期间增加的几个重要 API
- 我如何在最初的生产应用之外使用
is-kit - 一个涉及
Effect.catchIf的真实 bug 报告 - 现在 100 Stars 对我意味着什么
开始吧!🔧
⭐ 首先,50 Stars 意味着什么?
达到 50 Stars 时,我最想说明的是,is-kit 并不是一个仅供演示的玩具项目。
它已经被用于真实的生产环境。🚀
应用中存在许多重复的 runtime 检查,比如:
typeof value === "string";
typeof value === "number";
value === null || value === undefined;
还有一些更复杂的自定义 type guards,用来检查 HTTP 错误、字面量联合类型、数组、API 数据和普通对象等。
有意思的地方不在于 is-kit 让这些检查变短。
而在于它为这些检查提供了一种可复用的形式。
小型 guards 可以组合起来使用。
应用自己的 helpers 可以位于库和业务代码之间,而最终结果仍然能配合正常的 TypeScript 控制流工作。
这一点没有改变。
但从 50 Stars 到现在,库本身已经前进了不少。👀
🔑 属性细化成为正式支持的使用场景
我经常遇到这样一种需求:
我已经有一个不错的 type guard。 我只是想把它应用到某个属性上。
例如:
declare const input: unknown;
也许我已经知道怎么验证字符串:
import { isString } from "is-kit";
但现在我想表达:
“这个对象有一个
name属性,而且它的值是字符串。”
听起来简单,但实际上有几种不同情况。
必需属性不等于可选属性。 数组索引不等于对象属性。
而且,一个已被确认有值的可选属性,应该和一个仍然可选的属性产生不同的类型收窄结果。
因此,is-kit 增加了三个属性细化 helpers:
refineKeyrefineDefinedKeyrefineIndex
思路很简单:
拿一个已经可信的 type guard,把它应用到对象属性或数组元素上。
例如:
import { isString, refineKey } from "is-kit";
const hasStringName = refineKey("name", isString);
这样,已有的字符串 guard 就能在更深层复用,而不必在另一个自定义 predicate 里重新实现一遍。
这个 API 很小,但我很喜欢,因为它延续了 is-kit 的设计目标。
不去构建一个巨型 validator。
而是构建能够在普通 TypeScript 代码中复用的小型检查。
🔍 然后,discriminatedUnion 变得更加强大
自 50 Stars 那篇文章以来,最大的新增功能可能就是 v1.15.0 引入的穷尽式 discriminated union API。
考虑一个常见的 TypeScript 类型:
type Result = { ok: true; value: string } | { ok: false; error: string };
在 runtime 验证它其实不难。
检查 ok,再验证相应字段即可。
但还有另一个问题:
TypeScript 能否验证 validator 本身覆盖了整个 union?
这就是 discriminatedUnion 的用武之地。
import { discriminatedUnion, isString, oneOfValues, typedStruct } from "is-kit";
type Result = { ok: true; value: string } | { ok: false; error: string };
const resultUnion = discriminatedUnion<Result>();
export const isResult = resultUnion("ok", [true, false], {
true: typedStruct<Extract<Result, { ok: true }>>()({
ok: oneOfValues(true),
value: isString,
}),
false: typedStruct<Extract<Result, { ok: false }>>()({
ok: oneOfValues(false),
error: isString,
}),
});
它在 runtime 的行为很实用。
但更有意思的是 compile-time contract。
这个 builder 会检查:
- 缺失的 discriminant 分支
- 多余分支
- 与分支不兼容的 guards
- 不完整的 discriminant tuples
因此,如果 union 发生变化,guard 就不能悄悄保持不完整。
这一点很重要,因为手写 type guard 最容易出问题的原因之一就是 drift(类型和检查逻辑逐渐脱节)。
TypeScript 类型在演进。 runtime guard 却没跟上。 而通常编译器并不知道两者已经不再一致。
discriminatedUnion 正是希望让这类错误更难发生。
💪 库本身也变得更加严格
50 到 100 Stars 之间的改进并不全是新功能。
很多工作是为了让已有行为更加精确。
例如,近期版本修复了:
recordOf对字面量 key 的类型收窄- 原生
Error的 brand 检查 - iterator 被覆写的数组 guards
struct的 exact validation- 使用 symbol key 的 struct schemas
- 跨 realm 的 struct schemas
package 层面的测试也增加了。
现在 is-kit 会从 ESM 和 CommonJS TypeScript consumers 的角度检查发布后的 package,还覆盖了 Deno / JSR smoke tests。
这些东西放在代码片段里可能没那么吸引眼球。
但我认为这对小型库十分重要。
真正的考验,是它们在各种奇怪的 JavaScript 和 TypeScript 环境中能否正确工作。
🤖 我开始在其他 OSS 项目里使用 is-kit
达到 50 Stars 后,我也越来越自然地在自己维护的其他 OSS 项目中使用 is-kit。
比如,在 changelog-bot 的 changelog 工具代码里,我会使用 define 来让小型 predicates 保持可复用、可组合,而不是把每次 runtime 检查都写成孤立的 helper。
一个简化的例子如下 👇
import { define, oneOfValues } from "is-kit";
const CHANGELOG_SECTIONS = ["Added", "Changed", "Fixed", "Removed"] as const;
const isChangelogSection = define<(typeof CHANGELOG_SECTIONS)[number]>(
oneOfValues(CHANGELOG_SECTIONS),
);
具体代码并不是重点。
对我而言真正的变化是,我已经不再想着:
“这里应该使用我自己的库。”
而是自然而然地拿起需要的 guard。
当一个库不再让维护者觉得自己只是在演示它,而是能直接解决问题时,它才开始真正变得有用。
🚀 一个 is-kit Guard 意外遇到了 Effect 的类型问题
这是最近发生的事情中最有意思的一件。
一位 is-kit 用户把 type guard 和 Effect 的 catchIf 一起使用。
概念上,这个 refinement 大致是这样的 👇
(error: unknown): error is SomeError => ...
对于 is-kit 来说,这完全正常。
实际上,unknown 恰恰是这个库最初设计时考虑的起点。
但在 Effect.catchIf 中,这个 refinement 可能意外影响泛型类型推断。
error channel 可能被拓宽为 unknown。
这个报告最终促成了下面这个 Effect PR 👏 而且已经合并!🚀
相关类型原本大致是:
refinement: Predicate.Refinement<E, EB>;
修复后改成:
refinement: Predicate.Refinement<NoInfer<E>, EB>;
关键在于:refinement 的输入类型不应该重新定义已经由 Effect 确定的错误类型。
该 PR 还为下面这样的情况增加了类型测试:
const result = Effect.catchIf(
mixedEffect,
(e: unknown): e is AiError => e instanceof AiError,
() => Effect.succeed("ok"),
);
修复之前,refinement 的输入可能参与类型推断,从而把 error channel 拓宽。
使用 NoInfer 后,原本的错误类型仍然是唯一可信的依据。
同样的模式也在 Channel 和 Stream 中得到了修复。
关于这个问题,还有一篇 Bluesky 帖子:
我特别喜欢这个 bug,因为如果只测试 is-kit 本身,我大概永远不会发现这种交互问题。
不是 is-kit “弄坏了 Effect”,也不是 Effect 做了什么荒谬的事情。
它是一个微妙的 TypeScript 类型推断交互:一边是泛型 API,另一边是输入类型刻意写得很宽泛的 refinement。
这正是不同库开始在真实代码里相遇时才会出现的 edge case。
👍 这正是我想收到的反馈
is-kit 达到 50 Stars 时,我写过:
“有人觉得这个想法有用。”
光是这样就已经让我很开心了。
100 Stars 依然不意味着这个库有多庞大。
Star 不是 benchmark。
也不是正确性的证明。
更不代表所有 TypeScript 项目都应该安装 is-kit。
但有些东西确实变了。
人们不再只是给 repository 点个 Star。
他们开始:
- 真正使用这个库
- 提出问题
- 发现 edge cases
- 报告 bugs
- 与其他验证方案进行比较
- 把 guards 用在其他 TypeScript 库中
- 让我重新考虑那些自认为已经完成的 API
这些远比数字本身更有价值。💖
💬 这个项目也越来越容易解释了
从 50 到 100 Stars,还有另一个变化。
我越来越清楚 is-kit 不是什么。
它不是想取代所有 schema libraries。 它不是想把每个 runtime value 都变成 schema-first workflow。 它也不是想隐藏 TypeScript 正常的控制流。
核心问题仍然非常简单:
“这个
unknown值能安全地进入这个分支吗?”
有时 schema library 才是正确的选择。
例如,当我需要:
- 丰富的验证错误信息
- Transformations
- 表单验证
- 跨系统共享的 contract
我可能会选择 Zod、Valibot、ArkType 或其他工具。
但有时我已经在写普通 TypeScript 代码,只需要一个可复用的 narrowing function。
这就是我希望 is-kit 发挥作用的地方。
if (isUser(value)) {
value.id;
}
if (isHttpClientError(error)) {
// Handle HTTP client errors
}
items.filter(isString);
result.catch(isHttpClientError);
const isMaybeNumber = nullish(isNumber);
小小的 runtime 决策。 普通的函数。 普通的 TypeScript。
🐱 100 Stars 对我意味着什么
和那些最大的 TypeScript OSS 项目相比,100 Stars 依然不多。
没关系。
对我而言,重要的是项目正以一些与 Star 数量完全无关的方式变得更加真实。
自从达到 50 Stars:
- 增加了新 API
- 现有 API 变得更加严格
- 修复了 runtime edge cases
- 改进了 package 兼容性测试
- 我开始自然地在其他 OSS 中使用
is-kit - 外部实际使用带来了我自己无法创造的反馈
- 一个基于
unknown的 refinement 甚至帮助暴露了 Effect 的推断边界问题
最后这一点大概是我最喜欢的。
因为我的目标从来不是写一个只在自己 README 里看起来很漂亮的库!!!
真正的目标是:让可复用的 runtime narrowing 自然地融入真实的 TypeScript 代码。
而现在,我开始看到它在自己的 repository 之外发生了。
感谢每一位点 Star、尝试、反馈、提问、创建 issue,或者只是读过代码的人。🙏
我还有很多想改进的地方。
不过,100 Stars 的感觉还是很棒。😸⭐
如果小巧、可组合的 type guards 适合你的 TypeScript 风格,欢迎试试看!
感谢阅读!🤲