← All Blogs

The Power of Unions of Interfaces: A Better Way to Structure Types in TypeScript

The Power of Unions of Interfaces: A Better Way to Structure Types in TypeScript

by Abdelkader settah

December 13, 2024

When designing TypeScript types, it’s tempting to create interfaces where properties are union types. However, a more precise and safer approach often involves unions of interfaces. Let’s explore this idea with new, relatable examples.

The Problem: Interfaces with Union Properties

Suppose you’re designing a food delivery app where each order can be delivered by a bike, car, or drone. You define an Order interface:

interface Order {
  deliveryType: "bike" | "car" | "drone";
  deliveryDetails: BikeDetails | CarDetails | DroneDetails;
}

Here’s what each type of deliveryDetails might look like:

  • BikeDetails: { bikeNumber: string }
  • CarDetails: { carModel: string; plateNumber: string }
  • DroneDetails: { droneID: string }

While this structure works, it allows mismatched combinations, such as:

  • deliveryType: 'bike' with CarDetails
  • deliveryType: 'drone' with BikeDetails

Such mismatches are unrealistic and error-prone, making it harder to work with this interface.


A Better Solution: Use Unions of Interfaces

To fix this, define separate interfaces for each deliveryType, then create a union:

interface BikeOrder {
  deliveryType: "bike";
  deliveryDetails: BikeDetails;
}

interface CarOrder {
  deliveryType: "car";
  deliveryDetails: CarDetails;
}

interface DroneOrder {
  deliveryType: "drone";
  deliveryDetails: DroneDetails;
}

type Order = BikeOrder | CarOrder | DroneOrder;

Now, TypeScript enforces the correct relationship between deliveryType and deliveryDetails. It’s impossible to pair a bike delivery with CarDetails.


Why This Works Better

Using unions of interfaces models valid states only. When you process an Order, TypeScript automatically narrows down the type based on the deliveryType:

function processOrder(order: Order) {
  if (order.deliveryType === "bike") {
    console.log(`Delivering by bike:`, order.deliveryDetails.bikeNumber);
  } else if (order.deliveryType === "car") {
    console.log(`Delivering by car:`, order.deliveryDetails.carModel);
  } else {
    console.log(`Delivering by drone:`, order.deliveryDetails.droneID);
  }
}

This approach avoids errors and simplifies your code.


Another Example: Event Handling

Consider a UI system where you handle events for a button, text input, or slider. You define an UIEvent interface:

interface UIEvent {
  elementType: "button" | "input" | "slider";
  eventDetails: ButtonEvent | InputEvent | SliderEvent;
}

This allows mismatched combinations, such as elementType: 'button' with SliderEvent.

To fix this, use a union of interfaces:

interface ButtonEvent {
  elementType: "button";
  eventDetails: { buttonID: string; action: "click" | "hover" };
}

interface InputEvent {
  elementType: "input";
  eventDetails: { inputID: string; value: string };
}

interface SliderEvent {
  elementType: "slider";
  eventDetails: { sliderID: string; position: number };
}

type UIEvent = ButtonEvent | InputEvent | SliderEvent;

Now, TypeScript ensures consistency, and you can handle events easily:

function handleEvent(event: UIEvent) {
  if (event.elementType === "button") {
    console.log(`Button event: ${event.eventDetails.action}`);
  } else if (event.elementType === "input") {
    console.log(`Input value: ${event.eventDetails.value}`);
  } else {
    console.log(`Slider position: ${event.eventDetails.position}`);
  }
}

Optional Fields: Another Case for Precision

Optional fields in interfaces can also lead to ambiguity. Suppose you have a Profile interface:

interface Profile {
  name: string;
  phoneNumber?: string;
  email?: string;
}

This setup allows phoneNumber without email, or vice versa, but your app might require both or neither.

To make the relationship clearer, use a union:

interface BasicProfile {
  name: string;
}

interface ContactableProfile extends BasicProfile {
  phoneNumber: string;
  email: string;
}

type Profile = BasicProfile | ContactableProfile;

Now, TypeScript enforces that both phoneNumber and email must appear together:

const john: Profile = { name: "John Doe" }; // OK
const jane: Profile = {
  name: "Jane Doe",
  phoneNumber: "123-456-7890",
  email: "jane@example.com",
}; // OK
const invalid: Profile = {
  name: "Invalid User",
  phoneNumber: "123-456-7890", // Error: Missing email
};

Key Takeaways

  1. Avoid mixing unrelated unions. Interfaces with union properties can create mismatched combinations and errors.
  2. Use unions of interfaces. This ensures that only valid combinations are possible.
  3. Apply tagged unions. Add a type or tag field to make it easy to differentiate types in a union.
  4. Group related fields. For optional properties that depend on each other, use a union to clarify their relationship.

By adopting these practices, you’ll create safer, more maintainable TypeScript types that work seamlessly with its type-checking capabilities.