探究 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,而是换了一个问法:
对于任意一个 T,T 面对 X 和 Y 时,得到的判断结果是不是一致?
如果一致,那 X 和 Y 就非常接近「同一个类型」。
这比简单的双向 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
但如果 T 是 any,结果就不一样了。
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 会污染,unknown 和 never 又各有边界。
写业务类型时,我们未必需要天天手写这种工具,但读懂它们之后,再看很多复杂工具类型,心里会更有底,因为你会意识到,类型体操里很多奇怪写法并不是为了奇怪而奇怪。它们通常是在绕开 TypeScript 某个很具体的特性,或者利用某个很具体的特性。
Equal 利用泛型函数,让比较更严格。
IsAny 利用 any 对交叉类型的污染,反向识别 any。
读源码有时候就是这样,看起来像谜语,拆开以后发现,它其实是在认真处理那些我们平时容易忽略的边界。