TypeScript is a wonderful tool for writing JavaScript that scales. It’s more or less the de facto standard for the web when it comes to large JavaScript projects. As outstanding as it is, there are some tricky pieces for the unaccustomed. One such area is TypeScript discriminated unions.
Specifically, given this code:
interface Cat { weight: number; whiskers: number;
}
interface Dog { weight: number; friendly: boolean;
}
let animal: Dog | Cat;
…many developers are surprised (and maybe even angry) to discover that when they do animal.
, only the weight
property is valid, and not whiskers
or friendly
. By the end of this post, this will make perfect sense.
Before we dive in, let’s do a quick (and necessary) review of structural typing, and how it differs from nominal typing. This will set up our discussion of TypeScript’s discriminated unions nicely.
Structural typing
Table of Contents
The best way to introduce structural typing is to compare it to what it’s not. Most typed languages you’ve probably used are nominally typed. Consider this C# code (Java or C++ would look similar):
class Foo { public int x;
}
class Blah { public int x;
}
Even though Foo
and Blah
are structured exactly the same, they cannot be assigned to one another. The following code:
Blah b = new Foo();
…generates this error:
Cannot implicitly convert type 'Foo' to 'Blah'
The structure of these classes is irrelevant. A variable of type Foo
can only be assigned to instances of the Foo
class (or subclasses thereof).
TypeScript operates the opposite way. TypeScript considers types to be compatible if they have the same structure—hence the name, structural typing. Get it?
So, the following runs without error:
class Foo { x: number = 0;
}
class Blah { x: number = 0;
}
let f: Foo = new Blah();
let b: Blah = new Foo();
Types as sets of matching values
Let’s hammer this home. Given this code:
class Foo { x: number = 0;
} let f: Foo;
f
is a variable holding any object that matches the structure of instances created by the Foo
class which, in this case, means an x
property that represents a number. That means even a plain JavaScript object will be accepted.
let f: Foo;
f = { x: 0
}
Unions
Thanks for sticking with me so far. Let’s get back to the code from the beginning:
interface Cat { weight: number; whiskers: number;
}
interface Dog { weight: number; friendly: boolean;
}
We know that this:
let animal: Dog;
…makes animal
any object that has the same structure as the Dog
interface. So what does the following mean?
let animal: Dog | Cat;
This types animal
as any object that matches the Dog
interface, or any object that matches the Cat
interface.
So why does animal
—as it exists now—only allow us to access the weight
property? To put it simply, it’s because TypeScript does not know which type it is. TypeScript knows that animal
has to be either a Dog
or Cat
, but it could be either (or both at the same time, but let’s keep it simple). We’d likely get runtime errors if we were allowed to access the friendly
property, but the instance wound up being a Cat
instead of a Dog
. Likewise for the whiskers
property if the object wound up being a Dog
.
Type unions are unions of valid values rather than unions of properties. Developers often write something like this:
let animal: Dog | Cat;
…and expect animal
to have the union of Dog
and Cat
properties. But again, that’s a mistake. This specifies animal
as having a value that matches the union of valid Dog
values and valid Cat
values. But TypeScript will only allow you to access properties it knows are there. For now, that means properties on all the types in the union.
Narrowing
Right now, we have this:
let animal: Dog | Cat;
How do we properly treat animal
as a Dog
when it’s a Dog
, and access properties on the Dog
interface, and likewise when it’s a Cat
? For now, we can use the in
operator. This is an old-school JavaScript operator you probably don’t see very often, but it essentially allows us to test if a property is in an object. Like this:
let o = { a: 12 }; "a" in o; // true "x" in o; // false
It turns out TypeScript is deeply integrated with the in
operator. Let’s see how:
let animal: Dog | Cat = {} as any; if ("friendly" in animal) { console.log(animal.friendly);
} else { console.log(animal.whiskers);
}
This code produces no errors. When inside the if
block, TypeScript knows there’s a friendly
property, and therefore casts animal
as a Dog
. And when inside the else
block, TypeScript similarly treats animal
as a Cat
. You can even see this if you hover over the animal object inside these blocks in your code editor:
Discriminated unions
You might expect the blog post to end here but, unfortunately, narrowing type unions by checking for the existence of properties is incredibly limited. It worked well for our trivial Dog
and Cat
types, but things can easily get more complicated, and more fragile, when we have more types, as well as more overlap between those types.
This is where discriminated unions come in handy. We’ll keep everything the same from before, except add a property to each type whose only job is to distinguish (or “discriminate”) between the types:
interface Cat { weight: number; whiskers: number; ANIMAL_TYPE: "CAT";
}
interface Dog { weight: number; friendly: boolean; ANIMAL_TYPE: "DOG";
}
Note the ANIMAL_TYPE
property on both types. Don’t mistake this as a string with two different values; this is a literal type. ANIMAL_TYPE: "CAT";
means a type that holds exactly the string "CAT"
, and nothing else.
And now our check becomes a bit more reliable:
let animal: Dog | Cat = {} as any; if (animal.ANIMAL_TYPE === "DOG") { console.log(animal.friendly);
} else { console.log(animal.whiskers);
}
Assuming each type participating in the union has a distinct value for the ANIMAL_TYPE
property, this check becomes foolproof.
The only downside is that you now have a new property to deal with. Any time you create an instance of a Dog
or a Cat
, you have to supply the single correct value for the ANIMAL_TYPE
. But don’t worry about forgetting because TypeScript will remind you. ????
Further reading
If you’d like to learn more, I’d recommend the TypeScript docs on narrowing. That’ll provide some deeper coverage of what we went over here. Inside of that link is a section on type predicates. These allow you to define your own, custom checks to narrow types, without needing to use type discriminators, and without relying on the in
keyword.
Conclusion
At the beginning of this article, I said it would make sense why weight
is the only accessible property in the following example:
interface Cat { weight: number; whiskers: number;
}
interface Dog { weight: number; friendly: boolean;
}
let animal: Dog | Cat;
What we learned is that TypeScript only knows that animal
could be either a Dog
or a Cat
, but not both. As such, all we get is weight
, which is the only common property between the two.
The concept of discriminated unions is how TypeScript differentiates between those objects and does so in a way that scales extremely well, even with larger sets of objects. As such, we had to create a new ANIMAL_TYPE
property on both types that holds a single literal value we can use to check against. Sure, it’s another thing to track, but it also produces more reliable results—which is what we want from TypeScript in the first place.