Mutually exclusive props in TypeScript

Thomas Rubattel
3 min readSep 28, 2023
Electrons of an atom can’t be in the same quantum state — Credits: Webstacks on Unsplash

The goal of the present article is to show how to write a React component with mutually exclusive props in a type-safe fashion in TS.

What

What we want to achieve is a component accepting as props either successMessage or errorMessage, but not both.

<Notication errorMessage="File corrupted" />   // ✅

<Notication successMessage="Profile updated" /> // ✅

<Notication
errorMessage="Network issue"
successMessage="Connection established"
/> // 💥 TS compiler

How

One way to achieve that, is by using union type of object type for each possible props. Within each object type, all other props has the never type to exclude them.

type NotificationProps =
| {
successMessage: string,
errorMessage? never,
}
| {
successMessage?: never,
errorMessage: string,
};

const Notification: React.FC<NotificationProps> = (props) => (
<div style={{ position: "absolute", top: 50, right: 50 }}>
{(props.errorMessage !== undefined) ? (
<span>⛔️ {props.errorMessage}</span>
): (
<span>✅ {props.successMessage}</span>
)}
</div>
);

Issue

We found a way to implement mutually exclusive props but the issue of the current solution is that it does not scale.

What if the requirement changes and a new mutually exclusive props has to be introduced, let’s say for example warningMessage.

type NotificationProps =
| {
successMessage: string,
errorMessage? never,
warningMessage?: never,
}
| {
successMessage?: never,
errorMessage: string,
warningMessage?: never,
}
| {
successMessage?: never,
errorMessage?: never,
warningMessage: string,
};

Automation

TypeScript has tremendous features for doing type-level programming . Let’s leverage them in order to solve the issue mentioned right above.

type Message = {
errorMessage: string;
successMessage: string;
warningMessage: string;
};

type PickOne<T, F extends keyof T> =
Pick<T, F> & { [K in keyof Omit<T, F>]?: never };

type Messages<E> = E extends keyof Message ? PickOne<Message, E> : never;

The code snippet right above generates the union type that was manually written prior to that. If a new kind of message has to be added, then only one entry in Message has to be added.

For generating an union type, one can rely on the distributive property of the conditional type. In this case, for looping over each possible props.

Final component

import { PropsWithChildren, FC, useEffect, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";

type Message = {
errorMessage: string;
infoMessage: string;
successMessage: string;
warningMessage: string;
};

type PickOne<T, F extends keyof T> =
Pick<T, F> & { [K in keyof Omit<T, F>]?: never };

type Messages<E> = E extends keyof Message ? PickOne<Message, E> : never;

const MESSAGE_2_ICONS: Message = {
errorMessage: "⛔️",
successMessage: "✅",
infoMessage: "ℹ️",
warningMessage: "⚠️",
};

const Transition: FC<PropsWithChildren<{ isVisible: boolean }>> = ({
isVisible,
children
}) => (
<AnimatePresence>
{isVisible && (
<motion.div
initial={{ opacity: 0, y: -300 }}
exit={{ opacity: 1, y: -300 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8 }}
>
{children}
</motion.div>
)}
</AnimatePresence>
);

const Notification: FC<Messages<keyof Message>> = (props) => {
const [isVisible, setIsVisible] = useState(true);
const messageType = Object.keys(props)[0] as keyof Message;
const icon = MESSAGE_2_ICONS[messageType];

useEffect(() => {
setTimeout(() => setIsVisible(false), 5000);
}, []);

return (
<div style={{ position: "absolute", top: 50, right: 50 }}>
<Transition isVisible={isVisible}>
<div style={{ padding: 20, backgroundColor: "white" }}>
<span>
{icon} {props[messageType]}
</span>
</div>
</Transition>
</div>
);
};

export default Notification;

Illustration

Final component illustration

Takeaway

Types can be programmatically generated the same way as for data thanks to TypeScript’s type-level programming capabilities.

In the present article, we have seen how to program an union type based on a simple object type. This is possible due the distributivity property of the conditional type (TS 2.8) spitting out an object type using mapped type (TS 2.1) at each iteration.

Utility types are the counterpart of functions in the type realm. They take a generic type (TS 0.9) as argument and return a type.

Finally, the never type (TS 2.0) enforces an exclusion of a props.

--

--