The OneOf Type in TypeScript

The OneOf Type in TypeScript

Arif sardar

Published on 16th Sep, 2024

9 min read

OneOf
TypeScript
Custom Types

Union types in TypeScript are a powerful feature that allows you to create flexible type definitions. However, they come with a flaw: they don't enforce exclusivity between type members. For instance, a union of several types might allow an object to have properties from multiple types simultaneously—something that isn’t always desirable. In this blog, we'll discuss how to implement the OneOf type to enforce exclusivity and correct this behavior, improving type safety in your applications.

The Problem with Union Types

Union types in TypeScript allow you to define a type that can be one of several other types. While this is useful, it doesn't enforce mutual exclusivity between the fields of those types. Here's a common example:

TS
type BaseCar = { brand: string; model: string; year: number };
 
type ElectricCar = BaseCar & { batteryCapacity: number };
type GasCar = BaseCar & { fuelCapacity: number };
type SolarCar = BaseCar & { solarPanelArea: number };
 
type Car = ElectricCar | GasCar | SolarCar;

You’d expect a Car object to either have a batteryCapacity, a fuelCapacity, or a solarPanelArea property, but not more than one at a time. However, TypeScript won’t complain if you create a Car that has all three fields:

TS
const car: Car = {
  brand: "Tesla",
  model: "Model S",
  year: 2023,
  batteryCapacity: 100,
  fuelCapacity: 50,
  solarPanelArea: 10,
}; // No error, but logically incorrect

This clearly shows that TypeScript unions don’t enforce the exclusivity you might want. To solve this, we need to create a custom type that represents a union of all properties of the given types.

Understanding the Need for OneOf

When defining a union of types in TypeScript, it's common to expect exclusivity between different fields, but that doesn’t happen by default. This is where the OneOf type comes in.

The OneOf type ensures that an object can only have one of several specified properties at a time, making it an essential tool for scenarios where you want to prevent the overlap of properties that logically shouldn't exist together.

Let’s look at the car example again:

TS
type BaseCar = { brand: string; model: string; year: number };
type ElectricCar = BaseCar & { batteryCapacity: number };
type GasCar = BaseCar & { fuelCapacity: number };
type SolarCar = BaseCar & { solarPanelArea: number };
 
type Car = ElectricCar | GasCar | SolarCar;

Without OneOf, the Car type allows you to create a car with all three properties — batteryCapacity, fuelCapacity, and solarPanelArea — even though it’s not logically possible for a car to be electric, gas-powered, and solar-powered at the same time.

This is where OneOf comes in, solving the flaw by enforcing that only one of these types is valid for a car. With OneOf, if you try to define a car with both batteryCapacity and fuelCapacity, TypeScript will throw an error, as expected.

The Concept of Exclusive Type Enforcement

When working with union types, we often want to ensure that only one of the possible types is chosen, while the others are automatically excluded. This is particularly relevant when defining complex objects where only certain combinations of properties make sense.

The key concept here is exclusivity: we want to allow only one of the possible types (or sets of properties) at a time, and ensure that no overlap occurs. One way to achieve this is to force all non-relevant properties of other types in the union to become never. In TypeScript, never is a type that represents values that never occur. By using never, we can signal to TypeScript that certain properties should never exist together, effectively preventing them from being used at the same time.

In simpler terms, if you choose one type, all other fields that belong to other types will automatically become never, making it impossible to accidentally include them.

TS
type BaseCar = { brand: string; model: string; year: number };
 
type ElectricCar = BaseCar & {
  batteryCapacity: number;
  fuelCapacity?: never;
  solarPanelArea?: never;
};
type GasCar = BaseCar & {
  fuelCapacity: number;
  batteryCapacity?: never;
  solarPanelArea?: never;
};
type SolarCar = BaseCar & {
  solarPanelArea: number;
  batteryCapacity?: never;
  fuelCapacity?: never;
};
 
type Car = ElectricCar | GasCar | SolarCar;

Now the Car type enforces exclusivity between the ElectricCar, GasCar, and SolarCar types, ensuring that only one of the three can be used at a time. If you try to create a car with multiple properties, TypeScript will throw an error, preventing you from making a logically incorrect object.

TS
const myCar: Car = {
  brand: "Tesla",
  model: "Model S",
  year: 2023,
  batteryCapacity: 100,
  fuelCapacity: 50, // Error: Object literal may only specify known properties
};

But, manually setting the never type for each property can be cumbersome and error-prone, especially when dealing with a large number of types or properties. To simplify this process, we can create a custom type that automatically enforces exclusivity between the properties of different types.

Implementing the OneOf Type

To create the OneOf type, we need to define a custom type that merges all properties of the given types and ensures that only one of them is valid at a time. This type will automatically set non-relevant properties to never, enforcing exclusivity between the types.

So, first we need to create a utility type that merges all properties of the given types:

TS
type MergeTypes<TypesArray extends any[], Res = {}> = TypesArray extends [
  infer Head,
  ...infer Rem
]
  ? MergeTypes<Rem, Res & Head>
  : Res;

The MergeTypes type takes an array of types and merges their properties into a single type. It uses a recursive conditional type to iterate over the array and merge the properties of each type into the result type.

TS
type AllProperties = MergeTypes<[ElectricCar, GasCar, SolarCar]>;
RESULT
type AllProperties = BaseCar & {
  batteryCapacity: number;
} & {
  fuelCapacity: number;
} & {
  solarPanelArea: number;
};

Next, we need to define a utility type that extracts only the properties of the first type that are not present in the second type:

TS
type OnlyFirst<F, S> = F & { [Key in keyof Omit<S, keyof F>]?: never };

The OnlyFirst type takes two types F and S and returns a new type that contains only the properties of F that are not present in S. It uses mapped types to iterate over the keys of S and exclude any keys that are already present in F.

TS
type ExclusiveProps = OnlyFirst<ElectricCar, GasCar>;
RESULT
type o = BaseCar & {
  batteryCapacity: number;
} & {
  fuelCapacity?: undefined;
};

Finally, we can define the OneOf type that enforces exclusivity between the properties of the given types:

TS
export type OneOf<
  TypesArray extends any[],
  Res = never,
  AllProperties = MergeTypes<TypesArray>
> = TypesArray extends [infer Head, ...infer Rem]
  ? OneOf<Rem, Res | OnlyFirst<Head, AllProperties>, AllProperties>
  : Res;

The OneOf type takes an array of types and recursively compares each type with the merged properties of all types. It uses the OnlyFirst utility type to extract the exclusive properties of each type and combines them into the result type. The process continues until all types have been processed, resulting in a type that enforces exclusivity between the properties.

Now, we can use the OneOf type to define the Car type with exclusivity between the ElectricCar, GasCar, and SolarCar types:

TS
type Car = OneOf<[ElectricCar, GasCar, SolarCar]>;

With the OneOf type, the Car type enforces exclusivity between the ElectricCar, GasCar, and SolarCar types, ensuring that only one of the three can be used at a time. If you try to create a car with multiple properties, TypeScript will throw an error, preventing you from making a logically incorrect object.

TS
const myCar: Car = {
  brand: "Tesla",
  model: "Model S",
  year: 2023,
  batteryCapacity: 100,
  fuelCapacity: 50, // Error: Object literal may only specify known properties
};

Final Code

Here's the final code that defines the OneOf type and uses it to enforce exclusivity between the ElectricCar, GasCar, and SolarCar types:

ONE_OF.TS
type MergeTypes<TypesArray extends any[], Res = {}> = TypesArray extends [
  infer Head,
  ...infer Rem
]
  ? MergeTypes<Rem, Res & Head>
  : Res;
 
type OnlyFirst<F, S> = F & { [Key in keyof Omit<S, keyof F>]?: never };
 
export type OneOf<
  TypesArray extends any[],
  Res = never,
  AllProperties = MergeTypes<TypesArray>
> = TypesArray extends [infer Head, ...infer Rem]
  ? OneOf<Rem, Res | OnlyFirst<Head, AllProperties>, AllProperties>
  : Res;
CAR.TS
import { OneOf } from "./ONE_OF";
 
type BaseCar = { brand: string; model: string; year: number };
 
type ElectricCar = BaseCar & { batteryCapacity: number };
type GasCar = BaseCar & { fuelCapacity: number };
type SolarCar = BaseCar & { solarPanelArea: number };
 
type Car = OneOf<[ElectricCar, GasCar, SolarCar]>;

With this implementation, you can now create a Car object that enforces exclusivity between the ElectricCar, GasCar, and SolarCar types, improving type safety in your applications.

Conclusion

The OneOf type in TypeScript addresses a critical flaw in union types by enforcing exclusivity between properties that shouldn't coexist. By utilizing never for conflicting fields, it prevents invalid combinations of data and ensures that only one of the defined types is selected at a time. This approach is especially valuable in scenarios where logical consistency is paramount, such as distinguishing between different types of cars, messages, or any other mutually exclusive structures.

Implementing this pattern in your code not only improves type safety but also enhances the clarity and maintainability of your TypeScript applications. As TypeScript continues to evolve, tools like OneOf provide a powerful means to create robust and error-free systems that adhere strictly to your intended design logic.

© 2024 Arif Sardar. Made In India.

Version 4. Powered by Vercel (Next JS).