TypeScript a | b allows combination of both

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, 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


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.

Tags:

Typescript