使用区分联合时,有没有办法将一个对象的类型限制为与另一个对象的类型相同?

Is there a way to restrict the type of an object to the same of another one when using discriminated unions?

我有两个类:

interface Num {
    v: number, 
    type: 'num'
}

interface Sum {
    left: Expr, 
    right: Expr,
    type: 'sum'
}

还有一个类型:

type Expr = Sum | Num;

我想检查两个 Expr 是否相等,但使用独立函数(与面向对象的方法相反)。

我想写这样的东西:

function isEqual(e1: Expr, e2: Expr): boolean {
    if (e1.type !== e2.type) return false;
    switch(e1.type) {
        case 'num': return e1.v === e2.v;
        case 'sum': return isEqual(e1.left, e2.left) && isEqual(e1.right, e2.right);
    }
}

这是不可能的,因为在开关内部函数不知道 e2 的类型。

我需要这样写:

function isEqual(e1: Expr, e2: Expr): boolean {
    if (e1.type !== e2.type) return false;
    switch(e1.type) {
        case 'num': return e1.v === (e2 as Num).v;
        case 'sum': return isEqual(e1.left, (e2 as Sum).left) && isEqual(e1.right, (e2 as Sum).right);
    }
}

太乱了。

有没有办法让编译器推断 isEqual 函数中 e1e2 的类型(在初始检查之后)必须相等,或者至少有没有任何显式转换的公共超类型?

缩小受歧视的联合类型仍然很愚蠢。但是,是的,有一种方法,只是您可能不会喜欢它。

function isEqual(e1: Expr, e2: Expr): boolean {
    if (e1.type !== e2.type) return false;
    const t1 = e1.type
    const t2 = e2.type
    switch(e1.type) {
        case 'num': return e1.type === e2.type && e1.v === e2.v;
        case 'sum': return e1.type === e2.type && isEqual(e1.left, e2.left) && isEqual(e1.right, e2.right);
    }
}

为了让它稍微好一点,使用自定义类型保护

const s = function isSameType<T>(e1: T, e2: any): e2 is typeof e1 {
    return true
}

// then replace `e1.type === e2.type` with `s(e1, e2)`
// ...
    case 'num': return s(e1, e2) && e1.v === e2.v;

我简单解释一下背后的原因。

这里有 2 个条件,对人类来说很明显,两者都可以在运行时进行类型缩小。

/* PSEUDO CODE */
e1.type !== e2.type => (typeof e1 == typeof e2)
case 'num' => (typeof e1 == Num)

// we as human can tell:
(typeof e1 == typeof e2) & (typeof e1 == Num)
=> typeof e2 == Num

// but TS still see `typeof e2 == Expr`
// not smart enough to make connection btw 2 inferences.

但是如果你把case放在===之前,TS可以告诉

/* PSEUDO CODE */
case 'num' => (typeof e1 == Num)
e1.type === e2.type => (typeof e1 == typeof e2)
(typeof e1 == Num) & (typeof e1 == typeof e2)
=> typeof e2 == Num

是的,类型断言可能是用最少的努力得到你想要的东西的唯一方法,抱歉。

我只想在这个问题上补充一点,问题似乎是编译器不认为一对可区分的联合本身是可区分的联合。从逻辑上讲,判别式现在是两种联合类型的判别式的乘积(即所有判别式对的集合;在您的情况下为 {["num","num"], ["num","sum"], ["sum","num"], and ["sum","sum"]}), 但编译器不会进行这样的分析。我认为编译器检查此类内容的成本太高,因为检测何时像这样协同使用受歧视的联合会花费一些时间,而这通常会被浪费。

您可以通过从一对可区分的联合中合成您自己的可区分的联合来强制编译器执行此操作,您可以在其中给出从判别乘积的每个元素到新判别式的显式映射。这是跳圈代码:

type DiscriminatedUnionPair<
  U, K extends keyof U,
  V, L extends keyof V,
  M extends Record<keyof M, readonly [U[K], V[L]]>> = {
    [P in keyof M]: {
      kind: P,
      first: Extract<U, Record<K, M[P][0]>>,
      second: Extract<V, Record<L, M[P][1]>>
    }
  }[keyof M];

function discriminatedUnionPair<U, K extends keyof U,
  V, L extends keyof V,
  M extends Record<keyof M, readonly [U[K], V[L]]>
>(
  firstUnion: U,
  firstDiscriminantKey: K,
  secondUnion: V,
  secondDiscriminantKey: L,
  disciminantMapping: M
): DiscriminatedUnionPair<U, K, V, L, M> {
  const p = (Object.keys(disciminantMapping) as Array<keyof M>).find(p =>
    disciminantMapping[p][0] === firstUnion[firstDiscriminantKey] &&
    disciminantMapping[p][1] === secondUnion[secondDiscriminantKey]
  );
  if (typeof p === "undefined") throw new Error("WHAT, MAPPING IS BAD");
  return { kind: p, first: firstUnion, second: secondUnion } as any;
}

该代码可能会被存放在某处的图书馆中。然后你可以像这样实现 isEqual() 而不会出错:

function isEqual(e1: Expr, e2: Expr): boolean {
  const m = {
    sumsum: ["sum", "sum"],
    sumnum: ["sum", "num"],
    numsum: ["num", "sum"],
    numnum: ["num", "num"]
  } as const;
  const e = discriminatedUnionPair(e1, "type", e2, "type", m)
  switch (e.kind) {
    case 'numsum': return false;
    case 'sumnum': return false;
    case 'numnum': return e.first.v === e.second.v;
    case 'sumsum': return isEqual(e.first.left, e.second.left) &&
      isEqual(e.first.right, e.second.right);
  }
}

重要的一行是对 discriminatedUnionPair(e1, "type", e2, "type", m) 的调用,它产生一个类型为 {kind: 'sumsum', first: Sum, second: Sum} | {kind: 'sumnum', first: Sum, second: Num} | {kind: 'numsum', first: Num, second: Sum} | {kind: 'numnum', first: Num, second: Num} 的新元素,TypeScript 确实 将其识别为您期望的方式,并且代码按照您想要的方式运行:

const n1: Num = { type: "num", v: 1 }
const n2: Num = { type: "num", v: 2 }
const s1: Sum = { type: "sum", left: n1, right: n2 }
const s2: Sum = { type: "sum", left: n2, right: n1 }

console.log(isEqual(n1, n1)); // true
console.log(isEqual(n1, n2)); // false
console.log(isEqual(n1, s1)); // false
console.log(isEqual(s2, n2)); // false
console.log(isEqual(s1, s2)); // false
console.log(isEqual(s2, s2)); // true

当然,这可能不值得,因为您强制在运行时存在一个新的有区别的联合,它只在编译时具有相关性。而且您正在手动枚举所有可能的判别对,这可能会很快变旧。但我只是想展示什么是可能的。

‍‍

希望对您有所帮助;祝你好运!