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 1:

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. 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).


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

Solution 2:

Another option is to use optional never properties to explicitly disallow a mix of fields from the two types in the union:

type sth =
  { value: number, data: string; note?: never; } |
  { value: number, note: string; data?: never; };

const a: sth = { value: 7, data: 'test' };
const b: sth = { value: 7, note: 'hello' };
const c: sth = { value: 7, data: 'test', note: 'hello' };
   // ~ Type '{ value: number; data: string; note: string; }'
   //     is not assignable to type 'sth'.

The ts-essentials library has an XOR generic that can be used to help you construct exclusive unions like this:

import { XOR } from 'ts-essentials';

type sth = XOR<
  { 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' };
// ~ Type '{ value: number; data: string; note: string; }'
//     is not assignable to type ...

Here's a playground link for that last example.

Solution 3:

This answer addresses how to compute validation of the assignment of a literal initializer ,e.g., { value: 7, data: 'test', note: 'hello' } to a union of object types, e.g., type sth={ value: number, data: string } | { value: number, note: string } without ignoring any unspecified excess properties.

The type function presented here is comparable to ExclusifyUnion in the above solution of @jcalz. However it is not simply another type function using the same input with slightly different coding. Instead the function presented here uses additional input, as explain below.

Adding type of literal initializer as an extra parameter to type function

Consider the statement:

type T1 = {<some props>}
type T2 = {<some props>}
type T3 = {<some props>}
type TU=T1|T2|T3
SomeTypeDef<T> = ...
const t:SomeTypeDef<TU> = {a:1,b:2}

The last line is an assignment statement. The processing taking place in the assignment has two distinct and separate parts:

  • The left hand side in isolation, which is the type function SomeTypeDef with the single input variable TU.
  • Determining the validity of the assignment of the r.h.s. literal initializer {<some props>} to the l.h.s type. That computation takes place using Typescript's fixed assignment rules that cannot be changed.

Now suppose we define an additional type

type I = {a:1,b:2}

which you will notice is the type of the literal initializer on the r.h.s. of the assignment. Now suppose we add that type as an additional variable to a type function on the l.h.s.:

const t:SomeTypeDefPlus<TU,I> = {a:1,b:2}

Now the l.h.s type function has additional information to work with . Therefore whatever SomeTypeDef<TU> can express, SomeTypeDefPlus<TU,I> can also express in the same length code. However SomeTypeDefPlus<TU,I> may express more things than SomeTypeDef<TU>, and/or may be able to express the same things in shorter code. In psuedo-psuedo code:

Expressability(SomeTypeDefPlus<TU,I>) >= Expressability(SomeTypeDef<TU>)

You should be objecting because

  • writing the type type I = {<some props>}, AND
  • and writing the r.h.s literal initializer .... = {<some props>}

is twice as much writing - a code length penalty. That's true. The idea is that - if it is worth it - a way would eventually be enabled to infer type I from the r.h.s initializer, e..g, a preprocess or a new typescript language feature. After all, it's kind of silly that the static information {<some props>} is right there, but cannot accessed due to a design artifice.

A demonstration of the code is given below, followed by a discussion.

// c.f. https://github.com/microsoft/TypeScript/issues/42997
// craigphicks Feb 2021
//-----------------------
// TYPES
type T1 = {a:number,b:number}
type T2 = {a:number,c:number}
type T3 = {a:string,c?:number}
type T4 = {a:bigint, [key:string]:bigint}
type T5 = {a:string, d:T1|T2|T3|T4}
type T12 = T1|T2|T3|T4|T5
//-----------------------
// TYPES INFERRED FROM THE INITIALIZER 
type I0 = {}
type I1 = {a:1,b:1}
type I2 = {a:1,c:1}
type I3 = {a:1,b:1,c:1}
type I4 = {a:1}
type I5 = {a:'2',c:1}
type I6 = {a:'2'}
type I7 = {a:1n, 42:1n}
type I8 = {a:'1', d:{a:1n, 42:1n}}
type I9 = {a:'1', d:{}}
//-----------------------
// THE CODE 
type Select<T,I>= {[P in keyof I]: P extends keyof T ?
  (T[P] extends object ? ExclusifyUnionPlus<T[P],I[P]> : T[P]) : never} 
type ExclusifyUnionPlus<T,I>= T extends any ? (I extends Select<T,I> ? T : never):never
//-----------------------
// case specific type aliases
type DI<I>=ExclusifyUnionPlus<T12,I>
// special types for se question https://stackoverflow.com/q/46370222/4376643
type sth = { value: number, data: string } | { value: number, note: string };
type DIsth<I>=ExclusifyUnionPlus<sth,I>
//-----------------------
// THE TESTS - ref=refuse, acc=accept
const sth0:DIsth<{ value: 7, data: 'test' }>={ value: 7, data: 'test' }; // should acc
const sth1:DIsth<{ value: 7, note: 'test' }>={ value: 7, note: 'test' }; // should acc
const sth2:DIsth<{ value: 7, data:'test', note: 'hello' }>={ value:7, data:'test',note:'hello' }; // should ref
type DI0=DI<I0> ; const d0:DI0={} // should ref
type DI1=DI<I1> ; const d1:DI1={a:1,b:1} // T1, should acc
type DI2=DI<I2> ; const d2:DI2={a:1,c:1} // T2, should acc
type DI3=DI<I3> ; const d3:DI3={a:1,b:1,c:1} // should ref
type DI4=DI<I4> ; const d4:DI4={a:1} // should ref
type DI5=DI<I5> ; const d5:DI5={a:'2',c:1}  // T3, should acc
type DI6=DI<I6> ; const d6:DI6={a:'2'}  // T3, should acc
type DI7=DI<I7> ; const d7:DI7={a:1n,42:1n}  // T4, should acc
type DI8=DI<I8> ; const d8:DI8={a:'1',d:{a:1n,42:1n}}  // T5, should acc
type DI9=DI<I9> ; const d9:DI9={a:'1',d:{}}  // should ref
//-------------------
// Comparison with type function NOT using type of intializer
// Code from SE  https://stackoverflow.com/a/46370791/4376643
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>>;
//-------------------
// case specific alias
type SU=ExclusifyUnion<T12>
// tests
const sd0:SU={} // should ref
const sd1:SU={a:1,b:1} // should acc
const sd2:SU={a:1,c:1} // should acc
const sd3:SU={a:1,b:1,c:1} // should ref
const sd4:SU={a:1} // should ref
const sd5:SU={a:'2',c:1}  // should acc
const sd6:SU={a:'2'}  // should acc
const sd7:SU={a:1n,42:1n}  // should acc
const sd8:SU={a:'1',d:{a:1n,42:1n}}  // should acc
const sd9:SU={a:'1',d:{}}  // should ref
// Apparently ExclusifyUnion doesn't handle addtional property speficier in T4
// Also does it handle deep objects?  Have posted message to ExclusifyUnion author, awaiting reply.

Typescript Playground

Discussion

The code recurses for deep object - ExclusifyUnionPlus<T,I> calls Select and Select then recursively calls ExclusifyUnionPlus<T[P],I[P]> when the properties are themselves objects.

Some edge cases are not included, e.g., member functions.

Tests

Test cases include

  • additional keys
  • deep object (only 2-levels though)

Conclusion

Apart from the requirement to enter the instance twice, the proposed paradigm (adding initializer type to the lhs function) was shown to function correctly for several test cases of detecting excess properties..

We can judge the practical value of adding initializer type to the l.h.s. type function by comparing ExclusifyUnion and ExclusifyUnionPlus according to these two criteria:

  • Ease and clarity:
  • Total range of expression:

As for 'ease and clarity' , ExclusifyUnionPlus does seems easier to code and comprehend. On the other hand, writing the initializer twice is inconvenient. I have submitted a proposal to Typescript issues suggesting that something like

const t:SomeTypeDefPlus<TU,I> = {a:1,b:2} as infer literal I

would be helpful.

As for 'total range of expression', that is not yet known.