探究 TypeScript 类型体操里的 Equal 和 IsAny

探究 TypeScript 类型体操里的 Equal 和 IsAny

为了提升自己对 TypeScript 类型的了解和运用,我最近开始玩 Type Challenges

第一个示例里有句话挺有意思:"我们使用了一些神奇的技巧让 TypeScript 通过自身的类型系统来实现自动判题"。

我当时就有点好奇,这个「自动判题」到底是怎么做的?

所以我去看了仓库里的 utils/index.d.ts,发现里面确实有点东西。

尤其是两个类型,一个是 Equal,一个是 IsAny

先看源码

它们都在 utils/index.d.ts 里,相关实现如下:

ts 复制代码
export type Equal<X, Y> =
  (<T>() => T extends X ? 1 : 2) extends
  (<T>() => T extends Y ? 1 : 2) ? true : false

// https://stackoverflow.com/questions/49927523/disallow-call-with-any/49928360#49928360
export type IsAny<T> = 0 extends (1 & T) ? true : false

第一次看到这两段代码时,我的真实反应是,这代码看着确实很神奇。

Equal 为什么写这么复杂

Equal 用来判断两个类型是否相等,我们先从最直观的写法开始:

ts 复制代码
type SimpleEqual<X, Y> =
  X extends Y
    ? Y extends X
      ? true
      : false
    : false

这个版本的逻辑很简单,如果 X 能赋值给 Y,并且 Y 也能赋值给 X,那就认为它们相等。

对于普通类型,它看起来确实没什么问题。

ts 复制代码
type A = SimpleEqual<string, string>
// true

type B = SimpleEqual<string, number>
// false

一碰到联合类型,结果就不太对了。

ts 复制代码
type C = SimpleEqual<"a" | "b", "a">
// boolean

这不是我们想要的结果。"a" | "b" 明显不等于 "a",这里更符合直觉的结果应该是 false

结果变成 boolean,原因在于 X extends Y 里的 X 是裸类型参数。条件类型遇到这种写法时,会对联合类型做分发,"a" 算一次,"b" 算一次,最后结果就被合成了 true | false,也就是 boolean

这也是很多类型体操一开始绕不开的一个坑,类型代码看着像普通判断,实际执行时却有自己的分发规则。

所以我们经常会看到另一个版本,用元组把类型参数包起来:

ts 复制代码
type TupleEqual<X, Y> =
  [X] extends [Y]
    ? [Y] extends [X]
      ? true
      : false
    : false

这样可以避免联合类型分发(但是还有其它问题,下文会讲到)。

type-challenges 里的 Equal 做了什么

但 type-challenges 里的 Equal 还是没有用这个写法,而是用了一个泛型函数:

ts 复制代码
export type Equal<X, Y> =
  (<T>() => T extends X ? 1 : 2) extends
  (<T>() => T extends Y ? 1 : 2) ? true : false

这里最关键的点,不是函数真的会被调用,类型层面没有运行时调用。

它是在构造两个函数类型,然后让 TypeScript 去比较这两个函数类型是否兼容。每个函数里面都有一个泛型参数 T,返回值取决于 T extends X 还是 T extends Y

你可以把它理解成,Equal 不直接问 X 能不能赋值给 Y,而是换了一个问法:

对于任意一个 TT 面对 XY 时,得到的判断结果是不是一致?

如果一致,那 XY 就非常接近「同一个类型」。

这比简单的双向 extends 更严格,尤其是在处理 any 这种特殊类型时更稳妥。

ts 复制代码
type E1 = Equal<string, string>
// true

type E2 = Equal<"a" | "b", "a">
// false

type E3 = Equal<any, string>
// false

type E4 = Equal<any, any>
// true

我觉得理解 Equal 时,不用一上来就陷进类型兼容性的细节里,先抓住它的目的,它是在用泛型函数制造一个更严格的比较环境,避免普通条件类型在联合类型和 any 面前失真。

说到 any,就绕不开第二个类型 IsAny

源码是这样:

ts 复制代码
// https://stackoverflow.com/questions/49927523/disallow-call-with-any/49928360#49928360
export type IsAny<T> = 0 extends (1 & T) ? true : false

其实 type-challenges 源码注释里已经标了实现出处,是 Stack Overflow 上 jcalz 的回答:Disallow call with any,原贴里解释得非常清楚。

关键点是 any 会污染交叉类型

这行代码看起来很玄,但关键点其实只有一个,any 在交叉类型里有很强的污染能力。

正常情况下,1 & T 会变成一个很窄的交叉类型。

ts 复制代码
type A = 1 & string
// never

type B = 1 & unknown
// 1

type C = 1 & never
// never

所以这几种情况下,0 extends (1 & T) 都不会成立。

ts 复制代码
type A = IsAny<string>
// false

type B = IsAny<unknown>
// false

type C = IsAny<never>
// false

但如果 Tany,结果就不一样了。

ts 复制代码
type D = 1 & any
// any

1 & any 会被污染成 any,而 0 extends any 会成立。

所以:

ts 复制代码
type D = IsAny<any>
// true

这一招厉害的地方在于,它不是正面判断 T 是不是 any。因为 any 本身太特殊了,你越是直接拿 extends 去测它,它越容易给你一个模棱两可的结果。

它选择从一个副作用下手,观察 T 放进 1 & T 之后,会不会把整个交叉类型污染成 any

如果会,那基本就能判定它是 any

最后

这俩工具类型放在 Type Challenges 的 utils 里,是为了服务类型结果校验。可读完实现后,我觉得更有价值的是,它们提醒我们,TypeScript 的类型判断不是简单的「能不能赋值」这么粗糙。联合类型会分发,any 会污染,unknownnever 又各有边界。

写业务类型时,我们未必需要天天手写这种工具,但读懂它们之后,再看很多复杂工具类型,心里会更有底,因为你会意识到,类型体操里很多奇怪写法并不是为了奇怪而奇怪。它们通常是在绕开 TypeScript 某个很具体的特性,或者利用某个很具体的特性。

Equal 利用泛型函数,让比较更严格。

IsAny 利用 any 对交叉类型的污染,反向识别 any

读源码有时候就是这样,看起来像谜语,拆开以后发现,它其实是在认真处理那些我们平时容易忽略的边界。

相关推荐
GuWenyue1 小时前
10分钟搞定TodoList实战!从0搭建Bun+TS的RESTful接口服务
前端·typescript·bun
rising start3 小时前
九、vue3 组件通信:全场景详解
前端·vue.js·typescript
向上的车轮4 小时前
TypeORM 1.0 正式发布:新一代 Node.js ORM 框架全面解析
typescript·node.js·typeorm
vim怎么退出5 小时前
Dive into React——高级特性
前端·react.js·源码阅读
ZengLiangYi1 天前
测试策略:单元测试 + 集成测试怎么写
javascript·typescript·node.js
颂love1 天前
Vue的两大生态以及组件通信
前端·javascript·vue.js·typescript
vim怎么退出1 天前
Dive into React——事件系统
前端·react.js·源码阅读
hhb_6181 天前
TypeScript泛型实战:企业级请求封装全解析
javascript·ubuntu·typescript
crack_comet1 天前
修复 Claude Code TypeScript LSP 在 Windows 上启动失败的问题
windows·typescript·里氏替换原则