Typescript: Proposal: Add an "logical or" (^) operator

Created on 15 Feb 2017  ·  38Comments  ·  Source: microsoft/TypeScript



Based on this comment TypeScript does not allow exclusive union types.

I'm proposing a logical or operator similar to union (|) or intersection (&) operators that allows defining types that are one or another.

Code

type Person = { name: string; } ^ { firstname: string; lastname: string; };

const p1: Person = { name: "Foo" };
const p2: Person = { firstname: "Foo", lastname: "Bar" } ;

const bad1: Person = { name: "Foo", lastname: "Bar" }
                                    ~~~~~~~~~~~~~~~   Type Person can not have name and firstname together 
const bad2: Person = { lastname: "Bar", name: "Foo" }                                            
                                        ~~~~~~~~~~~   Type Person can not have lastname and name together

For literal and primitive types it should behave like union type:

// These are the same 
type stringOrNumber = string | number;
type stringORNumber = string ^ number;
Declined Suggestion

Most helpful comment

A little more conditional types magic so it works with primitive types:

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;

type NameOnly = { is: "NameOnly", name: string };
type FirstAndLastName = { is: "FirstAndLastName", firstname: string; lastname: string };
type Person = XOR<NameOnly, FirstAndLastName>;
let person: Person;

person = { is: "NameOnly", name: "Foo" };
person = { is: "FirstAndLastName", firstname: "Foo", lastname: "Bar" };

let stringOrNumber: XOR<string, number>;
stringOrNumber = 14;
stringOrNumber = "foo";

let primitiveOrObject: XOR<string, Person>;

primitiveOrObject= "foo";
primitiveOrObject= { is: "NameOnly", name: "Foo" };
primitiveOrObject= { is: "FirstAndLastName", firstname: "Foo", lastname: "Bar" };

All 38 comments

The issue is there are no exact/final types in TS. all types are open ended. so this is allowed from an assignable perspective:

var p1: {name: string };
var p2 =  {name:"n", firstName: "f", lastName: "l"};
p1 = p2;  // OK

A type is assignable to a union type, iff it is assignable to one of the constituents. so even with the exclusive union, open types would allow that check to pass.

Another feature that TS has is flagging "unknown" properties. This only applies to object literals, that happen to have a contextual type. e.g.:

var p: {name: string} = { name: "n", another: "f" }; // Error `another` is not a known property

This check is simplified for union types, just to say it has to be a "known" property on the union in general, not for this constituent. The main issue here is how unions are compared, when we are comparing constituent types, we do not know if we should check for "unknown" properties, because it might be one of the other types.

If i am not mistaken, you are after this unknown property check in this case. I would say we are better off trying to change how this is handled for unions, rather than creating a new concept in the language that users need to understand.

Related discussion in https://github.com/Microsoft/TypeScript/issues/12997. Issue tracked by https://github.com/Microsoft/TypeScript/issues/12745

12745 will solve parts of this problem. But it won't have exclusive or logic because tagged unions are merging assignable types. For example:

interface A { foo: string; }
interface B { bar: string; }

type C = A | B;

const a: C = { foo: '' } // Wrongfully ✔ // Hopefully #12745 fixes this
const b: C = { bar: '' } // Wrongfully ✔ // Hopefully #12745 fixes this
const c: C = { foo: '', bar: '' } // ✔

What I'm suggesting is an exclusive or operator between two interfaces:

interface A { foo: string; }
interface B { bar: string; }

type C = A ^ B;

const a: C = { foo: '' } // ✔
const b: C = { bar: '' } // ✔
const c: C = { foo: '', bar: '' } // ❌

without exact types the new operator does not solve the issue, since { foo: '', bar: '' } is still a { foo: string; }

This would be really helpful, Allows better management in development time.

Presumably the behavior here would be that

type A = { m: T } ^ { n : U };

is equivalent to

type A = { m: T, n: undefined } | { m: undefined, n : U };

@RyanCavanaugh it makes it even easier to implement as de-sugar algorithm is so easy!

This operator is still a good idea since there are lots of cases where an API expect two or more completely different interfaces.

Adding undefined can pile up quickly:

type A = { m: T } ^ { n:  U } ^ { o: Q }

vs.

type A = 
    { m: T; n: undefined; o: undefined; } |
    { m: undefined; n: U; o: undefined; } |
    { m: undefined; n: undefined; o: Q; }

Search terms so I can find this later: "mutually exclusive" "disjoint unions"

I'd like to add another use case for this feature. I'm currently using json-schema-to-typescript to generate TypeScript interfaces from JSON Schema. Currently the "oneOf" keyword is listed as not expressible in TypeScript. Having exclusive unions would then make it expressible.

With this example:

type A = { m: T } ^ { n:  U } ^ { o: Q }
// ->
type A = 
    { m: T; n: undefined; o: undefined; } |
    { m: undefined; n: U; o: undefined; } |
    { m: undefined; n: undefined; o: Q; }

This actually doesn't work:

// Assume T, U, and Q are string:
let a = { // <-- error
  m: ""
}

Because it expects:

// Assume T, U, and Q are string:
let a = {
  m: "",
  n: undefined, // <-- expects undefined value
  o: undefined,  // <-- expects undefined value
}

You have to define it like this:

type A = 
    { m: T; n?: undefined; o?: undefined; } |
    { m?: undefined; n: U; o?: undefined; } |
    { m?: undefined; n?: undefined; o: Q; }

But that allows you to put n: undefined on the object, which makes the key enumerable. So far I've found this works out better:

type A = 
    { m: T; n?: never; o?: never; } |
    { m?: never; n: U; o?: never; } |
    { m?: never; n?: never; o: Q; }

I wish there was an ^ operator. I've been bitten by thinking | was supposed to be an exclusive OR. Thinking about bitwise it makes sense. But I almost always want ^ instead of |.

I'm all for the ^ sugar, that's great.

But I also think TS needs to recognize and leverage mutually-exclusive union patterns, regardless of the sugar. For example, { kind: 'foo'; } | { kind: 'bar'; } is mutually exclusive because kind cannot possibly be 'foo' and 'bar' simultaneously. Likewise with literal number types. And naturally, { m: T; n?: never; } | { m?: never; n: U; } under discussion as a de-sugared { m: T; } ^ { n: U; } is mutually exclusive as well. None of these types is mutually exclusive because of any specific syntax, they just are exclusive by their very nature.

But TS doesn't recognize or leverage that fact as strongly as it could. For examples, #20375 and #21879 could benefit from recognizing mutually exclusive unions to allow for narrowing (that would not be safe with mutually inclusive unions). So I think this request should be more than just sugar. I would expect something like declare const test: { kind: 'foo'; } & { kind: 'bar'; } to result in test: never, but it doesn't, and TS will happily allow you to pass test to anything that's expecting a { kind: 'foo'; } or anything that's expecting a { kind: 'bar'; }.

We already turn unit type contradictions ("foo" & "bar") into never during union/intersection type distribution.

Going further than that is somewhat dangerous because it means we'd produce never in a way that was fundamentally un-debuggable - imagine intersection two types and deep inside two uninteresting properties conflict and the whole thing collapses to never and you'd have no way to figure out why.

Also, sometimes you want to intersect two types in a way where you're using the part of it that isn't contradictory.

I agree with the debugging issue; having a way to investigate type inference would be really useful in general but I imagine that would be very difficult to offer.

But I still think never is the correct type for this case. Omit could be used, perhaps, to specify that you weren't interested in that bit that conflicts; certainly on our project, that would be an expectation that we'd have of our coders, to be explicit about something like that.

Finally, even if you really want to maintain { kind: 'foo'; } & { kind: 'bar'; }, test.kind should still have the type never. It currently has the type 'foo' & 'bar', which is, again, impossible.

It seems like this operator should result in closed types rather than open types like the union operator.

I think I figured this out. By introducing a Without generic that forces all properties in an object to not present we can construct a working XOR generic:

FYI @isiahmeadows

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

type NameOnly = { name: string };
type FirstAndLastName = { firstname: string; lastname: string };
type Person = XOR<NameOnly, FirstAndLastName>

const p1: Person = { name: "Foo" };
const p2: Person = { firstname: "Foo", lastname: "Bar" } ;

const bad1: Person = { name: "Foo", lastname: "Bar" }
const bad2: Person = { lastname: "Bar", name: "Foo" }                                            

Works in 2.7:

screen shot 2018-03-16 at 12 42 02 am

@mohsen1 Hat off! 🎩

I've tried to play with your solution a bit, and so far I haven't been able to find a solution that allows common property names to remain in the resulting type. Below a simple example:
screen shot 2018-03-16 at 12 15 02 pm

In the real life use cases I have for an exclusive OR type, objects generally have at least some properties in common.

I guess that joins the mutually exclusive concerns in the previous comments.

@SylvainEstevez Maybe this way?

carbon

@geovanisouza92 Yes that'd definitely work! Only issue is that it requires the Pizza and Sandwich types declaration to be split, which in real life would be pretty weird :/

@mohsen1 Do you need to include the intersection? I know yours is effectively the symmetric difference based on properties, but it seems people here were looking for something closer to a logical xor rather than a set-wise xor.

I agree, we need to address shared properties too. I've made an Intersection generic that can get us some good result but it doesn't work for all cases

type Without<T> = { [P in keyof T]?: undefined };
type Intersection<T, U> = { [P in keyof (T | U)]: T[P] | U[P] };
type XOR<T, U> = Intersection<T,U> | ((Without<T> & U) | (Without<U> & T));

type NameOnly = { name: string; height: number; };
type FirstAndLastName = { firstname: string; lastname: string; height: number; }
type Person = XOR<NameOnly, FirstAndLastName>
type HeightOnly = Intersection<NameOnly, FirstAndLastName>

const h: HeightOnly = { height: 100 }
const p1: Person = { name: "Foo", height: 1.8 };
const p2: Person = { firstname: "Foo", lastname: "Bar", height: 2.0 } ;

const bad1: Person = { name: "Foo", lastname: "Bar" }
const bad2: Person = { lastname: "Bar", name: "Foo" }                                            
const bad3: Person = { name: "Foo", lastname: "Bar", height: 10 } // 😵

I'm confused why bad3 is a member of Person type?

Using the upcoming TypeScript 2.8 Exclude type, what about:

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

type NameOnly = { is: "NameOnly", name: string };
type FirstAndLastName = { is: "FirstAndLastName", firstname: string; lastname: string };
type Person = XOR<NameOnly, FirstAndLastName>;

let person: Person;

person = { is: "NameOnly", name: "Foo" };
person = { is: "FirstAndLastName", firstname: "Foo", lastname: "Bar" };

@EliSnow wow! Conditional Types were answer to every TypeScript problem, weren't they?

I think Required is not necessary there. It can break optional members of T and U

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

type NameOnly = { name: string; height: number; weight?: number; };
type FirstAndLastName = { firstName: string; lastName: string; height: number; }
type Person = XOR<NameOnly, FirstAndLastName>

const p1: Person = { name: "Foo", height: 1.8 };
const p2: Person = { name: "bar", height: 1.3, weight: 100 }
const p3: Person = { firstName: "Foo", lastName: "Bar", height: 2.0 } ;

const bad1: Person = { name: "Foo", lastName: "Bar" }
const bad2: Person = { lastName: "Bar"}
const bad3: Person = { height: 10 }
const bad4: Person = { name: "Foo", lastName: "Bar", height: 10 }

screen shot 2018-03-16 at 10 05 08 am

We ask for new operators and random fancy features and @ahejlsberg is like here is "conditional types" go build those things yourself! 😁

A little more conditional types magic so it works with primitive types:

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;

type NameOnly = { is: "NameOnly", name: string };
type FirstAndLastName = { is: "FirstAndLastName", firstname: string; lastname: string };
type Person = XOR<NameOnly, FirstAndLastName>;
let person: Person;

person = { is: "NameOnly", name: "Foo" };
person = { is: "FirstAndLastName", firstname: "Foo", lastname: "Bar" };

let stringOrNumber: XOR<string, number>;
stringOrNumber = 14;
stringOrNumber = "foo";

let primitiveOrObject: XOR<string, Person>;

primitiveOrObject= "foo";
primitiveOrObject= { is: "NameOnly", name: "Foo" };
primitiveOrObject= { is: "FirstAndLastName", firstname: "Foo", lastname: "Bar" };

@EliSnow
Yup with primitives exclusiveness doesn't mean anything. Your latest iteration satisfy everything I asked in the original post.

@mohsen1 BTW, I beat you to it kind of already for the logical variant of the type.

23777 is an example of why this feature would be very nice without complex workarounds, which can cause performance issues.

@mohsen1 There is a problem in type guarding after object spreading:

function foo(f: NameOnly) { }
function bar(b: FirstAndLastName) { }

function test(p: Person) {
    const rest = {...p}
    if ('name' in rest) {
        foo(rest)       // (1)
    } else {
        bar(rest)       // (2)
    }
}

(1) report error and the type of rest is:

const rest: { name?: never; weight?: never; firstName: string; lastName: string; height: number; } | { firstName?: never; lastName?: never; name: string; height: number; weight?: number; }

(2) works as expected and the type of rest is

const rest: { name?: never; weight?: never; firstName: string; lastName: string; height: number; }

The offered types above seem like good fits for what we would have implemented anyway, and it doesn't seem like we really need sugar for them.

it doesn't seem like we really need sugar for them.

I'm not sure about that. This is a very common use-case, and we may have _a lot more_ than just 2 elements.
This leads to unreadable syntax:

type Element = XOR<Foo, XOR<Bar, XOR<Baz, Qux>>>

and the IDE integration is outright disgusting:

image

with an awesome error message:

Type '{ type: "foo"; bar: number; }' is not assignable to type '(Without<Foo, (Without<Bar, (Without<Baz, Qux> & Qux) | (Without<Qux, Baz> & Baz)> & Without<Baz, Qux> & Qux) | (Without<Bar, (Without<Baz, Qux> & Qux) | (Without<...> & Baz)> & Without<...> & Baz) | (Without<...> & Bar)> & Without<...> & Without<...> & Qux) | (Without<...> & ... 2 more ... & Baz) | (Without<...> & ...'.
  Type '{ type: "foo"; bar: number; }' is not assignable to type 'Without<(Without<Bar, (Without<Baz, Qux> & Qux) | (Without<Qux, Baz> & Baz)> & Without<Baz, Qux> & Qux) | (Without<Bar, (Without<Baz, Qux> & Qux) | (Without<Qux, Baz> & Baz)> & Without<...> & Baz) | (Without<...> & Bar), Foo> & Foo'.
    Type '{ type: "foo"; bar: number; }' is not assignable to type 'Without<(Without<Bar, (Without<Baz, Qux> & Qux) | (Without<Qux, Baz> & Baz)> & Without<Baz, Qux> & Qux) | (Without<Bar, (Without<Baz, Qux> & Qux) | (Without<Qux, Baz> & Baz)> & Without<...> & Baz) | (Without<...> & Bar), Foo>'.
      Types of property 'bar' are incompatible.
        Type 'number' is not assignable to type 'undefined'. [2322]

@rrousselGit

That type is wrong anyways. It should be like this, for what most expect:

// T is the type you're checking, and this evaluates to `T extends A ^ B ? T : never`
type XOR<T, A, B> = T extends A & B ? never : T extends A | B ? T : never;

But for most JSON APIs, you don't want an exclusive or - you just want a discriminated union. For your use cases, this is probably sufficient: type Element = FooElement | BarElement.

Indeed you're right, my previous example is covered by simple unions, as they were exclusive by default

But the argument still stands. XOR is common enough to be natively included

When we write:

function fn(Foo|Bar);

The expected behavior here is usually a XOR, nor a OR. As the developper likely want to express:

function fn(Foo);
function fn(Bar);

But this requires a lot of code and duplicates. So XOR operator makes it much easier, but then we have very bad compilations errors and IDE integration.

@rrousselGit Not my usual expectation, and discriminated unions are just that, unions. Suppose that Foo is type Foo = {type: "foo", foo: 1} and Bar is type Bar = {type: "bar", bar: 2}.

  • {type: "foo", foo: 1} | {type: "bar", bar: 2} succeeds if the value matches either variant.
  • No concrete value can match both variants at the same time, so {type: "foo", foo: 1} & {type: "bar", bar: 2} is equivalent to never.
  • Thus, values can only match one or the other.

It's a type-level design pattern taking advantage of unions and some simple logic to make the conjunction an impossibility. TS doesn't need anything to encapsulate this.

If they shouldn't overlap, don't include overlap. It's that simple. Most JSON APIs even know not to return discriminated unions (enums in JSON Schema parlance), so that's not an issue. The main issue is some server JSON APIs reject extra parameters rather than ignore them, but this is exceedingly rare in my experience with JS libraries.

It's a type-level design pattern taking advantage of unions and some simple logic to make the conjunction an impossibility. TS doesn't need anything to encapsulate this.

The thing is, most languages with union type have their | for discriminated unions, not inclusive ones.
This is very strange for typescript to use inclusive unions instead and ask users to implement their custom exclusive union. Especially considering discriminative unions is the most common usage.

Why would we ever want the following to be valid?

type Foo = { bar: number } | { foo: number };
var x: Foo = { bar: 42, foo: 42 }

This defeats the whole point of type guards:

function isBar(value: any): value is Bar {}
function isFoo(value: any): value is Foo {}

function something(value: Foo | Bar) {
  if (isBar(value)) {  }
  else {}
}
function something2(value: Foo | Bar) {
  if (isFoo(value)) {  }
  else {}
}

something and something2 actually have different behaviors, because it is theoretically possible for value to be both Foo and Bar.

Hence the XOR. But as shown before, it's poorly integrated with typescript.

@rrousselGit

The thing is, most languages with union type have their | for discriminated unions, not inclusive ones.

No, most such languages only let you specify multiple variants with tags. For example, Haskell's data Either a b = Left a | Right b is morally equivalent to TS's type Either<A, B> = {type: "Left", value: A} | {type: "Right", value: B}.

In some languages, you can still share a variable name across multiple patterns within a single case if the types match. Here's an example of this in Rust, using the built-in Result<T, E> enum that's basically an Either or Choice. (It's defined as enum Result<T, E> { Ok(T), Err(E) }.) This is morally equivalent to something like this in TS:

type Result<A, B> = {type: "Ok", value: A} | {type: "Err", value: B};

const result: Either<number, number> = {type: "Ok", value: 1};

switch (result.type) {
case "Ok": case "Err": console.log(`Result: ${result.value}`); break
}

The main (and really, only) difference here between TS and these other languages is that it understands the concept of a type union without a tag, based mostly in set theory (disjoint unions - disjoint sets, the sets merged via set union, are a special case where their symmetric difference is equivalent to their union). So if you were to define const result: Either<number, string> = ..., then the type of result.value would be number | string rather than just number. Most languages with strong type systems disallow that, but TS aims to be highly interoperable with JS, which often doesn't care about that. Note that with number | string, you can print it, but you can't just do result.value.replace(/foo/g, "bar") unless you check typeof result.value === "string" first. This is why it's not generally a problem that TS is a bit looser with its restrictions.


It's worth mentioning Go has no concept of a discriminated union and is purely structural (more so than even TS).

just sumbled uppon this ussue ass well and this is my solution for anybody who will land here after me:

// Omit<{ a: string, b: string }, "a">  ==> { b: string }
type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>

// PickOne<{ a: string, b: string }, "a"> ==> { a: string; b?: never };
type PickOne<T, K extends keyof T> = Pick<T, K> & { [P in keyof Omit<T, K>]?: never };

// base type with all properties
type M = { m: number, n: number, o: number };

// type that allows EXACTLY ONE property from M
type N = 
   | PickOne<M, "m">
   | PickOne<M, "n">
   | PickOne<M, "o">;

// OK
const n1:N = { m: 42 };
const n2:N = { n: 42 };
const n3:N = { o: 42 };

// Type '{ o: number; x: number; }' is not assignable to type 'N'.
//  Object literal may only specify known properties, and 'x' does not exist in type 'N'.
const n4:N = { o: 42, x: 42 };

//  Type '{ o: number; m: number; }' is not assignable to type 'N'.
//  Type '{ o: number; m: number; }' is not assignable to type 'PickOne<M, "o">'.
//    Type '{ o: number; m: number; }' is not assignable to type '{ m?: never; n?: never; }'.
//      Types of property 'm' are incompatible.
//        Type 'number' is not assignable to type 'never'.
// (same error goes for other conbinations of m, n and o properties)
const n5:N = { m: 42, n: 42 }; 

only thing, that i didnt figure out is generic declaration of type N:

type P = "m" | "n" | "o";
// same as M above, but uses unit P
type M = { [key in P]: number }

// this one will work only if typeof T === typeof M
// (second param of PickOne must be keyof T - dont know how to enumerate that into union...)
type PickExaclyOne<T> = 
   | PickOne<T, "m">
   | PickOne<T, "n">
   | PickOne<T, "o">;

// ideall way, how to define type N
type N = PickExaclyOne<M>

A little more conditional types magic so it works with primitive types:

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;

type NameOnly = { is: "NameOnly", name: string };
type FirstAndLastName = { is: "FirstAndLastName", firstname: string; lastname: string };
type Person = XOR<NameOnly, FirstAndLastName>;
let person: Person;

person = { is: "NameOnly", name: "Foo" };
person = { is: "FirstAndLastName", firstname: "Foo", lastname: "Bar" };

let stringOrNumber: XOR<string, number>;
stringOrNumber = 14;
stringOrNumber = "foo";

let primitiveOrObject: XOR<string, Person>;

primitiveOrObject= "foo";
primitiveOrObject= { is: "NameOnly", name: "Foo" };
primitiveOrObject= { is: "FirstAndLastName", firstname: "Foo", lastname: "Bar" };

Is this a bug? Live Demo

I've got an error in this sample of code with @EliSnow XOR type https://gist.github.com/inoyakaigor/a7ddca413753c4a3460544348f13f1e9

If change it to
type XOR<T, U> = (T | U) extends Object ? (Without<T, U> & U) | (Without<U, T> & T) : T | U; _(Object instead of object)_ fix it

What about long chains of XOR types? People might have use-cases (like I do) when you have to XOR more than 40 types.

This is how it looks:

export type Task =
    XOR<{ a: AModule },
      XOR<{b: BModule }, 
        XOR<{ c: CModule }, 
          XOR<{ d: DModule }, 
            XOR<{ e: EModule }, { f: FModule }> // ...
          >
        >,
      >
    >;

I would perfer to have:

XOR<
  { a: AModule },
  { b: BModule },
  { c: CModule },
  { d: DModule },
  { e: EModule },
  { f: FModule },
  ...
>

or

^ { a: AModule },
^ { b: BModule },
^ { c: CModule },
^ { d: DModule },
^ { e: EModule },
^ { f: FModule },
//  ...

Related https://github.com/krzkaczor/ts-essentials/issues/183

@sobolevn The former isn’t too hard to write in Typescript 4.1:

type AllXOR<T extends any[]> =
    T extends [infer Only] ? Only :
    T extends [infer A, infer B, ...infer Rest] ? AllXOR<[XOR<A, B>, ...Rest]> :
    never;
AllXOR<[
  { a: AModule },
  { b: BModule },
  { c: CModule },
  { d: DModule },
  { e: EModule },
  { f: FModule },
  ...
]>

Before 4.1, you don’t have conditional recursive types, which makes this a pain. There are often ways to get around that, but I don't know what you would need to do to satisfy the compiler there off the top of my head.

Even in 4.1, you will probably run into the “recursion is too long and possibly infinite” error pretty quickly—probably before you could XOR 40 types.

Was this page helpful?
0 / 5 - 0 ratings