Mutually Exclusive Unions in TypeScript (and why ExclusifyUnion rocks)
When modelling data, we often want either one shape or another, never both at once. This is the essence of a mutually exclusive union. In TypeScript, that means writing unions where accessing properties becomes ergonomic without defensive checks everywhere.
This post shows a neat pattern using ?: never, why it doesn’t scale, and how type-fest’s ExclusifyUnion helper elegantly solves this problem.
The basic problem
Mutually exclusive unions help us distinguish the shape of a given object. Take the following example:
type Dog = {
bark: () => void;
};
type Cat = {
meow: () => void;
};
const vocalize = (animal: Dog | Cat) => {
if ("bark" in animal) {
animal.bark();
return;
}
return animal.meow();
};
Once we’ve checked that bark or meow exists on animal, TypeScript narrows the union and everything is fine.
But the ergonomics in vocalize aren’t ideal. Intuitively, we’d love to destructure and call whichever function exists:
const vocalize = ({ bark, meow }: Dog | Cat) => {
// ^ ^
// Property 'meow' does not exist on type 'Dog | Cat'.
// Property 'bark' does not exist on type 'Dog | Cat'.
return (bark ?? meow)();
};
TypeScript complains because you’re trying to access bark or meow before the union has been narrowed.
The ?: never trick
A common workaround is to add the other member’s exclusive properties as ?: never. This hints to the type system that a property might be accessed, even if it doesn’t exist on this branch, so narrowing through destructuring becomes possible.
type Dog = {
bark: () => void;
meow?: never;
};
type Cat = {
bark?: never;
meow: () => void;
};
const vocalize = ({ bark, meow }: Dog | Cat) => {
return (bark ?? meow)();
};
Now destructuring works, and TypeScript still understands that exactly one of bark or meow exists at a time (an exclusive-or).
Scaling This Pattern
The ?: never trick is fine for small unions, but it quickly becomes tedious when you introduce more types, each with many unique fields. Imagine adding more animals:
type Dog = {
bark: () => void;
wagTail: () => void;
fetch: (item: string) => void;
guard: () => void;
// Cat props
meow?: never;
purr?: never;
hiss?: never;
// Bird props
chirp?: never;
fly?: never;
};
type Cat = {
meow: () => void;
purr: () => void;
hiss: () => void;
// Dog props
bark?: never;
wagTail?: never;
fetch?: never;
guard?: never;
// Bird props
chirp?: never;
fly?: never;
};
type Bird = {
chirp: () => void;
fly: () => void;
// Dog props
bark?: never;
wagTail?: never;
fetch?: never;
guard?: never;
// Cat props
meow?: never;
purr?: never;
hiss?: never;
};
Now we’re manually repeating a growing list of ?: never properties for every new type. As the union grows, maintaining all these exclusions becomes a nightmare of boilerplate.
Introducing ExclusifyUnion
This is exactly where type-fest’s ExclusifyUnion comes in (which I originally proposed here). It automates the process of adding ?: never based on your union type.
With ExclusifyUnion, you can keep your types clean and simple:
type Dog = {
bark: () => void;
wagTail: () => void;
fetch: (item: string) => void;
guard: () => void;
};
type Cat = {
meow: () => void;
purr: () => void;
hiss: () => void;
};
type Bird = {
chirp: () => void;
fly: () => void;
};
const interact = (
{ fetch, chirp }: ExclusifyUnion<Dog | Cat | Bird>,
thing: string,
) => {
return (fetch ?? chirp)?.(thing);
};
That’s it. No manual exclusions, no verbosity, just clean, exclusive union types that work.
A familiar example: Zod’s safeParse
Zod’s safeParse result is a mutually exclusive union shaped to be ergonomic without extra checks. Conceptually:
type SafeParseResult<T> =
| { success: true; data: T }
| { success: false; error: Error };
In Zod, the types are defined more strictly to allow destructuring:
export type ZodSafeParseSuccess<T> = {
success: true;
data: T;
error?: never;
};
export type ZodSafeParseError<T> = {
success: false;
data?: never;
error: Error;
};
export type ZodSafeParseResult<T> =
| ZodSafeParseSuccess<T>
| ZodSafeParseError<T>;
Which enables this pattern:
const f = (x: unknown) => {
const { data, error } = z.string().safeParse(x);
if (!error) {
data; // string
}
};
Key Takeaways
-
Mutually exclusive shapes help TypeScript distinguish object structures cleanly. For example:
type Dog = { bark: () => void; }; type Cat = { meow: () => void; };These ensure that only one of the two shapes applies at any given time.
-
Adding
prop?: neverto the opposite properties gives you ergonomic access to any properties without confusing TypeScript’s type narrowing.:
type Dog = { bark: () => void; meow?: never; }; type Cat = { bark?: never; meow: () => void; }; However, this approach doesn’t scale when your union includes many types or each type has many unique fields. The boilerplate grows fast.
type-fest’s
ExclusifyUnionsolves this elegantly, combining the clarity of clean domain types with the convenience of destructuring. It automatically enforces exclusivity across union members—no more repetitive?: neverdefinitions.
Top comments (0)