Does TypeScript support mutually exclusive types?
Asked Answered
B

8

73

I have a method that takes a parameter. I would like TypeScript to verify that the object being passed in (at compile-time, I understand run-time is a different animal) only satisfies one of the allowed interfaces.

Example:

interface Person {ethnicity: string;}
interface Pet {breed: string;}
function getOrigin(value: Person ^ Pet){...}

getOrigin({}); //Error
getOrigin({ethnicity: 'abc'}); //OK
getOrigin({breed: 'def'}); //OK
getOrigin({ethnicity: 'abc', breed: 'def'});//Error

I realize that Person ^ Pet is not valid TypeScript, but it's the first thing I thought to try and seemed reasonable.

Bazluke answered 8/2, 2017 at 20:54 Comment(2)
I think what you're looking for is Type Guards. AFAIK TypeScript doesn't support exclusive types.Preoccupation
For people looking to keep only one property among all the keys of an interface, I suggest using oneOfBailar
F
87

As proposed in this issue, you could use conditional types to write a XOR type:

type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: never };
type XOR<T, U> = (T | U) extends object ? (Without<T, U> & U) | (Without<U, T> & T) : T | U;

And now your example works:

interface Person {ethnicity: string;}
interface Pet {breed: string;}
function getOrigin(value: XOR<Person, Pet>) { /* ... */}

getOrigin({}); //Error
getOrigin({ethnicity: 'abc'}); //OK
getOrigin({breed: 'def'}); //OK
getOrigin({ethnicity: 'abc', breed: 'def'});//Error
Funiculate answered 9/11, 2018 at 16:23 Comment(6)
That XOR is so handy! How could this be extendd to support multiple mutualy exclusive types? Could you extend your answer to support XOR<Person, Pet, Car, Tree, ..., House> ?Habitue
One would need to build up an according chain (I suspect it works because of transitivity?!): type XOR3<S, T, U> = XOR<S, XOR<T, U>>;Twinkle
@Habitue A little late to the party, but this (TS playground) would accomplish what you were looking for with OneOf<[Person, Pet, Car, Tree, ..., House]>Seagrave
Horrifying @tjjfvi... :)Ogdoad
@Seagrave Did you write that? I'd like to be able to credit the creator when I use it :)Sawhorse
@ConnorDooley Yes, I did. It was a while ago, so here's a cleaner version: tsplay.dev/wgLpBNSeagrave
H
19

You can use the tiny npm package ts-xor that was made to tackle this problem specifically.

With it you can do the following:

import { XOR } from 'ts-xor'
 
interface A {
  a: string
}
 
interface B {
  b: string
}
 
let A_XOR_B: XOR<A, B>
 
A_XOR_B = { a: 'a' }          // OK
A_XOR_B = { b: 'b' }          // OK
A_XOR_B = { a: 'a', b: 'b' }  // fails
A_XOR_B = {}                  // fails

You can even XOR more than one types like so:

XOR<A, B, C, D, E, F> // supports XORing up to 200 type arguments

Full disclosure: I'm the author of ts-xor. I found that I needed to implement the XOR type from repo to repo all the time. So I published it for the community and me and in this way, I could also add tests and document it properly with a readme and jsdoc annotations. The implementation is what @Guilherme Agostinelli shared from the community.

Habitue answered 25/2, 2019 at 0:25 Comment(0)
M
4

To augment Nitzan's answer, if you really want to enforce that ethnicity and breed are specified mutually exclusively, you can use a mapped type to enforce absence of certain fields:

type Not<T> = {
    [P in keyof T]?: void;
};
interface Person {ethnicity: string;}
interface Pet {breed: string;}
function getOrigin(value: Person & Not<Pet>): void;
function getOrigin(value: Pet & Not<Person>): void;
function getOrigin(value: Person | Pet) { }

getOrigin({}); //Error
getOrigin({ethnicity: 'abc'}); //OK
getOrigin({breed: 'def'}); //OK

var both = {ethnicity: 'abc', breed: 'def'};
getOrigin(both);//Error
Managua answered 8/2, 2017 at 21:25 Comment(0)
G
3

You can use Discriminating Unions:

interface Person {
  readonly discriminator: "Person"
  ethnicity: string
}

interface Pet {
  readonly discriminator: "Pet"
  breed: string
}

function getOrigin(value: Person | Pet) { }

getOrigin({ }) // Error
getOrigin({ discriminator: "Person", ethnicity: "abc" }) // OK
getOrigin({ discriminator: "Pet", breed: "def"}) // OK
getOrigin({ discriminator: "Person", ethnicity: "abc", breed: "def"}) // Error
Graehl answered 20/5, 2021 at 15:44 Comment(0)
T
3

For a bigger list of options you can use StrictUnion:

type UnionKeys<T> = T extends T ? keyof T : never;
type StrictUnionHelper<T, TAll> = T extends any ? T & Partial<Record<Exclude<UnionKeys<TAll>, keyof T>, never>> : never;
type StrictUnion<T> = StrictUnionHelper<T, T>

type WhatYouWanted = StrictUnion<Person | Pet | Car>
Thai answered 1/9, 2022 at 10:23 Comment(0)
E
1

As of TS v4.7, I found Omit to be the simplest solution:

interface Circle {
    radius: number;
}

interface Polygon {
    sides: number;
}

type Either<A, B> = Omit<A, keyof B> | Omit<B, keyof A>;

const mutuallyExclusiveProps: Either<Circle, Polygon> = { radius: 5 };

mutuallyExclusiveProps.sides = 5; // Error
Embed answered 4/7, 2022 at 15:11 Comment(3)
Works fine only until you don't have a common property in both of them, say perimeter. Then, you won't be able to pass neither a valid Circle nor a valid Polygon into the thing ;)Ichthyo
Correct. I thought that was the whole reason for this type to exist. I personally use this trick in my front-end projects to define strictly mutually exclusive props for a particular component which I can then go and spread.Embed
Yeah, that would definitely be one of the use cases, but I stumbled into this post while looking for a way to structure a tree graph, say, with leaf nodes that have value, or branch nodes that have children, but also leaves and branches could have properties in common, inherited from BaseNode. So in a resulting structure I wanted XOR<Branch, Leaf>, so to guarantee there can't be children and value at the same time. I solved it through an explicit never prop BTW, so children?: never in a Leaf and value?: never in a Branch and it worked fine.Ichthyo
T
0

I've come up with this solution. We have an unexported base type that we use as a blueprint for the exported type, in which only one property is allowed, and the others are never.

type OnlyOne<Base, Property extends keyof Base> = Pick<Base, Property>
& Partial<Record<keyof Omit<Base, Property>, never>>;

TS Playground

Trichromatic answered 27/3, 2023 at 10:35 Comment(0)
S
-3

You can use union types:

function getOrigin(value: Person | Pet) { }

But the last statement won't be an error:

getOrigin({ethnicity: 'abc', breed: 'def'}); // fine!

If you want that to be an error then you'll need to use overloading:

function getOrigin(value: Pet);
function getOrigin(value: Person);
function getOrigin(value: Person | Pet) {}
Sebi answered 8/2, 2017 at 21:16 Comment(1)
Union types doesn't solve the problem and is kinda what the OP is asking what to replace it with, but function overload works very nicely for this use case.Pyrargyrite

© 2022 - 2024 — McMap. All rights reserved.