A discriminated union is a TypeScript feature that enables the creation of a type that can represent several different possibilities or variants. By attaching discriminators to each variant, TypeScript's type system can help ensure that we handle all possible cases gracefully. Discriminators can be string literals, numeric literals, or even symbols.
Why are Discriminated Unions important?
Using discriminated unions in your TypeScript code brings numerous benefits:
- Improved Type Safety: With discriminated unions, TypeScript can ensure that all possible variants of a type are accounted for, eliminating the risk of undefined or unexpected behavior at runtime.
- Enhanced Autocompletion: IDEs and code editors can leverage the discriminators to provide accurate autocompletion suggestions based on the specific variant being handled.
- Better Code Maintainability: Discriminated unions make code easier to read and understand by explicitly indicating the possible cases for a given type.
- Comprehensive Error Handling: TypeScript's static type checking can help us catch missing or mismatched discriminators, reducing the likelihood of introducing bugs.
But how it works?
Let's build a simple example to understand how it works.
We have a type or interface called Vehicle, which looks something like this:
type Vehicle = {
type: 'motorbike' | 'car';
make: string;
model: string;
fuel: 'petrol' | 'diesel',
doors?: number;
bootSize?: number;
}
const myCar: Vehicle = {
make: 'vw',
model: 'golf',
fuel: 'diesel',
type: 'car',
bootSize: 400,
doors: 5,
};
As you can see we have common properties, some that we expect to be always there for both Cars and Motorbikes, like make
, model
, fuel
and a type
property to discriminate what type of object we have, that could be either motorbike
or car
.
We also have some optional properties, like doors
, which cars have, but not (most of) motorbikes, and a bootSize
.
And we use that Type to use it for object called myCar
.
So what's the problem?
Let's say that now we have a new object, for our motorbike, and it looks something like this:
const myBike: Vehicle = {
type: 'motorbike',
make: 'honda',
model: 'cbr',
fuel: 'petrol',
}
The problem here is that nobody is stopping us to do this instead:
const myBike: Vehicle = {
type: 'motorbike',
make: 'honda',
model: 'cbr',
fuel: 'diesel',
doors: 10,
bootSize: 100,
}
Which it makes no sense, as normally a motorbike doesn't have doors, or a boot, and pretty sure they don't run on diesel either!
On the other side, also we made optional the doors
and bootSize
optional properties to accommodate the the motorbike type, but cars normally have doors and boots, so we want to have those properties compulsory if the type is a car!
How we can achieve that? Well with a discriminated union, where our discrimination field is our type
property.
The first thing is to figure out what are the fields that will be in common for both types:
type Vehicle = {
make: string;
model: string;
fuel: 'petrol' | 'diesel',
}
Next, we use the discriminated union by extending the Vehicle
type with the operator &
, and using a union to extend it to two different types base on the type
, so something like this:
type Vehicle = {
make: string;
model: string;
fuel: 'petrol' | 'diesel',
} & ({ type: 'car' } | { type: 'motorbike' });
At the moment is not adding much, but if we pass the type: 'car'
, the type will be a union of the Vehicle
type, and the { type: 'car' }
type, ignoring the other type in the union.
To make it more readable, we can also separate those types as follows:
type MotorBike = {
type: 'motorbike';
}
type Car = {
type: 'car';
}
And rewrite the discriminated union as follows:
type Vehicle = {
make: string;
model: string;
fuel: 'petrol' | 'diesel',
} & (Car | MotorBike);
Now we can start to add the properties we want for motorbikes, for example we want to clarify that motorbikes will only have petrol
as a fuel, and cars must have doors and boots:
type MotorBike = {
type: 'motorbike';
fuel: 'petrol';
}
type Car = {
type: 'car';
doors: number;
bootSize: number;
}
Which also means that if you create a new object as follows of the motorbike type, and you will pass diesel
as a fuel, the IDE will throw you an error!
Vice-versa if you create a car object, and you don't pass the doors or the bootSize properties, you will get an error, while the motorbike will not!
// This is valid
const myBike: Vehicle = {
type: 'motorbike',
make: 'honda',
model: 'cbr',
fuel: 'petrol',
}
// This is also valid
const myCar: Vehicle = {
make: 'vw',
model: 'golf',
fuel: 'diesel',
type: 'car',
bootSize: 400,
doors: 5,
};
// This will throw a TS error
const myCar: Vehicle = { // <- myCar will show an error
make: 'vw',
model: 'golf',
fuel: 'diesel',
type: 'car',
};
// This will also throw a TS Error as doors doesn't exists on that type
const myBike: Vehicle = {
type: 'motorbike',
make: 'honda',
model: 'cbr',
fuel: 'petrol',
doors: 5, // <-- will error here
}
Handling different properties with Type Guards
The next question you might ask yourself then is: "how do I handle these properties that might or might not exists as my object might have different shapes?".
The answer is: Type Guards.
But what are Type Guards?
In TypeScript, a type guard is a feature that allows you to narrow down the type of a variable within a conditional block. It provides a way to perform type-specific operations and access properties or methods that are only available on certain types. Type guards are particularly useful when working with union types or discriminated unions.
Type guards are essentially runtime checks that inform the TypeScript compiler about the type of a variable. Once the type is narrowed down within the guarded block, the compiler can provide more accurate type checking and enable autocompletion for the specific type.
For example if we want to have a function that handles a vehicle, we might have something like this:
const vehicleHandler = (vehicle: Vehicle) => {
switch (vehicle.type) {
case 'car':
console.log(`The car has ${vehicle.doors} doors`);
break;
case 'motorbike':
console.log(`The only fuel my motorbike can take is ${vehicle.fuel}`);
break;
}
}
Essentially by using this switch statement, or an if condition, as long is a conditional block, the IDE will only show the properties available in that block, for example my IDE will show this in the motorbike
condition:
Showing only the properties available for the appropriate type.
Disclaimer
Parts of this article was created with the help of AI.
Top comments (2)
Thanks for this @darkmavis1980. Really helpful article. Great work!!
this was quite helpful to me and the concept is nicely explained!