Mutually exclusive properties in TypeScript

Learn how to make one of two properties required, but not both, using TypeScript types, interfaces, and a union type.
October 26, 2023TypeScript
4 min read

How can we create a TypeScript type where either one or the other property is required? These two properties should be mutually exclusive, meaning that they should not be able to coexist.

Consider the following scenario. Let's say that we want to create a Message type, where either a userId or a username must be provided, but not both.

We want to use this Message type to define function parameters for a sendMessageToUser function that is able to send a message to a user by receiving either the user's ID or their username.

You might be asking, why we would want the sendMessageToUser function to behave like this? The answer is flexibility. If we do know the user's ID when calling the sendMessageToUser function, we can pass it directly. However, if we don't know the user's ID when calling the sendMessageToUser function, we can pass the user's username and it will still work just the same.

Creating the Message type

Let's use TypeScript types, interfaces, and a union type to create the Message type that accepts a userId or a username, but not both.

type MessageBase = {
  message: string;
};

interface MessageWithUserId extends MessageBase {
  userId: string;
  username?: never;
}

interface MessageWithUsername extends MessageBase {
  username: string;
  userId?: never;
}

type Message = MessageWithUserId | MessageWithUsername;

First, we build a base type, called MessageBase. Then, we build mutually exclusive interfaces that extend from the base type. The TypeScript never data type is used in the mutually exclusive interfaces to indicate the property that will never occur in each. Finally, the Message type is defined as a union type. A union type allows Message to be one of several types. The vertical bar (|) is used to separate each type.

As you might have noticed above, it is possible for an interface to extend an object-like type.

Testing the Message type

Let's test the Message type to make sure that it can receive a userId or a username, but not both.

// ✅ Good
let userIdMessage: Message = { userId: '', message: '' };
// ✅ Good
let usernameMessage: Message = { username: '', message: '' };
// ❌ userId and username cannot coexist
let usernameMessage: Message = { userId: '', username: '', message: '' };
// ❌ userId or username is required
let usernameMessage: Message = { message: '' };

Using the Message type

Let's use the Message type that we created above for a sendMessageToUser function. If the userId is not provided as a parameter, the function will proceed to look up the user's ID using the username that is expected to be provided when no userId is present.

async function sendMessageToUser({
  userId,
  username,
  message,
}: Message): Promise<void> {
  let id = userId;

  if (!id) {
    id = await getUserId(username!);
    if (!id) {
      return;
    }
  }

  await sendMessage(id, message);
}

// for demo purposes
function getUserId(username: string) {
  return 'id';
}

// for demo purposes
function sendMessage(userId: string, message: string) {}

We used the the non-null assertion operator (!) on username inside the conditional block that executes when userId is not present.

We know from the Message type that username is guaranteed to be present when userId is not present.

The non-null assertion operator tells the TypeScript compiler that we can ignore the possibility of username being undefined in that execution path. As a result, TypeScript will no longer show the following warning for username.

Argument of type 'string | undefined' is not assignable
to parameter of type 'string'. Type 'undefined' is not
assignable to type 'string'.

Further reading

New
Be React Ready

Learn modern React with TypeScript.

Learn modern React development with TypeScript from my books React Ready and React Router Ready.