I have union types Item and ItemSlot and a mapped type that maps Items to Slots.
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;
};
There is a clear relation between items and item slots, and this is reflected in the type mapping. But I couldn't figure out how to make use of this relationship so I can make generic functions that can get an Item and map it to the correct ItemSlot in a type safe way.
These work
// 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;
}
But I don't want the manual option. And slot may not get passed from outside like examples below
// 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;
}
Somehow I need to hint that the item has a slot that would point to one of the correct slots. Do I need to make Item generic on Slot? How would that work together with union types? Or is there a better solution maybe that doesn't require the use of more generics?
This doesn't seem like a unique problem. I would also appreciate if you can link some examples in the wild where union types are handled in a generic way.
You're running into an issue I've called "correlated union types", as discussed in microsoft/TypeScript#30581. Sometimes people write code where an unchanging value of a union type is used multiple times, and which is clearly safe if you keep in mind that the value's type must be the same union member in all occurrences. For example, if
xis of typenumber[] | string[], thenx.push(x[0])is safe (assumingxis non-empty) becausexis either anumber[]both times or it's astring[]both times. But the compiler does not analyze the code this way because it's not tracking the identity of the value, just the type. It treats it the same as if you wrotex1.push(x2[0])where bothx1andx2are of typenumber[] | string[]. And that would clearly be unsafe.That's the problem you're having with your
putItem()code.slots[item.slot]anditemmust be of the same type, but that's only discoverable if you tract the identity ofitemand not its type.The recommended approach to correlated unions is a refactoring described at microsoft/TypeScript#47109, where you move away from unions and toward generics that are constrained to unions, and specifically involving a "base" interface and generic indexes into that interface and into mapped types over that interface. The goal is to make it so that the two correlated expressions are seen as being of a visibly-equivalent generic type.
With your example as shown, it could look like this. First the base interface:
And then the
ItemSlotstype becomes a generic type of the following form:You could keep around your original
ItemSlotsis you want, but the point of this is thatItemSlots<"weapon">is seen is theWeapon-appropriate piece ofItemSlots, andItemSlotes<"armor">is theArmor-appropriate piece.Then
putItem()can be made generic in the following way:Note that there's a bit of a workaround to microsoft/TypeScript#33181 since when you index into a generic (like
ItemMap[K]) with a specific key (like"slot") the compiler widens things to a non-generic. So I needed to annotate thatslotis of typeItemMap[K]['slot'], since that is the key ofItemSlots<K>.Your
filter()version is similarly generic:So that works and is at least vaguely type safe, especially compared to just using type assertions. It would be nice if the compiler could always "see" the safety by distributing its analysis over unions, or tracking identities, but for now microsoft/TypeScript#47109 is the best approach we have for this sort of issue.
Playground link to code