网站搜索

typescript - 我如何让我的函数专门针对联合类型标签?


我有联合类型Item和ItemSlot,以及一个映射类型,它将Item映射到Slot。

type WeaponSlot = "melee" | "ranged";

type ArmorSlot = "helmet" | "gloves" | "boots";

type Weapon = {
  type: "weapon";
  slot: WeaponSlot;
  damage: number;
};

type Armor = {
  type: "armor";
  slot: ArmorSlot;
  armorValue: number;
};

type Item = Weapon | Armor;

type ItemSlot = WeaponSlot | ArmorSlot;

// type ItemSlots = {
//     melee: Weapon;
//     ranged: Weapon;
//     helmet: Armor;
//     gloves: Armor;
//     boots: Armor;
// }
type ItemSlots = {
  [O in Item as O["slot"]]: O;
};

物品和物品槽之间有明确的关系,这在类型映射中得到了体现。但我无法弄清楚如何利用这种关系,从而编写通用函数,既能获取一个Item,又能以类型安全的方式将其映射到正确的ItemSlot。

这些有效

// manual way
function putItem(item: Item, slots: ItemSlots) {
  if (item.slot === "boots") {
    slots["boots"] = item;
  } else if (item.slot === "melee") {
    slots["melee"] = item;
  }
}


// can do generic when slot is passed from outside
function putItem2<S extends ItemSlot>(
  item: ItemSlots[S],
  slots: ItemSlots,
  slot: S,
) {
  slots[slot] = item;
}

但是我不想要手动选项。并且槽可能不会像下面的例子那样从外部传入

// a function that takes an item and puts it in its corresponding slot
function putItem3(item: Item, slots: ItemSlots) {
  slots[item.slot] = item;
}

// or a function that takes an item list and slot as an input and
// filters items to get an item suitable for the slot
function putItem4(items: Item[], slot: ItemSlot, slots: ItemSlots) {
  // just return the first item that suits to slot
  const item = items.filter((item): item is Something => item.slot === slot)[0];

  slots[item.slot] = item;
}

我需要以某种方式提示该物品有一个插槽,可以指向其中一个正确的插槽。我需要让Item对Slot使用泛型吗?这与联合类型如何配合使用?或者是否有更好的解决方案可能不需要使用更多的泛型?

这似乎不是一个独特的问题。如果你能链接一些在实际中以通用方式处理联合类型的例子,我也将不胜感激。

答案#1

你遇到的问题我称之为“相关联合类型”,如 microsoft/TypeScript#30581 所讨论的。有时候人们会写出这样的代码:一个不变的联合类型值被多次使用,如果你记住该值的类型在所有出现中必须是相同的联合成员,那么这是显然安全的。例如,如果 x 的类型是 number[] | string[],那么 x.push(x[0]) 是安全的(假设 x 非空),因为 x 两次都是 number[] 或者两次都是 string[]。但是编译器不会这样分析代码,因为它并不追踪值的身份,只关注类型。它会把它当作你写了 x1.push(x2[0]) 来处理,其中 x1 和 x2 的类型都是 number[] | string[]。而这显然是不安全的。

这就是你在 putItem() 代码中遇到的问题。slots[item.slot] 和 item 必须是相同类型的,但这只有在你跟踪 item 的 身份 而不是它的类型时才能发现。


处理相关联合的推荐方法是在 microsoft/TypeScript#47109 中描述的重构,其中你从联合类型转向受联合约束的泛型,特别是涉及一个“基础”接口以及对该接口和该接口上的映射类型的泛型索引。目标是使两个相关表达式被视为具有可明显等价的泛型类型。

如你的示例所示,它可能看起来像这样。首先是基础接口:

type ItemMap = { [I in Item as I["type"]]: I }
/* type ItemMap = {
    weapon: Weapon;
    armor: Armor;
} */

然后,ItemSlots 类型就变成了以下形式的泛型类型:

type ItemSlots<K extends keyof ItemMap> = Record<ItemMap[K]["slot"], ItemMap[K]>;

如果你愿意,你可以保留原来的 ItemSlots,但重点是 ItemSlots<"weapon"> 被视为 Weapon 对应的 ItemSlots 部分,而 ItemSlots<"armor"> 则是 Armor 对应的部分。

然后 putItem() 可以用以下方式实现泛型化:

function putItem3<K extends keyof ItemMap>(item: ItemMap[K], slots: ItemSlots<K>) {
  const slot: ItemMap[K]['slot'] = item.slot
  slots[slot] = item;
}

请注意,对于 microsoft/TypeScript#33181,有一点变通方法,因为当你用特定键(如 "slot")索引泛型(如 ItemMap[K])时,编译器会将其扩展为非泛型。因此我需要注释 slot 的类型为 ItemMap[K]['slot'],因为这是 ItemSlots<K> 的键。

你的 filter() 版本同样是通用的:

function putItem4<K extends keyof ItemMap>(
  items: Item[], slot: ItemMap[K]["slot"], slots: ItemSlots<K>
) {
  const item = items.filter((item): item is ItemMap[K] => item.slot === slot)[0];
  slots[slot] = item;
}

这样做可行,并且至少在一定程度上是类型安全的,尤其是相比仅使用类型断言来说。如果编译器能够通过在联合类型上分配其分析或跟踪身份来始终“看到”安全性,那就好了,但目前 microsoft/TypeScript#47109 是我们对于这类问题所拥有的最佳方法。

代码的 Playground 链接