1

To make two properties of a type mutually exclusive, the common idiom I encountered is as follows:

type T = { x: number, y?: never } | { y: number, x?: never }

In this case only x or y can be set but not both. (The other approach are discriminating unions, but this requires extra property which doesn't really fit my case). The problem is when I want to make both x and y properties optional. If I use:

type T = { x?: number, y?: never } | { y?: number, x?: never }

This works fine as long as I set x or y during initialization. E.g.:

let a: T = { x: 1 };
a.y = 1; // Error

However if starting without x or y, it doesn't work as I expected:

let a: T = {};
a.x = 1; // OK
a.y = 1; // OK

I assumed that the 2nd line the compiler will narrow down the type and the 3rd line will raise an error, however this is not the case.

My question is why no error is raised above, since after the assignment the shape of "a" will not conform to any shape implied by type T?

Yevgeniy P
  • 1,480
  • 1
  • 15
  • 23
  • Unions allow unsound property writes in general, as shown [here](https://tsplay.dev/mZvGDm) without anything optional. I'm sure this has been mentioned somewhere in the TS GitHub repo issue list and it's just a design limitation of the language. I can't find it right now and I'm about to fall asleep so I'll try again later unless someone else finds something better first. – jcalz May 12 '23 at 04:23
  • I see, thanks. Sure, I ll wait. Perhaps there is some other way to make properties mutually exclusive that avoids this issue? – Yevgeniy P May 12 '23 at 04:31
  • I don't think so, but note that SO posts are supposed to ask a single question, not multiple ones that relate to the same code. Of the above 1 and 2, what's the primary question? I was assuming 1, but maybe it's 2? Either way, please [edit] to make it clear which question is the one whose answer is sought here. If you want both answered it would be best to make a new post for the other one. – jcalz May 12 '23 at 13:08
  • Ok, I edited the post to leave only the first question. – Yevgeniy P May 13 '23 at 01:11

1 Answers1

1

For better or worse, TypeScript's type system isn't fully sound. Full type safety is not a TypeScript language design goal (see #3) There's often a tradeoff between type safety and convenience, and there are some situations where TS opts for convenience.


One place this tends to show up is with property writes. TypeScript makes the convenient but unsafe assumption that object types are covariant in their property types. (See Difference between Variance, Covariance, Contravariance and Bivariance in TypeScript ​.) So you're allowed to narrow properties in subtypes, like:

interface Bar { 
  a: unknown; 
}

interface Baz extends Bar { 
  a: string; 
}

const baz: Baz = { a: "hello" };

const bar: Bar = baz; // okay

Here Baz is considered a subtype of Bar, and you can assign a value of type Baz to a variable of type Bar. So a Bar's a property can be anything at all, but a Baz's a property has to be a string. For property reads this is completely safe. But as soon as you write to a property of the supertype, you can easily do unsound things:

bar.a = 123; // okay?
baz.a.toUpperCase(); // RUNTIME ERROR!

That's unfortunate, but it's intentional.


This shows up with union types as well. For any types X and Y, X is a subtype of X | Y. That means if you have a value of type X | Y and you want to write a property to it, it will accept things that might not be safe for either X or for Y, as you showed in your example. Here's another example:

const foo = Math.random() < 0.5 ?
    { a: "abc", b: 123 } :
    { a: 123, b: "abc" };
/* const foo: 
   { a: string; b: number; } | 
   { a: number; b: string; } */
foo.a = 10; 
foo.b = 20; // no error, oops!

This is the same issue more or less. There is a recent suggestion at microsoft/TypeScript#54051 to change this behavior for unions specifically, but it's not clear if it will ever happen, especially given how pervasive the issues with mutability and subtyping are throughout the language.


So that's mostly the answer to why you don't get an error. But you might wonder why you do get an error when you initialize the union typed variable. The same error can be demonstrated here:

const qux: { a: string, b: number } | { a: number, b: string } = 
  { a: 123, b: "abc" };

/* const qux: { a: number; b: string; } */
qux.a = 10;
qux.b = 20; // error!

The reason is that variables of union types undergo assignment narrowing. If you assign a value to a variable of a union type, the compiler will temporarily narrow the apparent type of the variable to just those union members compatible with the assigned value. In the qux case above that's {a: number; b: string}, and so the assignment of the b property is an error. In essence, the apparent type of qux is not a union, and so you don't run into the same problem.

And no, assignment narrowing doesn't happen to parent objects when you set a property. Maybe that could happen in the future (and possibly would be one way to implement microsoft/TypeScript#54051) but for now it's not part of the language. The only way for assignment to narrow the type of a variable is by assigning a new value to the variable, not by mutating the existing value.

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360