网站搜索

TypeScript - 为什么我的条件类型没有按我预期工作?


class A {}
class B {}
class C {}
class D {}

//! How/Why does that even work?
type MyObjectType<T extends (keyof Hash), Hash> = (keyof Hash) extends T ? null : Hash[T];

class Example {
  public static ClassesByName = { A: A, B: B, C: C, D: D };

  public stringToClass<
    Hash extends typeof Example.ClassesByName,
    T extends keyof Hash,
  >(className: T): MyObjectType<T, Hash> {
    throw new Error("unimplemented");
  }
}

const instance = new Example();
const klassA = instance.stringToClass("A"); //<- Expected: klassA is "typeof A" as wanted
const klassB = instance.stringToClass("B"); //<- Expected: klassB is "typeof B" as wanted
const klassZ = instance.stringToClass("Z"); //<- Expected: klassZ is null, wrong parameter triggers ts error as wanted

操场

看起来我的条件类型的工作方式与文档相反

TypeScript 关于条件类型的文档说明:

SomeType extends OtherType ? TrueType : FalseType;

因此我的逻辑是:如果 Hash 中的键包含键 (T),则返回 Hash[T],否则返回 null;所以我写了:

type MyObjectType<T extends (keyof Hash), Hash> = (keyof Hash) extends T ? Hash[T] : null;

但那不起作用,而将 TrueType : FalseType 反过来却可以完美运行:

type MyObjectType<T extends (keyof Hash), Hash> = (keyof Hash) extends T ? null : Hash[T];

为什么它会反过来工作?

答案#1

听起来你对 extends 关键字在泛型约束和条件类型中的含义感到困惑。如果你有两种类型 X 和 Y,那么说 X extends Y 意味着如果你有一个类型为 X 的值,你可以将其赋值给类型为 Y 的变量,但反过来不一定成立。

所以如果你想说你有一个类型为 K 的值,它应该可以赋值给类型为 keyof H 的变量。比如,假设 H 是 {a: string, b: number, c: boolean}。然后你有一个类型为 K 的值,它应该可以赋值给联合类型变量 "a" | "b" | "c"。这样 K 可以是 "a",或者 "b",或者 "a" | "b",但如果它是类似 "z" 的值,就不行。在这种情况下,你需要写 K extends keyof H。如果你说相反的,即 keyof H extends K,你就是在说 K 至少需要接受 所有 的 "a" | "b" | "c",但那并不是你想要做的。

如果你仍然觉得 extends 应该以相反的方式工作,那可能意味着你在考虑对象类型而不是键类型。对象在它们的键类型上是 逆变 的,这意味着如果 A extends B,那么 keyof B extends keyof A。有关类似问题,请参阅类型的联合和交叉。


所以你这种类型

type MyObjectType<K extends keyof H, H> = (keyof H) extends K ? null : H[K];

只是偶然“起作用”。由于 K extends keyof H,(keyof H) extends K 唯一可能的情况是 K 和 keyof H 完全相同。这意味着 K 必须是 H 中所有键的并集。这通常不会发生;如果你用一个有效的键调用 stringToClass(),那么 K 将只是那个键,检查 (keyof H) extends K 将 失败,你会得到 H[K]。另一方面,如果有人用一个无效的东西调用 stringToClass(),K 的推断将失败,它将回退到完整约束 keyof H,检查 (keyof H) extends K 将 成功,你将得到 null。这种偶然的工作方式是一系列巧合,我们应该重新正确地编写你的类型。


所以我们希望 K extends keyof H 而不是相反。这意味着你可以直接写 type MyObjectType<K extends keyof H, H>=H[K],根本不需要条件类型。毕竟,你已经约束了 K extends keyof H。如果你必须进行检查,因为你想对错误输入返回 null,那么你应该放宽 K 的约束以允许这样的输入,然后以正确的方式进行条件检查:

type MyObjectType<K, H> = K extends keyof H ? H[K] : null

然后 stringToClass() 看起来像

class Example {
  public static ClassesByName = { A: A, B: B, C: C, D: D };

  public stringToClass<
    H extends typeof Example.ClassesByName,
    K,
  >(className: K & keyof H): MyObjectType<K, H> {
    throw new Error("unimplemented");
  }
}

在这里我再次从 K 删除了约束(这样 MyObjectType<K, H> 实际上就允许它了),并且为了在你用错误的 classname 输入调用它时保留错误,我将 K 与 keyof H 取了交集。


让我们来试试:

const instance = new Example();
const klassA = instance.stringToClass("A"); // okay
// const klassA: typeof A
const klassB = instance.stringToClass("B"); // okay
// const klassB: typeof B
const klassZ = instance.stringToClass("Z"); // error!
// ---------------------------------> ~~~
// const klassZ: null

看起来不错。


代码的 Playground 链接