
大家好我是一名前端工程师刚从荷兰的一个小岛 Texel 度假回来。️今天我们来聊一个看起来完全安全的 Type Guard类型守卫const isUser (value: unknown): value is User { // runtime checks... };看起来不错对吧TypeScript 知道当isUser(value)返回true时value就是一个User。但这里有一个小问题。TypeScript 信任这个承诺但它并不会证明你的运行时检查真的验证了User里的每一个字段。就这样Type Guard 会慢慢地偏离它声称要保护的类型。让我们来看看。️ Type Guard 可能在没有任何报错的情况下过时假设我们从这样一个类型开始type User { id: string; name: string; };再写一个手写的 Type Guardconst isUser (value: unknown): value is User { if (typeof value ! object || value null) { return false; } const candidate value as Recordstring, unknown; return typeof candidate.id string typeof candidate.name string; };到目前为止一切都能对应上。后来我们更新了Usertype User { id: string; name: string; role: admin | member; };但是忘了更新 Guard。const isUser (value: unknown): value is User { if (typeof value ! object || value null) { return false; } const candidate value as Recordstring, unknown; return typeof candidate.id string typeof candidate.name string; };这里没有对role做检查但代码依然能编译通过。 为什么 TypeScript 发现不了因为(value: unknown): value is User是一个用户自定义类型谓词user-defined type predicate。你是在告诉 TypeScript相信我。如果这个函数返回true那么值就是一个User。TypeScript 可以检查你声明的谓词类型本身是否合理但它无法从一般意义上证明任意的运行时逻辑真的验证了该类型的每一个部分。所以下面这种写法也是可能的const isUser (_value: unknown): _value is User true;一个糟糕透顶的 Guard却是一段完全合法的 TypeScript。返回类型是我们自己写下的契约而不是从函数体里证明出来的结论。 这变成了一个维护难题真正烦人的不是写一次 Guard而是要在时间推移中让这两样东西保持同步TypeScript 类型 ↕ 运行时验证类型会变化属性可能被新增、删除、重命名、改为可选、改成另一种类型。每次发生这种情况我们都需要记住某个地方的运行时 Guard 可能也需要同步更新。如果忘了编译器也不一定会告诉我们。这种必须靠记忆来预防的 bug恰恰是我最不想依赖记忆去解决的那一类。✅ 如果类型本身就能成为契约呢这就是我为什么在is-kit里加入了typedStruct的原因之一。假设应用里已经存在这个类型type User { id: string; name: string; age?: number; };我们可以基于这个已有类型来构建 Guardimport { isNumber, isString, optionalKey, typedStruct } from is-kit; const isUser typedStructUser()({ id: isString, name: isString, age: optionalKey(isNumber), });现在字段映射与User之间有了类型层面的关联。在运行时它依然执行普通的对象验证但在编译时TypeScript 可以检查我们声明的 Guard 是否与它们应当遵循的对象类型相匹配。 现在漂移变得可见了让我们再次给类型加一个字段type User { id: string; name: string; role: admin | member; age?: number; };但忘记更新 GuardtypedStructUser()({ id: isString, name: isString, age: optionalKey(isNumber), // TypeScript error: // role is missing });很好——运行时 bug 变成了编译期问题。如果 Guard 使用了不兼容的字段类型也会触发同样的效果import { isNumber, isString, oneOfValues, optionalKey, typedStruct, } from is-kit; typedStructUser()({ id: isString, name: isNumber, // TypeScript error: // User[name] is string role: oneOfValues(admin, member), age: optionalKey(isNumber), });这正是我最关心的部分。typedStruct并不能消除维护工作它只是让被遗忘的维护变得可见。 可选Optional与可空Nullable是两回事对象 Guard 另一个容易混淆的地方是可选属性。考虑type User { id: string; nickname?: string | null; };这里有两个独立的概念nickname可能不存在nickname可能存在但值为null这是两种不同的运行时契约。用typedStruct来表达import { isString, nullable, optionalKey, typedStruct } from is-kit; const isUser typedStructUser()({ id: isString, nickname: optionalKey(nullable(isString)), });于是isUser({ id: user-1 }); // true isUser({ id: user-1, nickname: null, }); // true isUser({ id: user-1, nickname: Neko, }); // true isUser({ id: user-1, nickname: 42, }); // false我喜欢把这两个决定分开、明确地表达optionalKey(...)→ 属性可能缺失nullable(...)→ 值可能为 null它们乍看起来很像但描述的是完全不同的东西。 嵌套类型也不需要重复定义现在想象一个更大的类型type Account { readonly id: string; readonly profile: { readonly displayName: string; readonly bio: string | null; } | null; readonly tags: readonly string[]; };我们可以手动把profile的形状复制到另一个类型里但那会再制造一个可能漂移的东西。相反我们可以直接引用已有的类型import { arrayOf, isString, nullable, typedStruct } from is-kit; const isProfile typedStructNonNullableAccount[profile]()({ displayName: isString, bio: nullable(isString), }); const isAccount typedStructAccount()({ id: isString, profile: nullable(isProfile), tags: arrayOf(isString), });这是我喜欢的方式在编译期复用已有类型在运行期组合小型 Guard。应用类型仍然是 Guard 想要遵循的那个源。 额外的运行时属性怎么办还有一个值得区分的点。这是两个不同的问题我的 Guard 定义是否与 TypeScript 类型匹配运行时对象是否允许包含额外的属性默认情况下对象依然可以带额外的键。如果你希望运行时对象的形状也是封闭的可以启用精确模式exact modeimport { isString, typedStruct } from is-kit; type User { id: string; name: string; }; const isExactUser typedStructUser()( { id: isString, name: isString, }, { exact: true, }, );于是isExactUser({ id: user-1, name: Ada, }); // true isExactUser({ id: user-1, name: Ada, debug: true, }); // false是否拒绝额外属性是一个运行时策略层面的决定。它不应该与让 Guard 定义与 TypeScript 类型保持同步混为一谈。⚖️ 谁应该是事实来源Source of Truth我并不认为每个项目都存在唯一正确的验证风格。关键问题是现在到底是谁在定义这组数据的形状手写谓词Manual predicateconst isSomething (value: unknown): value is Something { // custom logic };当验证逻辑比较特殊、不主要是结构性的时它很合适。Guard 优先Guard-firstconst isUser struct({ id: isString, name: isString, });当 Guard 本身应当定义最终类型时它很有用。类型优先Type-firstconst isUser typedStructUser()({ id: isString, name: isString, });当User已经存在、且运行时 Guard 需要与它保持一致时很有用。Schema 优先Schema-first当你需要结构化校验错误、类型转换coercion、transform、默认值、生成产物等能力时schema 库或代码生成可能是更好的事实来源。它们解决的是不同的问题。我并不认为每个布尔验证都需要变成一套 schema。 typedStruct 不做什么这里有一些重要的边界。typedStruct不会从 TypeScript 类型生成运行时验证——类型在运行时会被擦除所以你仍然需要声明要执行的 Guard。它也不会证明每个自定义谓词都是诚实的转换coerce值返回丰富的结构化校验错误取代 schema 优先的工作流在其字符串键对象契约中验证数值或 symbol 属性它的定位刻意比这些更小。目标很简单在你已有的对象类型与你选择执行的运行时 Guard之间架一座有类型约束的桥。 最重要的部分这篇文章的核心其实不是typedStruct而是这个类型谓词是一个承诺而不是一个证明。(value): value is User并不代表 TypeScript 检查了你的实现、证明了每一个User字段都被验证过。是我们自己许下了这个承诺。所以当 TypeScript 类型是事实来源时我认为让运行时 Guard 在结构上依赖这个类型而不是依赖我们记住未来每一次改动是很有价值的。这就是我想让typedStruct帮忙做的事。如果你的 Guard 定义类型 → 用 guard-first 的方式。如果已有的 TypeScript 类型应当定义契约 → 把 Guard 连接到这个类型。如果你需要丰富的解析、转换、coercion 或详细的错误 → 这才是 schema 开始发挥价值的地方。如果你也在做 TypeScript 或前端开发遇到环境相关问题可以参考本站的浏览器卡顿排查和更多实用教程。相关阅读如何让 iPhone Safari 在后台打开新标签页如何在 Safari 浏览器中允许或拦截弹窗Windows 网络连接相关设置教程