DEV Community

Alessio Michelini
Alessio Michelini

Posted on • Updated on

What are TypeScript Discriminated Unions?

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:

  1. 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.
  2. Enhanced Autocompletion: IDEs and code editors can leverage the discriminators to provide accurate autocompletion suggestions based on the specific variant being handled.
  3. Better Code Maintainability: Discriminated unions make code easier to read and understand by explicitly indicating the possible cases for a given type.
  4. 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,
};
Enter fullscreen mode Exit fullscreen mode

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',
}
Enter fullscreen mode Exit fullscreen mode

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,
}
Enter fullscreen mode Exit fullscreen mode

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',
}
Enter fullscreen mode Exit fullscreen mode

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' });
Enter fullscreen mode Exit fullscreen mode

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';
}
Enter fullscreen mode Exit fullscreen mode

And rewrite the discriminated union as follows:

type Vehicle = {
  make: string;
  model: string;
  fuel: 'petrol' | 'diesel',
} & (Car | MotorBike);
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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!

Syntax Error TS

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
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

Type Guards

Showing only the properties available for the appropriate type.

Disclaimer

Parts of this article was created with the help of AI.

Top comments (1)

Collapse
 
keyurparalkar profile image
Keyur Paralkar

Thanks for this @darkmavis1980. Really helpful article. Great work!!