Discriminated union types are limited to only one literal type property. This can be very useful sometimes when the same of objects are complex.
interface A1 {
type: 'a';
subtype: 1;
}
interface A2 {
type: 'a';
subtype: 2;
foo: number;
}
interface B {
type: 'b';
}
type AB = A1 | A2 | B;
const ab: AB = <AB>{};
if (ab.type === 'a') {
// ab is A1 | A2, awesome! ♥
if (ab.subtype === 2) {
// ab is still A1 | A2 :( it should be A2
ab.foo; // Error: foo doesn't exist on A1 | A2
}
}
Expected behavior:
compile successful.
Actual behavior:
won't compile without explicit cast.
You _can_ have multiple discriminants, but we only consider a property to be a discriminant property if it is present in _all_ constituents of the declared type. If you add a subtype property to B it all works:
interface A1 {
type: 'a';
subtype: 1;
}
interface A2 {
type: 'a';
subtype: 2;
foo: number;
}
interface B {
type: 'b';
subtype: undefined;
}
It would be nice if we didn't need this rule, but it makes it much harder for the control flow analyzer to know what to pay attention to. Specifically, a property would only be a discriminant _sometimes_, depending on what happened earlier in the control flow, and that becomes much harder to track.
What about the code above but with:
type A = A1 | A2;
type AB = A | B;
Might that be more reasonably expected to work? I could imagine in a real project, A might be defined in a separate file with no knowledge that it gets used elsewhere in the A|B union.
@yortus No that won't work either, I tried all alternatives. Besides as long as the type-checker is concerned, (A | B) | C <==> A | (B | C) <==> A | B | C
@ahejlsberg I wish I had a deeper understanding of how the type-checker works. But with my limited knowledge I can see that AB was successfully narrowed to A1 | A2. And using A1 | A2 directly ( not after one level of narrowing ) works as intended. I mean:
// same as ab after the first if O.o
type A12 = A1 | A2;
const a12 = <A12>{};
if (a12.subtype === 2) {
a12.foo; // Ok
}
This really tickles my brain. How is narrowed union type A1 | A2 from A1 | A2 | B any different from type declared A1 | A2? Can't the type-checker re-evaluate the possibilities of another Discriminated Union type after each narrowing? There shouldn't be a need to support more properties to be used in a discrimination. Maybe a simple recursive approach would fix it. If at all possible, I'm not familiar enough with the implementation details unfortunately :disappointed:
@ahejlsberg why are we limited to the declared type here instead of the current narrowed type?
Using the declared rather than the narrowed type does lead to some non-orthogonal behaviour:
type A = {type: 'x', x: number} | {type: 'y', y: number};
function foo(a: A) {
a // type is A
return a.type === 'x' ? a.x : a.y; // OK <===== (1)
}
function bar(a: A | Date) {
if (!(a instanceof Date)) {
foo(a); // OK
a // type is A
return a.type === 'x' ? a.x : a.y; // ERROR <===== (2)
}
}
Note that (1) compiles but (2) does not, however:
a is A at both (1) and (2)@ahejlsberg I think this isn't working as intended, because a lot of JSON schemas have unions and unions of unions and etc. And this issue prevents creating good types for working with them in TS. For example in swagger 2.0 Parameter is union of entities like PathParameter, QueryParameter, etc. and they could be unions of different basic schemas for different types (arrays, strings, etc.).
@DanielRosenwasser problem seems to happen in several cases.
Example 1 (union inside another union):
interface BasicA {
type: 'A';
a: string;
}
interface BasicB {
type: 'B';
b: string;
}
interface BasicC {
type: 'C';
c: string;
}
interface SubtypeD {
subtype: 'D';
d: string;
}
interface SubtypeE {
subtype: 'E';
e: string;
}
type D = (SubtypeD & BasicA) | (SubtypeD & BasicB);
type E = (SubtypeE & BasicA) | (SubtypeE & BasicB);
type Test = BasicC | D | E;
function foo(bar: Test) {
switch (bar.type) {
case 'A':
console.log(bar.a);//OK
if (bar.subtype === 'D') {
console.log(bar.d);//Compile ERROR, but have to be OK
} else {
console.log(bar.e);//Compile ERROR, but have to be OK
}
break;
case 'B':
console.log(bar.b);//OK
if (bar.subtype === 'D') {
console.log(bar.d);//Compile ERROR, but have to be OK
} else {
console.log(bar.e);//Compile ERROR, but have to be OK
}
break;
case 'C':
console.log(bar.c);//OK
break;
default:
break;
}
}
It has two workarounds:
D | E. It reduces readability and in more complex situation will lead to creating a lot of extra functions only to make compiler work.BasicC asinterface BasicC {
type: 'C';
c: string;
subtype: undefined;
}
This solution in most cases is better, but leads to tight coupling.
NOTE
It seems that control flow based type analyzer works with unions as with discriminant unions ONLY in case if DECLARED type is discriminant union, but not when narrowed type is. So I guess it could be solved with some sort of recursion.
Example 2 (union isn't top operator when used with intersection in definition of type):
interface BasicA {
type: 'A';
a: string;
}
interface BasicB {
type: 'B';
b: string;
}
interface BasicC {
c: string;
}
type Test = BasicC & (BasicA | BasicB);
function foo(bar: Test) {
if (bar.type === 'A') {
console.log(bar.a);//Compile ERROR, but have to be OK
} else {
console.log(bar.b);//Compile ERROR, but have to be OK
}
}
But this works as expected:
type Test = (BasicC & BasicA) | (BasicC & BasicB);
Only issue here is that first syntax has better readability.
NOTE
This is probably another issue.
P.S.
I can try to create test cases for this issue and make a PR if it will help.
@Igmat your example assumes that all types are effectively "sealed", which isn't a safe assumption in general. There's nothing that prevents a subtype of BasicA from _also_ having a subtype field of some arbitrary type.
Fix for the OP problem now available in #11198.
Most helpful comment
Using the declared rather than the narrowed type does lead to some non-orthogonal behaviour:
Note that (1) compiles but (2) does not, however:
aisAat both (1) and (2)