Search code examples
typescriptunion-types

How to define a true logical OR of object types (no mixing of different object keys in result)


An example:

type TA = {a:number,b:number}
type TB = {a:number,c:number,d:number}
const t1:Or<TA,TB> = {a:1,b:1} // want
const t2:Or<TA,TB> = {a:1,c:1,d:1} // want 
const t3:Or<TA,TB> = {a:1,b:1,c:1} // DON'T want 

The desired result is for t1 to be valid because it exactly fits TA, and t2 to be valid because it exactly fits TB, but for t3 to be invalid because it doesn't exactly fit either TA or TB.

When

type Or<T,U>=T|U

TypeScript actually considers t3 to be valid. TypeScript union type | is also allowing keys objects to be merged and sometimes partial objects.

Placing each type in a single element array as follows:

type T0 = {a:number,b:number}
type T1 = {a:number,c:number,d:number}
type Test=[T0]|[T1]
const t1:Test=[{a:1,b:1,}]  
const t2:Test=[{a:1,c:1,d:1}]  
const t3:Test=[{a:1,b:1,c:1}] // fails as desired

works, but the object under test also has to be placed in a single-element array.

Is there any way to get around that?


Solution

  • The Why

    In TypeScript, | is not an operator*, it denotes that the type is a union of lefthand and righthand-side types. This is important to understand if you are to grasp why {a:number,b:number} | {a:number,c:number,d:number} allows {a:number,b:number,c:number}.

    When you declare a union, you tell the compiler that a type assignable to the union should be assignable to at least one member of it. With this in mind, let's check the {a:number,b:number,c:number} type from this point of view.

    The lefthand side member of the union is {a:number,b:number}, which means that types assignable to it must have at least 2 properties of type number: a and b (there is a notion of excess property checks for object literals, but, as already mentioned by T.J. Crowder, this is inapplicable for unions). From the handbook**:

    the compiler only checks that at least the ones required are present and match the types required

    Thus, since {a:number,b:number,c:number} is assignable to {a:number,b:number} no more checks are needed - the type satisfies at least one requirement of the union. Btw, this behavior is perfectly in line with the truth table of the logical OR, which is analogous to what a union is.


    Your attempt to resolve this by wrapping the types into tuples relies on naked vs. wrapped type parameter behavior. Because you wrapped the types in tuples, the compiler compares tuples of one element to each other. Obviously, the third tuple is not the same as the first and the second one, which gives you the desired result.


    The What

    What you actually want is the behavior exhibited by logical XOR: one of, but not both. Apart from using tagged types (mentioned by T.J. Crowder), one can define a utility transforming a pair of types into a union of "all props from A that are present in both, but not in A alone" types:

    type XOR<A,B> = ({ [ P in keyof A ] ?: P extends keyof B ? A[P] : never } & B) | ({ [ P in keyof B ] ?: P extends keyof A ? B[P] : never } & A);
    

    And here is how it would work (the trade-off of the utility is that the excess property is leaked to intellisense, but one is immediately disallowed to specify it due to never):

    const t0:XOR<TA,TB> = {a:1} //property 'b' is missing
    const t1:XOR<TA,TB> = {a:1,b:1} // OK
    const t2:XOR<TA,TB> = {a:1,c:1,d:1} // OK 
    const t3:XOR<TA,TB> = {a:1,b:1,c:1} // types of property 'c' are incompatible
    

    Playground


    * The notion of | being an operator was present in the first revision and was later edited out

    ** It has to be noted that this does not mean all checks shortcircuit when a match is found. In this case, all members of the union are object literals themselves, so the constraint on known properties still applies to each, leading to the TS2322 error if an unknown property is present during assignment:

    const t4:XOR<TA,TB> = {a:1,b:1,c:1, g:3} //Object literal may only specify known properties