Mutually exclusive properties in TypeScript
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'.