论TS体操中类型相等的具体方法、原因、逻辑与内部实现(TS源码相关)

开头

前几天学习类型体操,对于类型相等:

type Equal<X, Y, A = X, B = never> = (<T>() => T extends X ? 1 : 2) extends <
  T
>() => T extends Y ? 1 : 2
  ? A
  : B;
复制代码

代码是看了,懂了一点,但又好像没有全懂,有点感到困惑不解:作者说这样可以检测是否属性相等,但属性相等可以X extends YY extends X,为什么需要<T>() => T extends X ? 1 : 2呢?
顺着这个疑问,我找到了该语句的最早出处,大概是所和检查器有关,但这粗略的解释也依旧让人困惑,打破砂锅问到底,顺着这个issue,我找到了stackoverflow上的一篇文章对其进行了详细的解释,原文虽然较为详尽,但英文依旧让人阅读与理解困难,于是我对其用自己的语言和理解进行了翻译,其中晦涩难懂的部分添加了示例辅助讲解,过时的部分添加了更新,分享给大家。


问题分解

首先,上来咱就给他咣咣先添加几对括号,简单点,让逻辑简单点,别把人绕迷糊了:

export type Equals<X, Y> =
    (
      (<T>() => (T extends /*第一个*/ X ? 1 : 2)) extends /*第二个*/
      (<Z>() => (Z extends /*第三个*/ Y ? 1 : 2)) 
    )
        ? true 
        : false;
复制代码

很好,现在咱们可以把问题逐步分析,大致可以分为两部分:第1、3个extends和第2个extends

第2个extends

咱们先来看第二个extends 关键字:当你把 类型 填入 该泛型时,本质上是在解决一个问题:——一个类型为 type <T>() => (T extends X ? 1 : 2) 的变量能否赋值给一个类型为 type (<Z>() => (Z extends Y ? 1 : 2)) 的变量?

用代码来换个方式说的话,可能更为简单易懂:

declare let x: <T>() => (T extends /*第一个*/ X ? 1 : 2) // 为X填入一个实际的类型
declare let y: <Z>() => (Z extends /*第三个*/ Y ? 1 : 2) // 为Y填入一个实际的类型
y = x // 你寻思下会发生错误不
复制代码

Github的Comment说到:

条件类型的赋值规则要求在extend关键字后的类型和检查器所定义的类型相同

这里咱谈论说的是第一个第三个extend关键字:检查器将仅允许当变量后方名为XY的类型相等的时候,发生X赋值给Y。说太多了脑袋抽抽,咱把XY都填入number试试:

declare let x: <T>() => (T extends number ? 1 : 2)
declare let y: <Z>() => (Z extends number ? 1 : 2)
y = x // 你再寻思下会发生错误不
复制代码

毫无疑问,这俩玩意儿就是一模一样的,会发生错误就见鬼了。咱再换个别的玩意试试,就决定是你了,numberstring

declare let x: <T>() => (T extends number ? 1 : 2)
declare let y: <Z>() => (Z extends string ? 1 : 2)
y = x // 你再再寻思下会发生错误不
复制代码

现在这俩类型肯定不一样啦,所以它会告诉你:莫挨老子!

第1、3个extends

X和Y相等的原因

吼啦,现在解决完第一个问题,我们搞清楚了第二个 extends 关键字是干啥用的,咱们就有精力掉过头来细细研究下,为啥XY必须相等捏?如果他们是一样的,那啊哈,一切很简单啦,一模一样肯定可以赋值啦;其他情况下,思考我所描述的最后一种情况,以 Equals<number, string>为例, 想象此处不会发生错误:

declare let x: <T>() => (T extends number ? 1 : 2)
declare let y: <Z>() => (Z extends string ? 1 : 2)
y = x // 忽略此处的类型检查假设其成立
复制代码

思考下列代码:

declare let x: <T>() => (T extends number ? 1 : 2)
declare let y: <Z>() => (Z extends string ? 1 : 2)

const a = x<string>() // a的类型为2,因为string类型不能被number类型约束
const b = x<number>() // b的类型为1,为number类型可被number类型约束

const c = y<string>() // c的类型是1,因为string类型可被string类型约束
const d = y<number>() // d的类型是2,因为number类型不能被string类型约束

y = x

// 根据y的类型声明, e的类型应为1,但我们将x赋值y,而x在该脚本中应返回2
// 类型为1而变量值为2,显然这里有逻辑错误
const e = y<string>() 
// 同理,根据y的类型定义,此处类型应为2,但现在y为X,此处实际变量值实际为1,变量值1与类型‘2’不符
const f = y<number>()
复制代码

当类型不是numberstring的时候,结果也相近——即使是毫不相干却更复杂的其他类型,比如,对象。让我们试试当X{foo: string, bar: number}Y{foo: string} 结果如何呢?注意下列代码:此处x可赋值给y

declare let x: <T>() => (T extends {foo: string, bar: number} ? 1 : 2)
declare let y: <Z>() => (Z extends {foo: string} ? 1 : 2)

// a为类型2,因为 {foo: string} 不能被 {foo: string, bar: number} 约束
const a = x<{foo: string}>()

// b为类型1
const b = y<{foo: string}>()

y = x
// 根据y的类型声明,c的类型应为2,但我们把x赋予y,而x应返回1,变量值为1而类型为2,逻辑错误
const c = y<{foo: string}>()
复制代码

如果你把类型调个各,尝试X{foo: string}且Y为{foo: string, bar: number},当你使用y<{foo: string}>()这个函数时仍然会发生逻辑错误。不管你如何尝试,它总会有点大病,出些发生逻辑错误的幺蛾子。

T/Z extends X/Y ? 1:2的深层逻辑

更准确的说,如果X类型和Y类型不相同,那么总有些类型,可以被其中一个所约束,却不能被另一个所约束——如果你尝试将这些类型填入泛型之中,必然会得到一坨逻辑错误。实际上,当你尝试赋值xy,编译器将报以下错误:


不能将类型“<T>() => T extends number ? 1 : 2”分配给类型“<Z>() => Z extends string ? 1 : 2”。
  不能将类型“T extends number ? 1 : 2”分配给类型“Z extends string ? 1 : 2”。
    不能将类型“1 | 2”分配给类型“Z extends string ? 1 : 2”。
      不能将类型“1”分配给类型“Z extends string ? 1 : 2”。
复制代码

在本例中,因为不知道TX是否会相同,它所以变量x的类型将包括是X和不是X的情况,强制返回1|2——而类型1|2不能赋值给类型 Z extends Y ? 1 : 2,因为ZT一样,都不知道是否会能被(Z所对应的)Y和(T所对应的)X所约束。

Y所约束。

换个说法可能更加清晰明了:

type test=any extends string?1:2; //test类型为1|2
复制代码

这是因为,一个变量的具体类型只能指定为具体类型之一,不可能为any类型;而any类型包括string和string之外的类型,它既可能是string又可能不是string,所以类型test需要包括这两种情况的返回结果,即为”1|2″。

正是如此,回头看看我们最初的代码:

export type Equals<X, Y> =
    (
      (<T>() => (T extends /*第一个*/ X ? 1 : 2)) extends /*第二个*/
      (<T>() => (T extends /*第三个*/ Y ? 1 : 2)) 
    )
        ? true 
        : false;
复制代码

逻辑就显而易懂了。你可以发现,即使当对比类型是Any或者带有属性readonly的时候,该表达式将依旧成立。


深度探讨:为什么Equals<{x: 1} & {y: 2}, {x: 1, y: 2}>false

说说为什么 Equals<{x: 1} & {y: 2}, {x: 1, y: 2}>false

据我目前所了解来看,这是源于一个实现中的细节(也许它可能不是一个bug,也许一个有意而为之的feature,咱也不知道,咱也不知道咋问)。

理论上来说,它当然应返回true,就像咱们上面所讨论的,Equals 返回 false (理论上)的情况仅发生在XY不同而传入的T仅为其中之一的时候。在本例中,如果你尝试像上面一样声明类型然后赋值 y=x 的时候,会发现提示类型错误。在本例中,类型为 {x: 1} & {y: 2} 的,应该都可以赋值给类型为 {x: 1, y: 2} 的,理论上应该返回true,但实际上却与此相反。

我认为,当判断类型是否相等的时候,typescript的实现使用了摸鱼偷懒的方案。注意,我未曾为typescript贡献源码,也不了解源码——可能我描述的细节不足,但总体来说应该是对的——typescript的实现中处理类型检查的文件为checker.ts,在19555行(仅目前main分支,未来可能变动,请灵活使用Ctrl+F)似乎是 T extends X ? 1 : 2Z extends Y ? 1 : 2 的对比实现,相关内容为:

// 19555行
// 两个条件类型'T1 extends U1 ? X1 : Y1'和'T2 extends U2 ? X2 : Y2' 之中,
// 如果T1和T2其中之一和另一个相关, U1和U2是相同类型, X1和X2相关,Y1和Y2相关
// 那么这两个条件类型相关
let sourceExtends = (source as ConditionalType).extendsType;
//19568行
if (isTypeIdenticalTo(sourceExtends, (target as ConditionalType).extendsType) &&/* ... */) {
  // ...
}
复制代码

备注所说的相关条件之一, 如果U1U2——在我们的案例中为XY——是相同的, 即为我们所尝试检查的,在19568行,你可以观察到extends后面的类型通过使用 isTypeIdenticalTo 方法进行比较,而该方法又是isTypeRelatedTo(source, target, identityRelation) 的返回:

function isTypeRelatedTo(source: Type, target: Type, relation: /* ... */) {
    // ...
    if (source === target) {
        return true;
    }
    if (relation !== identityRelation) {
        // ...
    }
    else {
        if (source.flags !== target.flags) return false;
        if (source.flags & TypeFlags.Singleton) return true;
    }
    // ...
}
复制代码

你可以通过代码看到,首先他们检测是否是确切相同、完全一模一样的类型(本例中的 {x: 1} & {y: 2}{x: 1, y: 2} 未被TS实现考虑),然后对比它们的 flags 。如果你点击此处查看 Type 的定义,你将发现 flags 的类型 TypeFlags 实现在此处,然后你会看到:交集(&)有其自己的 flag 。所以类型 {x: 1} & {y: 2} 拥有交集的flag,而类型 {x: 1, y: 2} 没有,所以typescript认为其不相关,所以 Equals 返回了与理论相违背的 false

结尾与感想

原文到这里就结束了,目前来说,issue中虽然有修补交集判断的方案:

type EqualsWrapped<T> = T extends infer R & {}
    ? {
          [P in keyof R]: R[P]
      }
    : never

export type Equals<A, B> = (<T>() => T extends EqualsWrapped<A> ? 1 : 2) extends <
    T
>() => T extends EqualsWrapped<B> ? 1 : 2
    ? true
    : false
复制代码

但是这种方案仍然有各种不足,比如其对voidnullundefined的判断会有错误,似乎目前来讲并没有非常完备的判断方法。也许并不是不存在,毕竟TS图灵完备,但终究需要我们去仔细探索思考研究,才能得出解决方案。

这不禁让我想起之前阅读源码时,一个val.toString === Object.prototype.toString让我查了很久都没找原因,直到翻到issue中——也许是搜索引擎太弱鸡,但我想很多知识其实没有那么难,阅读思考,都需追根求底才行。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享