Background image

How to type an object with exclusive-or properties in TypeScript

How can we define a type for an object, which has some required attributes and some attributes, of which one and only one must be defined?

Let's say we want to define a type for a message that has a timestamp and either a text or an id.

// ✅ This should be valid
const messageWithText = {
timestamp: "2021-08-22T19:58:53+00:00",
text: "Hello!"
}
// ✅ This should be valid
const messageWithId = {
timestamp: "2021-08-22T19:58:53+00:00",
id: 123
}
// ❌ This should be a type error
const messageWithBoth = {
timestamp: "2021-08-22T19:58:53+00:00",
id: 123,
text: "Hello!"
}
// ❌ This should be a type error
const messageWithoutEither = {
timestamp: "2021-08-22T19:58:53+00:00",
}

Union type

One way to approach this would be to use a union type

type Message = {timestamp: string}
type TextMessage = {text: string};
type IdMessage = {id: number};
type TextOrIdMessage = Message & (TextMessage | IdMessage);

Now, timestamp is a required attribute, and one of id and text attributes must also be present in an object of type TextOrIdMessage.

const msg1: TextOrIdMessage = {timestamp: "2021-08-22T19:58:53+00:00", text: "Hello!"}
const msg2: TextOrIdMessage = {timestamp: "2021-08-22T19:58:53+00:00", id: 1234}
// ❌ Type error!
const msg3: TextOrIdMessage = {timestamp: "2021-08-22T19:58:53+00:00"}

This does not however prevent us from creating an object with both id and text 😔

// This is still valid..
const msg4: TextOrIdMessage = {timestamp: "2021-08-22T19:58:53+00:00", id: 1234, text: "Hello!"}

Using never to forbid a key from a type

What we can do to prevent this, is to use the never type

type Message = {timestamp: string}
type TextMessage = {text: string, id?: never};
type IdMessage = {id: number, text?: never};
type TextOrIdMessage = Message & (TextMessage | IdMessage);

We've added id with the type never to TextMessage and text as type never to IdMessage. This means id and text can never be in the same object just like we wanted!

// ❌ Type error!
const msg4: TextOrIdMessage = {timestamp: "2021-08-22T19:58:53+00:00", id: 1234, text: "Hello!"}

This is a great solution and most of the time it would probably be enough. But is there a way to make a more generic, reusable solution?

A reusable exclusive-or utility type

As it turns out, there is. This is a solution I came across in a Stack Overflow question Why does A | B allow a combination of both, and how can I prevent it?. The solution was provided by jcalz.

The solution is a bit more involved than the previous one, so we will go through it step by step. Here's the full code to create a utility type called ExclusifyUnion.

type AllKeys<T> = T extends unknown ? keyof T : never;
type Id<T> = T extends infer U ? { [K in keyof U]: U[K] } : never;
type _ExclusifyUnion<T, K extends PropertyKey> =
T extends unknown ? Id<T & Partial<Record<Exclude<K, keyof T>, never>>> : never;
type ExclusifyUnion<T> = _ExclusifyUnion<T, AllKeys<T>>;

ExclusifyUnion<TextOrIdMessage> will give us a union type in which id and text are defined as optional undefined types.

type ExclusifiedMessage = ExclusifyUnion<TextOrIdMessage>;
/*{
timestamp: string;
text: string;
id?: undefined
} |
{
timestamp: string;
id: number;
text?: undefined
}*/

That's a lot to unpack 😅 Let's look at AllKeys and Id first and then go through step by step, how ExclisifyUnion works.

On the first line type AllKeys<T> = T extends unknown ? keyof T : never; we are creating a utility for getting all the keys from a type. Just using keyof T isn't enough because in our example with the type TextOrIdMessage this would result in only the key timestamp. This is because keyof a union type only returns the keys which are shared by all the types in the union.

But with the magic of distributive conditional types we can extract all the keys from a union. This works because when T extends unknown ? keyof T : never is given a union, it applies keyof T to each part of the union. AllKeys<TextOrIdMessage> gives us a union of all the keys "timestamp" | "id" | "text".

type AllKeys<T> = T extends unknown ? keyof T : never;
type AllTheKeys = AllKeys<TextOrIdMessage>
// "timestamp" | "id" | "text"

Now, let's look at the Id utility type.

type Id<T> = T extends infer U ? { [K in keyof U]: U[K] } : never;

At first look, Id looks pretty baffling 🤔 It seems to just give you back exactly the type you give to it..

Id<number> // number
Id<number[]> // number[]
Id<{a: string}> // {a: string}

But when you give Id a more complex type like TextOrIdMessage, the result is a "compiled" version type. While TextOrIdMessage and Id<TextOrIdMessage> are functionally the same, the latter is easier to work with because your editor can show you a more readable version of the type.

The purpose of Id is to eliminate intersections in order to make the type easier to read. You can read more about it at the end of this Stack Overflow answer.

Id<TextOrIdMessage>
/*
{
timestamp: string;
text: string;
} | {
timestamp: string;
id: number;
}
*/

How does Id work then? Well, it uses a conditional type with infer to extract all the keys and value types from the given type in order to eliminate intersections.

type SomeIntersectionType = {id: number, timestamp: string} & {timestamp: string, text: string}
type ReadableIntersectionType = Id<SomeIntersectionType> // {id: number, timestamp: string, text: string}

Now that we've looked at what AllKeys and Id do, let's go over what ExclusifyUnion does to TextOrIdMessage.

type _ExclusifyUnion<T, K extends PropertyKey> =
T extends unknown ? Id<T & Partial<Record<Exclude<K, keyof T>, never>>> : never;
type ExclusifyUnion<T> = _ExclusifyUnion<T, AllKeys<T>>;

The key to understanding ExclusifyUnion is noticing that it uses a distributive conditional type to apply Id<T & Partial<Record<Exclude<K, keyof T>, never>>> to each part of a union given as the generic type T. Inside the conditional, T refers to the part of the union it is being applied to.

Remember, K here is AllKeys<TextOrIdmessage> which resolves to "timestamp" | "text" | "id".

Our type TextOrIdMessage, is a union type with two parts, {timestamp: string, text: string} and {timestamp: string, id: number}.

So when we apply the inside of Id<...> to each part of TextOrIdMessage, we will get

{timestamp: string, text:string} & Partial<Record<Exclude<"timestamp" | "id" | "text", "timestamp" | "text">, never>>
{timestamp: string, id:number} & Partial<Record<Exclude<"timestamp" | "id" | "text", "timestamp" | "id">, never>>

After resolving Exclude we will have

{timestamp: string, text:string} & Partial<Record<"id", never>>
{timestamp: string, id:number} & Partial<Record<"text", never>>

Resolving Record gives us

{timestamp: string, text:string} & Partial<{id: never}>
{timestamp: string, id:number} & Partial<{text: never}>

And finally resolving Partial and the intersection operators

{timestamp: string, text:string, id?: undefined}
{timestamp: string, id:number, text?: undefined}

The final result will be the union of these types just like we wanted 🥳

type ExclusifiedMessage = ExclusifyUnion<TextOrIdMessage>;
/*{
timestamp: string;
text: string;
id?: undefined
} |
{
timestamp: string;
id: number;
text?: undefined
}*/

Further reading

Why does A | B allow a combination of both, and how can I prevent it? - stackoverflow.com
Proposal: Add an "exclusive or" (^) operator - github.com
all possible keys of a union type - stackoverflow.com

You might also enjoy