Hoi hoi! 👋
我是 @nyaomaru,一名前端工程师,刚从荷兰的小岛 Texel 短暂旅行回来。😸🏝️
今天想聊聊一个看起来非常安全的 Type Guard。
ts
const isUser = (value: unknown): value is User => {
// runtime checks...
};
看起来没问题,对吧?
TypeScript 知道:当 isUser(value) 返回 true 时,这个值就是 User。
但这里有一个小问题:
TypeScript 会相信这个承诺。
它不会证明你的 runtime check 是否真的验证了 User 的每一个字段。
这也正是 Type Guard 可能逐渐与它声称要保护的类型发生偏移的地方。
我们来看一下。👀

🕳️ Type Guard 可能在没有任何报错的情况下过期
假设一开始我们有这个类型:
ts
type User = {
id: string;
name: string;
};
然后手写一个 Type Guard:
ts
const isUser = (value: unknown): value is User => {
if (typeof value !== "object" || value === null) {
return false;
}
const candidate = value as Record<string, unknown>;
return (
typeof candidate.id === "string" &&
typeof candidate.name === "string"
);
};
到这里,一切都还是一致的。
后来,我们修改了 User:
ts
type User = {
id: string;
name: string;
role: "admin" | "member";
};
但是忘了更新 guard。
ts
const isUser = (value: unknown): value is User => {
if (typeof value !== "object" || value === null) {
return false;
}
const candidate = value as Record<string, unknown>;
return (
typeof candidate.id === "string" &&
typeof candidate.name === "string"
);
};
这里完全没有检查 role。
但代码依然可以正常通过编译。😿
🧠 为什么 TypeScript 没有发现这个问题?
因为:
ts
(value: unknown): value is User
这是一个 user-defined type predicate(用户自定义类型谓词)。
你其实是在告诉 TypeScript:
相信我。如果这个函数返回
true,这个值就是User。
TypeScript 可以检查你声明出来的 predicate type 本身是否合理。
但它通常无法证明一段任意的 runtime logic,是否真的验证了这个类型的所有部分。
所以这样的代码也是合法的:
ts
const isUser = (_value: unknown): _value is User => true;
这是一个非常糟糕的 guard。
但在 TypeScript 看来完全合法。😹
这个 return type 是我们自己写下的 contract(契约),不是由函数实现自动证明出来的结果。
🔄 这会变成一个维护问题
真正麻烦的地方并不是第一次写这个 guard。
而是以后始终要让这两者保持同步:
text
TypeScript type
↕
Runtime validation
类型会发生变化。
属性可能会:
- 新增
- 删除
- 重命名
- 变成 optional
- 改成另一个类型
而每次发生这种变化时,我们都必须记得:代码库中的某个 runtime guard 可能也需要同步更新。
如果忘了,compiler 不一定会提醒你。
我真的不想依靠"记得更新"来避免这种 bug。
✅ 如果类型本身可以成为 contract 呢?
这也是我给 is-kit 增加 typedStruct 的原因之一。
假设应用中的类型已经存在:
ts
type User = {
id: string;
name: string;
age?: number;
};
我们可以基于这个现有类型来构建 guard:
ts
import {
isNumber,
isString,
optionalKey,
typedStruct,
} from "is-kit";
const isUser = typedStruct<User>()({
id: isString,
name: isString,
age: optionalKey(isNumber),
});
现在,这个 field map 在类型层面与 User 建立了关系。
在 runtime,它依然只是执行普通的对象校验。
但在 compile time,TypeScript 可以检查:你声明的这些 guards 是否与它们应该跟随的 object type 一致。
💥 现在 drift 会变得可见
我们再给类型增加一个字段:
ts
type User = {
id: string;
name: string;
role: "admin" | "member";
age?: number;
};
但忘了更新 guard:
ts
typedStruct<User>()({
id: isString,
name: isString,
age: optionalKey(isNumber),
// TypeScript error:
// role is missing
});
很好。
原本可能出现在 runtime 的 bug,现在变成了 compile-time 问题。
如果某个字段使用了不兼容的 guard,也一样:
ts
import {
isNumber,
isString,
oneOfValues,
optionalKey,
typedStruct,
} from "is-kit";
typedStruct<User>()({
id: isString,
name: isNumber,
// TypeScript error:
// User["name"] is string
role: oneOfValues("admin", "member"),
age: optionalKey(isNumber),
});
这是我最在意的部分。
typedStruct 并不会消除维护工作。
它只是让"忘记维护"这件事变得可见。
🧩 Optional 和 Nullable 是两回事
对象 guard 里另一个很容易混淆的地方,是 optional property。
例如:
ts
type User = {
id: string;
nickname?: string | null;
};
这里其实有两个完全不同的概念:
text
nickname 可以不存在
以及:
text
nickname 可以存在,但值为 null
它们是不同的 runtime contract。
使用 typedStruct:
ts
import {
isString,
nullable,
optionalKey,
typedStruct,
} from "is-kit";
const isUser = typedStruct<User>()({
id: isString,
nickname: optionalKey(nullable(isString)),
});
那么:
ts
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
它们乍看很像,但表达的是不同的事情。
🌳 嵌套类型也不需要重复声明
再来看一个更大的类型:
ts
type Account = {
readonly id: string;
readonly profile: {
readonly displayName: string;
readonly bio: string | null;
} | null;
readonly tags: readonly string[];
};
我们当然可以手动把 profile 的 shape 再复制一份成另一个类型。
但这样又多了一份可能发生 drift 的东西。
更好的方式是直接引用已经存在的类型:
ts
import {
arrayOf,
isString,
nullable,
typedStruct,
} from "is-kit";
const isProfile = typedStruct<
NonNullable<Account["profile"]>
>()({
displayName: isString,
bio: nullable(isString),
});
const isAccount = typedStruct<Account>()({
id: isString,
profile: nullable(isProfile),
tags: arrayOf(isString),
});
这是我比较喜欢的模型:
在 compile time 复用已有类型,在 runtime 组合小型 guards。
应用中的 type 仍然是 guard 应该跟随的 source of truth。
🔒 那 runtime 中的额外属性呢?
这里还有一个值得区分的问题。
下面其实是两个不同的问题:
- 我的 guard definition 是否与 TypeScript type 一致?
- runtime object 是否允许包含额外属性?
默认情况下,对象仍然可以包含额外的 key。
如果你希望 runtime 中的对象 shape 也必须完全封闭,可以开启 exact mode:
ts
import { isString, typedStruct } from "is-kit";
type User = {
id: string;
name: string;
};
const isExactUser = typedStruct<User>()(
{
id: isString,
name: isString,
},
{
exact: true,
},
);
于是:
ts
isExactUser({
id: "user-1",
name: "Ada",
});
// true
isExactUser({
id: "user-1",
name: "Ada",
debug: true,
});
// false
是否拒绝额外属性,是一个 runtime policy 的选择。
它不应该和"让 guard definition 与 TypeScript type 保持同步"混为一谈。
⚖️ 到底谁应该是 Source of Truth?
我不认为所有项目都只有一种正确的 validation 风格。
真正重要的问题是:
这份数据的 shape,到底本来应该由谁负责?
Manual predicate
ts
const isSomething = (
value: unknown,
): value is Something => {
// custom logic
};
适合验证逻辑比较特殊,或者主要问题并不是结构校验的场景。
Guard-first
ts
const isUser = struct({
id: isString,
name: isString,
});
适合让 guard 本身来定义最终类型的情况。
Type-first
ts
const isUser = typedStruct<User>()({
id: isString,
name: isString,
});
适合 User 已经存在,而 runtime guard 需要持续与它保持一致的情况。
Schema-first
当你需要下面这些能力时,schema library 或 code generation 可能更适合作为 source of truth:
- 结构化 validation error
- coercion
- transform
- default value
- generated artifact
这些方案解决的是不同的问题。
我并不认为每一个返回 boolean 的 validation check 都应该升级成一套 schema。😸
🚫 typedStruct 不会做什么
这里也有一些很重要的边界。
typedStruct 不会从 TypeScript type 自动生成 runtime validation。
TypeScript type 在 runtime 会被擦除,所以你依然需要明确声明真正要执行的 guards。
它也不会:
- 证明每一个 custom predicate 都是诚实的
- 对值做 coercion
- 返回详细、结构化的 validation error
- 替代 schema-first workflow
- 在它基于 string key 的 object contract 中验证 numeric key 或
symbolproperty
它刻意保持得更小。
它的目标只是建立一座类型安全的桥:
你已经拥有的 object type
与:
你真正选择在 runtime 执行的 guards
之间的桥。
🎯 最重要的一点
这篇文章真正想表达的重点,其实并不是 typedStruct。
而是这一点:
Type predicate 是一个承诺,不是一个证明。
下面这段:
ts
(value): value is User
并不意味着 TypeScript 已经检查了你的实现,并证明 User 的所有字段都被验证过。
这个承诺是我们自己写下的。
所以,当一个现有的 TypeScript type 本身就是 source of truth 时,我更喜欢让 runtime guard 在结构上依赖这个 type,而不是依赖开发者记住未来的每一次修改。
这就是我希望 typedStruct 帮忙解决的问题。😸
如果 guard 本身定义类型,那么使用 guard-first。
如果现有的 TypeScript type 应该定义 contract,就让 guard 与这个 type 建立联系。
如果你需要更丰富的 parsing、transform、coercion 或详细错误信息,那么 schema 的重量就开始值得了。
我也在 is-kit 文档中写了一篇更完整的指南:
让 Type Guard 与 TypeScript 类型保持同步 | is-kit
如果你喜欢小型、可复用的 TypeScript Type Guard,is-kit 也是开源的:
如果这篇文章对你有帮助,也欢迎给 GitHub 项目点一个 ⭐!
感谢阅读!🙌