Typescript: Optional chaining union type with interfaces

Created on 21 Nov 2019  路  30Comments  路  Source: microsoft/TypeScript

TypeScript Version: 3.7.2

Search Terms:
optional chaining
optional chaining union

Code

interface Common {
    name: string
    age: number
}

interface A extends Common{
    address: string
}

interface B extends Common{
    city: string
}


type MyType = A | B

const func = (arg?: MyType) => {
    const res = arg?.city  // city does not exist on type A
}

Expected behavior:
Optional chaining works well in this situation as arg might be of B type.

Actual behavior:
Fails with TS error city does not exist on type A.

Playground Link: http://www.typescriptlang.org/play/?ssl=1&ssc=1&pln=21&pc=1#code/JYOwLgpgTgZghgYwgAgMIHsC2n0mQbwChkTkQ5MIAuZAZzClAHNjS4nqyBXTAI2kIBfQoVCRYiFAEFkEAB6QQAE1posOEEVLI4SpVAi1aNeoxAtho8NHhJkAIVkKIy1Rmy4tpBMDABPEwZmIRFCfwAHFABZPwAVP0jkAF5kGQAfBxEEXHpkGC4QBGTkAAo4KCYAfhoY+MiASmSAPgJWEmyQXINVFPKqgDoffxDCIA

Related Issues: no

Question

Most helpful comment

And the example you've shown seems another bug:
https://www.typescriptlang.org/play/#code/JYOwLgpgTgZghgYwgAgMIHsC2n0mQbwFgAoZM5EOTCALmQGcwpQBzE85OF2igV0wBG0EgF8SJUJFiIUAQWQQAHpBAATemiw4QRUuTirVUCPXp1GzEG2JjiE8NHhJkAIQXKIajRmy5dHBGAwAE9zJlZRcTtiEIAHFABZYIAVYPjkAF5keQAfVyiEXEZkGF4QBEzkAAo4KBY6JNT4gEpMgD4CdnJCkHp0ABsIADp+9BYauuaAbkjogHo57Lx0AQArCAQwZDAACzgt4A1CzFj94AFB5AB3IJ3kSUcZbOq4NWRGtIhmgBoSBeQBLwtnsNHBkIEQshYlB0PEoJD0DBtp8+IJoEMSD1iphggB5NaVfzkSjUOgAchcKzJvz0ZC4PAArAAGGkcAxGExmZBkgBicAA1igLBAIGBqV0yBDQsgAExMpmiGbzRYASTFRywpzA50uN127xSn2+DHQ9y2CFeAJQp1MEFUnFGVgxxFK5SqOPxq2mUSAA

Typescript must complain about such situation, isn't it? :)

All 30 comments

But it would not be safe to assume that arg?.city is of type string | undefined.

interface C extends A {
    city: number;
}

C is assignable to MyType, but the city property is of type number.

In this case (if we are talking about type MyType = A | B | C) I believe it would be quite safe to assume that arg?.city is possibly (and that is function of ? I believe ) string | number | undefined.

I probably understand how union types work and why currently this behavior happens but all in all I have a feeling that I'm still correct with optional chaining at whole. Just try look at the example from the standpoint of data coming to the func: I have described that arg might be A or B or C so I'm doing nothing criminal when trying to get city via optional chaining as I'm not sure that arg has it and that is clearly defined in union type MyType

Sure, but then you also have somewhere:

interface D extends A { city: boolean; }

It is compatible to MyType, but the city property is of the type boolean, so it's not compatible with the type string | number | undefined.

TypeScript has structural typing, so you always have to assume there are more unknown properties. Assuming that city will be a specific type because it exists on some of the union types is not a safe assumption.

What you want is not type-safe.

Why it is not safe? Such construction in case when city is defined just narrows types for res, I don't ask typescript here to guess the type for the arg. I see no issue to for typescript assume that for const res = arg?.city res could be of type 'boolean | string | number | undefined' even if typescript don't preciesly know the type of arg at the moment. What is not type safe here?

Providing an example of what I mean, based on your code. No additional interfaces are declared.

const func = (arg?: MyType) => {
    // using a type-assertion to allow access of the property for demonstration purpose.
    const res = (arg as Partial<A & B>)?.city

    // So res is of type string | undefined

    // But when we check at runtime, it's actually number.
    // So continuing to use this res variable as a string would be a bad idea.
    console.log(typeof res)
}

// An object that is compatible with interface A (and MyType),
// but has a city property of type number.
const myObj = {
    name: 'Bob',
    age: 50,
    address: 'Fake street',
    city: 200
};

// It's compatible with MyType, so it can be passed along.
func(myObj);

With your suggestion you could access city, but what would be the type of that? The only type that would make sense to use is unknown, but I doubt you really want that. The only other type information available would be string, so should the type be string | undefined? But the object that was provided has a city property of type number, so the inferred type would not match with the data we actually have.

With your suggestion you could access city, but what would be the type of that? The only type that would make sense to use is unknown, but I doubt you really want that.

WHY ??? :) It's type string | boolean | number | undefined. It's not unknown. Knowing that func gets arg of type MyType I can clearly understand the type of a city IF IT EXISTS - it's type is all possible variants described, which is string | boolean | number | undefined.

// But when we check at runtime, it's actually number. // So continuing to use this res variable as a string would be a bad idea.
Not true. Typescript will calculate that it will be string | boolean | number | undefined and you will be as safe as if you'd just define in any other case const anyOtherVar:string | boolean | number | undefined; .

It's type string | boolean | number | undefined. It's not unknown.

Why do you think that? How could the compiler infer the number or boolean from the types available? All the compiler knows at that point is MyType, which can be either A or B, and the only available type for city in these two interfaces is string. It can't know about number or boolean.

Hm, so I was mislead by you. I thought you introduced C and D and added them to union type MyType like type MyType = A | B | C | D. As you haven't added them to union type then res as you said would be string | undefined, C and D doesn't matter, they are just types extended from A. In function func typescript works only with MyType which is A | B.

image

For this case typescript will throw error as number is not string | undefined

Hm, so I was mislead by you.

Yeah, I'm sorry about that. That's why I wrote "no additional interfaces are declared". :-)

Here's a link to the full example on the Playground.

For this case typescript will throw error as number is not string | undefined

What kind of error? This can't be a compilation error, because all types are fully compatible.

Hm.
Seems what you're showing is not quite correct from the viewpoint of the original question. If you're as a developer has redefined type for res (const res = (arg as Partial<A & B>)?.city) this is your decision as a developer. It doesn't play any role either with optional chaining or without it as you mislead typescript. Am I not correct?

We are talking about case when optional chaining can calculate type for city in union type which is still seems correct behavior after all our debates.

And the example you've shown seems another bug:
https://www.typescriptlang.org/play/#code/JYOwLgpgTgZghgYwgAgMIHsC2n0mQbwFgAoZM5EOTCALmQGcwpQBzE85OF2igV0wBG0EgF8SJUJFiIUAQWQQAHpBAATemiw4QRUuTirVUCPXp1GzEG2JjiE8NHhJkAIQXKIajRmy5dHBGAwAE9zJlZRcTtiEIAHFABZYIAVYPjkAF5keQAfVyiEXEZkGF4QBEzkAAo4KBY6JNT4gEpMgD4CdnJCkHp0ABsIADp+9BYauuaAbkjogHo57Lx0AQArCAQwZDAACzgt4A1CzFj94AFB5AB3IJ3kSUcZbOq4NWRGtIhmgBoSBeQBLwtnsNHBkIEQshYlB0PEoJD0DBtp8+IJoEMSD1iphggB5NaVfzkSjUOgAchcKzJvz0ZC4PAArAAGGkcAxGExmZBkgBicAA1igLBAIGBqV0yBDQsgAExMpmiGbzRYASTFRywpzA50uN127xSn2+DHQ9y2CFeAJQp1MEFUnFGVgxxFK5SqOPxq2mUSAA

Typescript must complain about such situation, isn't it? :)

For this case typescript will throw error as number is not string | undefined

It's just a "hack" to make it work with current features, but the result would be absolutely the same as with optional chaining.

We are talking about case when optional chaining can calculate type for city in union type which is still seems correct behavior after all our debates.

The only type information for city available from the union (as you provided it, A | B) is string (from B). No other type information is available. So the type of res would be string | undefined. But you can pass an object literal with a different city type to the function, as long as it's structurally compatible with A, and in that case you would have a different type at runtime and the compiler has no way to know about this within your function (as demonstrated by my example).


And the example you've shown seems another bug:
Typescript must complain about such situation, isn't it? :)

I really don't know what you think the bug is here. Please try to be more explicit in what exactly you think should be happening. It's all valid TypeScript code and works as it should.


I'm afraid I can't describe any simpler why this is a really bad idea and not type-safe at all. If you still don't understand the problem, then we'll have to wait for other people to chime in. :-)

Ok, thanks for your time and patience. Can we remain this issue open to allow others to chime in?:)

Sure, absolutely. I'm no moderator and not part of the TypeScript team.

I'd still love to know what you think is a bug and what should be happening: https://github.com/microsoft/TypeScript/issues/35263#issuecomment-557061630
Because I think there's a crucial misunderstanding.

But you can pass an object literal with a different city type to the function, as long as it's structurally compatible with A, and in that case you would have a different type at runtime and the compiler has no way to know about this within your function (as demonstrated by my example).

If thinking in terms of data then it's quite okay to assume that res is string | undefined further in code as if it is exists then arg is of type B then. If it doesn't exist then arg is of type A. Either way type of res doesn't contradict to any situation in that case.

Sure, absolutely. I'm no moderator and not part of the TypeScript team.

I'd still love to know what you think is a bug and what should be happening: #35263 (comment)
Because I think there's a crucial misunderstanding.

Sure. func expects MyType. So when arg has city then it's definitely B (as A doesn't have city declared). Following that logic city is string here, but number passed.

Sure. func expects MyType. So when arg has city then it's definitely B (as A doesn't have city declared). Following that logic city is string here, but number passed.

TypeScript has a structural type-system. So if the structure is compatible, it can be passed around.

func expects an argument of type MyType, and MyType is a union A | B. That means you can either pass A or pass B.

The object myObj is structurally compatible with the type A, because all properties of A are present with the correct type in myObj.

That's why it can be passed along just fine, because myObj qualifies as the type A, and func expects an argument of type A.

Yes but it's incompatible with B. So it's possibly incompatible with the entire MyType which means that typescript neglects that?

That doesn't matter. A union A | B says it must be either compatible with A or B. It doesn't have to be compatible with both.

That's why you can only access properties that are present in both types (A and B), and why the conditional operator won't let you access city.

And this is the fundamental reason why I'm against this issue, and why I think you're not understanding my concerns.

Thanks for explanation. But again, in this case as A doesn't have city then to my mind it's quite ok to assume that arg is of a type B then as it does in other cases like this http://www.typescriptlang.org/play/#code/JYOwLgpgTgZghgYwgAgMIHsC2n0mQbwChkTkQ5MIAuZAZzClAHNjS4nqyBXTAI2kIBfQoVCRYiFAEFkEAB6QQAE1posOEEVJkKnAOQBGPQG5WJOEqVQItWjXqMQLYaPDR4SZACFZCiMtUMbFwtUnJKGj0AJhMzZARgMABPewZmIRFCZIAHFABZJIAVJNzkAF5kGQAfbxEEXHpkGC4QBHLkAAo4KCYaAuLcgEpygD4CONoAd0SEAAtO7qYAOnCIYdDtEgQ4WhRDPSo4zdJrMC4oPD0AGzgTZAB6e+RF5GBVGVnoCCPSbd3kaIHH7HU7nS68G53R7PHqvVQ+T7WOLCFxAA.

If it can guess type by value of existing property why typescript can't guess type by presence of property?

But again, in this case as A doesn't have city

But at runtime it will have that property, so at runtime the conditional operator will access that property just fine. And at runtime it will have the wrong type.


Anyway, I'm out now. It's clear that I can't describe the issue comprehensible to you. :-( I'm sorry.

No problem. Sorry for taking your time

Thanks for explanation. But again, in this case as A doesn't have city then to my mind it's quite ok to assume that arg is of a type B then as it does in other cases like this http://www.typescriptlang.org/play/#code/JYOwLgpgTgZghgYwgAgMIHsC2n0mQbwChkTkQ5MIAuZAZzClAHNjS4nqyBXTAI2kIBfQoVCRYiFAEFkEAB6QQAE1posOEEVJkKnAOQBGPQG5WJOEqVQItWjXqMQLYaPDR4SZACFZCiMtUMbFwtUnJKGj0AJhMzZARgMABPewZmIRFCZIAHFABZJIAVJNzkAF5kGQAfbxEEXHpkGC4QBHLkAAo4KCYaAuLcgEpygD4CONoAd0SEAAtO7qYAOnCIYdDtEgQ4WhRDPSo4zdJrMC4oPD0AGzgTZAB6e+RF5GBVGVnoCCPSbd3kaIHH7HU7nS68G53R7PHqvVQ+T7WOLCFxAA.

If it can guess type by value of existing property why typescript can't guess type by presence of property?

btw, you might have missed this :)

Duplicate of #33736 ?

not fully sure

FYI people can comment on closed issues; a GitHub issue being closed does not prevent people from seeing it or commenting. We use open/closed to track "Is there concrete engineering work to do here".

Anyway after looking at the examples here, everything you've shown is the intended behavior. I think Stack Overflow or another venue might be a better forum for any future questions here - the issue tracker is for bugs; the odds that something that surprises you is because of a long-hidden bug in type relationships is really quite low. Hope that helps!

Providing an example of what I mean, based on your code. No additional interfaces are declared.

const func = (arg?: MyType) => {
    // using a type-assertion to allow access of the property for demonstration purpose.
    const res = (arg as Partial<A & B>)?.city

    // So res is of type string | undefined

    // But when we check at runtime, it's actually number.
    // So continuing to use this res variable as a string would be a bad idea.
    console.log(typeof res)
}

// An object that is compatible with interface A (and MyType),
// but has a city property of type number.
const myObj = {
    name: 'Bob',
    age: 50,
    address: 'Fake street',
    city: 200
};

// It's compatible with MyType, so it can be passed along.
func(myObj);

With your suggestion you could access city, but what would be the type of that? The only type that would make sense to use is unknown, but I doubt you really want that. The only other type information available would be string, so should the type be string | undefined? But the object that was provided has a city property of type number, so the inferred type would not match with the data we actually have.

i understood u means but isnt that a bug or problem with typescript? pls check this code
https://www.typescriptlang.org/play?#code/JYOwLgpgTgZghgYwgAgMIHsC2n0mQbwFgAoZM5EOTCALmQGcwpQBzE85OF2igV0wBG0EgF8SJUJFiIUAQWQQAHpBAATemiw4QRUuTirVUCPXp1GzEG2JjiE8NHhJkAIQXKIajRmy5dHBGAwAE9zJlZRcTtiEIAHFABZYIAVYPjkAF5keQAfVyiEXEZkGF4QBEzkAAo4KBYAfjok1PiASkyAPgJ2cgB6XuReelZOZDiIAFo4U2gwYFwx9E4AG2X0AHdOBCRTZHQYMYALFFiodHioEJL0KGRVCG0LODmF2N4oWPR6CAA6HrJ-shgAcanVkAAycHIADkgRC0KBeFqLHa-g45EKIHo6GWvzWLCq432nDqPzhwVagI4yLJQWCP2MsWWMiq0Lg0IANDDFNDKXoyLZbCR+tk8OgBAArCAIMBHZ5AjSFTCxZ7AAS45DrIKHRFSJxyapwNTIZppCCtDnCgYCXiyw7TUbk5Cnc6zYJ7A7jPiCaB-YiY4qYYIAeUllTRZEo1Do0Jc4s5gK4PAArAAGS38ziGYymGMAMTgAGsUBYIBAwAnM+S6AAmVOp0QAbiiIoAkhXFVgVXN1SgtWAdab4lzsUDZQgjcghM7pt9VCtcCw-aVylUg6GJa1m9EgA
compile fine but error at runtime because typescript has a "structural type-system" now to fix this is a breaking change

@RyanCavanaugh i think this should be a feature request and a bug fix

I was thinking the same that @mdbetancourt posted but with the first example.

interface Common {
    name: string
    age: number
}

interface A extends Common{
    address: string
}

interface B extends Common{
    city: string
}

interface C extends A {
  city: number;
}

type MyType = A | B

const func = (arg: MyType) => {
    const res = 'city' in arg? arg.city : undefined;
}

const c: C = {
    name: 'Bob',
    age: 50,
    address: 'Fake street',
    city: 200
};

func(c);

res here can be string | undefined, but in runtime it will receive a number. So it seems weird that not being able to do arg.city is because the type might be wrong, considering that it would be equivalent to 'city' in arg? arg.city : undefined.

The difference in behavior is intentional, because these are different syntaxes. Having different behavior do different things is how you can write different programs.

You might legitimately write

if (foo.length) {

as a shorthand for foo.length !== 0 && foo.length !== undefined and not realize that you're hitting an aliased member of foo, but there's no question what you're doing when you write this -

if ("length" in foo) {

Here it's obvious that you're trying to type-test, and even though it isn't 100.0% correct, if we stopped you from writing this the next thing you're going to do is clearly to write a type assertion, so there's really no point.

Oh I understand now. Makes sense that "length" in foo is used as a type-test and it helps in avoiding to write a type assertion, while in foo.length is not clear that it may be a type test. Thanks for your time and the explanation!

Was this page helpful?
0 / 5 - 0 ratings

Related issues

uber5001 picture uber5001  路  3Comments

zhuravlikjb picture zhuravlikjb  路  3Comments

DanielRosenwasser picture DanielRosenwasser  路  3Comments

siddjain picture siddjain  路  3Comments

blendsdk picture blendsdk  路  3Comments