Discriminated Unions

At the end of the previous lesson, you told potions from weapons by their properties, with "heal" in item. It works, but it's fragile: TypeScript doesn't notice a typo such as "heel" in item, and when another kind of item gets a heal property one day, the check quietly stops working.

The usual way to model “one of several kinds of things” in TypeScript is to give every object a property that says what it is. It's usually called kind, and in each type of the union, it has a different literal type:

type Potion = { kind: "potion"; name: string; heal: number };
type Weapon = { kind: "weapon"; name: string; damage: number };
type Item = Potion | Weapon;

const inventory: Item[] = [
    { kind: "potion", name: "Healing Potion", heal: 20 },
    { kind: "weapon", name: "Silver Sword", damage: 12 },
];

Such a union is called a discriminated union: all its types share the property kind, and its value tells them apart. The property is also called the tag. Checking it narrows the type:

function describeItem(item: Item): string {
    if (item.kind === "potion") {
        return `${item.name} (heals ${item.heal})`;
    } else {
        return `${item.name} (damage ${item.damage})`;
    }
}

for (const item of inventory) {
    console.log(describeItem(item));
}

This prints:

Healing Potion (heals 20)
Silver Sword (damage 12)

Inside the if, the item is a Potion, because only potions have kind: "potion"; in the else branch, it's a Weapon. The properties that every type in the union has, here kind and name, you can read without a check.

When the program runs, the tag is an ordinary property: console.log(inventory[1]) prints { kind: "weapon", name: "Silver Sword", damage: 12 }. For TypeScript, though, it's the key to the whole object. TypeScript knows which properties go with each kind, so it checks your data too:

type Potion = { kind: "potion"; name: string; heal: number };
type Weapon = { kind: "weapon"; name: string; damage: number };
type Item = Potion | Weapon;

const loot: Item[] = [
    { kind: "potion", name: "Elixir", damage: 5 },
    { kind: "poton", name: "Tonic", heal: 5 },
];
main.ts(6,39): error TS2353: Object literal may only specify known properties, and 'damage' does not exist in type 'Potion'.
main.ts(7,7): error TS2820: Type '"poton"' is not assignable to type '"potion" | "weapon"'. Did you mean '"potion"'?

A potion with damage is caught, because a Potion has no damage, and so is a misspelled kind, with a suggestion. A misspelled comparison, such as item.kind === "poton", gives the TS2367 error you know: the types have no overlap.

Typical mistakes:

  • An object without its tag. const tonic: Item = { name: "Tonic", heal: 5 }; gives error TS2322: Type '{ name: string; heal: number; }' is not assignable to type 'Item'., and the second line of the message explains: Property 'kind' is missing in type '{ name: string; heal: number; }' but required in type 'Potion'.
  • kind: string in the types instead of a literal type. Then kind may be any text, it doesn't tell the types apart, and checking it narrows nothing: after if (item.kind === "potion"), reading item.heal is still an error.

Instructions

A quest reward is either gold or an item. The list in the editor already uses both kinds, but TypeScript doesn't know ItemReward yet. Define it for the item rewards in the list: kind: "item", a name (a text) and a count (a number).

Write a function describeReward with a parameter reward of the type Reward. For gold, it returns, for example, 50 gold; for an item, the count and the name, for example 3x Healing Potion. Print every reward from the list. The program then prints:

50 gold
1x Silver Sword
3x Healing Potion
30 gold

Add up the gold from all the gold rewards, and print the total after the rewards:

50 gold
1x Silver Sword
3x Healing Potion
30 gold
Gold in total: 80

Start programming for free

By signing up, you agree to the Terms of Service and Privacy Policy.

Or sign up with:

1/8