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
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.
Seems like “union” is a misapplied term. I would say that the set theory term that should apply is “intersection”. That is, “weight” is the thing in common between “Dog” and “Cat”. In set theory we would call that the intersection.
Remember, we’re focused on the set of values a given type can contain.
When we say
let a: Dog | Cat
we are declaring thata
can hold any value in the set union of valid Dog values, and valid Cat values.But when it comes to properties TS will let us access on
a
, it is limited to the ones TS knows, for sure, will be there, which, however confusing this might be, winds up being the set intersection of Dog’s properties, and Cat’s properties.This may be worth mentioning for some: use an enum for
ANIMAL_TYPE
. You do end up having to remember to add more animals to the enum, aside from creating the class (or type), but you avoid typos and gain typing on the property, which is a big win IMO.Good call! I agree.
No need, as typescript will automatically enforce that the string is exactly the correct value. So typos become impossible as well.
One difference between enums and unions is that enums compile to run-time objects, whereas type unions get stripped out. Not a performance consideration, but something to be aware of.
In this case, a string union will get the job done just fine since TypeScript will provide intellisense and also complain if there’s ever a type mismatch in the future. But enums definitely save you the trouble of manually updating the individual usages.
I could’ve sworn there was an issue with an older version of TypeScript where discriminated unions with enums didn’t correctly narrow types, but it seems to work fine now.
Thank you SO MUCH for writing this. I was beginning to regret setting up my new side project using TS and was beating my head against exactly this problem when your article floated across my feed. You did a great job of breaking a complex topic down into understandable chunks and actionable solutions.
Thanks for the info Adam.
You mention that:
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.
Just to be clear discriminated unions are a concept that can be expressed in TypeScript through the use of unions, discriminator properties, logic that checks discriminators, and static control-flow analysis. TypeScript is not quite supporting the concept of discriminated unions as a language feature but rather allows the user to support this concept as a programming pattern.
I just wanted to make clear what TS does and does not help you with. It is useful for readers to understand the benefits that come with this being fully supported as a language feature and the dangers inherent in the lack of support.
You have to make sure to scale your logic that handles union types as you scale your union types. In languages that support disjoint unions natively you generally also get exhaustive pattern matching as a feature. This allows you to be certain that as you expand your union types you also expand your logic that handles these union types. A similar pattern can be expressed in TypeScript.