Tomáš Hübelbauer Tomáš Hübelbauer - 1 month ago 13
TypeScript Question

TypeScript a | b allows combination of both

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!

Answer Source

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 (as of v2.5) 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. In TypeScript 2.6 it looks like there will be improved excess property checking on discriminated unions, which will mitigate this. I am not sure if that will work for your cases, though, 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; 

Hope that helps. Good luck!