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'
withCarDetails
deliveryType: 'drone'
withBikeDetails
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
- Avoid mixing unrelated unions. Interfaces with union properties can create mismatched combinations and errors.
- Use unions of interfaces. This ensures that only valid combinations are possible.
- Apply tagged unions. Add a
type
ortag
field to make it easy to differentiate types in a union. - 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.