Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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!

like image 677
Tomáš Hübelbauer Avatar asked Sep 22 '17 17:09

Tomáš Hübelbauer


People also ask

How does proof of work prevent double-spending?

By incentivizing miners to verify the integrity of new crypto transactions before adding them to the distributed ledger that is blockchain, proof of work helps prevent double spending.

What is double-spending problem in banking?

Double-spending is a problem that arises when transacting digital currency that involves the same tender being spent multiple times. Multiple transactions sharing the same input broadcasted on the network can be problematic and is a flaw unique to digital currencies.

Why is 3 way handshake necessary?

TCP's three-way handshake has two important functions. It makes sure that both sides know that they are ready to transfer data and it also allows both sides to agree on the initial sequence numbers, which are sent and acknowledged (so there is no mistake about them) during the handshake.


2 Answers

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

like image 115
jcalz Avatar answered Sep 24 '22 05:09

jcalz


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.

like image 23
danvk Avatar answered Sep 24 '22 05:09

danvk