Search code examples
typescriptxortypescript-types

Why does A | B allow a combination of both, and how can I prevent it?


I was surprised to find that TypeScript won't complain at me doing something like this:

type sth = { value: number, data: string } | { value: number, note: string };
const a: sth = { value: 7, data: 'test' };
const b: sth = { value: 7, note: 'hello' };
const c: sth = { value: 7, data: 'test', note: 'hello' };

I thought maybe value was picked out as a type union discriminant or something, because the only thing that I could come up with to explain this was if TypeScript somehow understood number here to be a superset of 1 | 2 for example.

So I changed value to be value2 on the second object:

type sth = { value: number, data: string } | { value2: number, note: string };
const a: sth = { value: 7, data: 'test' };
const b: sth = { value2: 7, note: 'hello' };
const c: sth = { value: 7, data: 'test', note: 'hello' };

Still, no complaint, and I'm able to construct c. IntelliSense breaks down on c though, it won't suggest anything when I . into it. Same if I change value in c to be value2.

Why doesn't this produce an error? Clearly, I have failed to provide one type or the other and instead provided a weird mix of both!


Solution

  • The discussion in issue Microsoft/TypeScript#14094 is relevant here.

    Types in TypeScript are open in the sense that an object has to have at least the properties described by a type for it to match. So the object { value: 7, data: 'test', note: 'hello' } matches the type { value: number, data: string }, even though it has that excess note property:

    const obj = { value: 7, data: 'test', note: 'hello' };
    const val: sthA = obj; // okay
    

    So your c variable is indeed a valid sth. It would only fail to be a sth if it were missing all properties required by some constituent of the union:

    // error: missing both "data" and "note"
    const oops: sth = { value: 7 };  
    

    However: when you are assigning a fresh object literal to a typed variable in TypeScript, it performs excess property checking to try to prevent errors. This has the effect of "closing" TypeScript's open types for the duration of that assignment. This works as you expect for interface types. But for unions, TypeScript currently (as mentioned in this comment) only complains about properties that don't appear on any of the consituents. So the following is still an error:

    // error, "random" is not expected:
    const alsoOops: sth = { value: 7, data: 'test', note: 'hello', random: 123 };
    

    But TypeScript currently doesn't do excess property checking on union types in the strict way that you want, where it checks the object literal against each constituent type and complains if there are extra properties in all of them. It does do this with discriminated unions, as mentioned in microsoft/TypeScript#12745, but that doesn't address your issue because neither definition of sth is discriminated (meaning: having a property whose literal type picks out exactly one constituent of the union).

    There is a suggestion at microsoft/TypeScript#20863 to perform excess property checks on all unions, but it's not part of the language yet.


    So, until and unless this is changed, the best workaround for you is probably to avoid unions when using object literals by assigning explicitly to the intended constituent and then widening to the union later if you want:

    type sthA = { value: number, data: string };
    type sthB = { value: number, note: string };
    type sth = sthA | sthB;
    
    const a: sthA = { value: 7, data: 'test' };
    const widenedA: sth = a;
    const b: sthB = { value: 7, note: 'hello' };
    const widenedB: sth = b;
    const c: sthA = { value: 7, data: 'test', note: 'hello' }; // error as expected
    const widenedC: sth = c; 
    const cPrime: sthB = { value: 7, data: 'test', note: 'hello' }; // error as expected
    const widenedCPrime: sth = cPrime; 
    

    If you really want to express an exclusive union of object types, you can use mapped and conditional types to do so, by turning the original union into a new one where each member explicitly prohibits extra keys from the other members of the union by adding them as optional properties of type never (which shows up as undefined because optional properties can always be undefined):

    type AllKeys<T> = T extends unknown ? keyof T : never;
    type Id<T> = T extends infer U ? { [K in keyof U]: U[K] } : never;
    type _ExclusifyUnion<T, K extends PropertyKey> =
        T extends unknown ? Id<T & Partial<Record<Exclude<K, keyof T>, never>>> : never;
    type ExclusifyUnion<T> = _ExclusifyUnion<T, AllKeys<T>>;
    

    Armed with that, you can "exclusify" sth into:

    type xsth = ExclusifyUnion<sth>;
    /* type xsth = {
        value: number;
        data: string;
        note?: undefined;
    } | {
        value: number;
        note: string;
        data?: undefined;
    } */
    

    And now the expected error will appear:

    const z: xsth = { value: 7, data: 'test', note: 'hello' }; // error!
    /* Type '{ value: number; data: string; note: string; }' is not assignable to
     type '{ value: number; data: string; note?: undefined; } | 
     { value: number; note: string; data?: undefined; }' */
    

    Playground link to code