你的 Type Guard 可能会悄悄地与 TypeScript 类型发生偏移 🔧

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 中的额外属性呢?

这里还有一个值得区分的问题。

下面其实是两个不同的问题:

  1. 我的 guard definition 是否与 TypeScript type 一致?
  2. 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 或 symbol property

它刻意保持得更小。

它的目标只是建立一座类型安全的桥:

你已经拥有的 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 也是开源的:

nyaomaru/is-kit on GitHub

如果这篇文章对你有帮助,也欢迎给 GitHub 项目点一个 ⭐!

感谢阅读!🙌

相关推荐
lizhongxuan1 小时前
Agent Sandbox 怎么选
后端
用户544693392561 小时前
为什么有管理员权限,Token正确,访问却是403
后端
明月_清风1 小时前
数据平台到底是什么?一篇文章搞懂数据平台开发
大数据·后端·数据分析
心之语歌1 小时前
DeerFlow Docker Desktop 部署教程
后端
美好世界1 小时前
Codex 源码导读:第二部分——一次 Turn 的完整生命周期
后端
据说幸运很容易1 小时前
接口测试框架重构:YAML 用例驱动 + 分层设计
后端·架构
海岳云舟1 小时前
SpringCloudGateway 动态转发后端服务
后端
我的div丢了肿么办1 小时前
自定义类型和类型别名以及实例化结构体的5种方式
后端·go
小满zs1 小时前
Go语言第十三章(互斥锁,读写锁)
后端·go