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