Skip to content

Optional chaining union type with interfaces #35263

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
deser opened this issue Nov 21, 2019 · 38 comments
Closed

Optional chaining union type with interfaces #35263

deser opened this issue Nov 21, 2019 · 38 comments
Labels
Question An issue which isn't directly actionable in code

Comments

@deser
Copy link

deser commented Nov 21, 2019

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

@MartinJohns
Copy link
Contributor

MartinJohns commented Nov 21, 2019

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.

@deser
Copy link
Author

deser commented Nov 21, 2019

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

@MartinJohns
Copy link
Contributor

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.

@deser
Copy link
Author

deser commented Nov 21, 2019

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?

@MartinJohns
Copy link
Contributor

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.

@deser
Copy link
Author

deser commented Nov 21, 2019

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; .

@MartinJohns
Copy link
Contributor

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.

@deser
Copy link
Author

deser commented Nov 21, 2019

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.

@deser
Copy link
Author

deser commented Nov 21, 2019

image

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

@MartinJohns
Copy link
Contributor

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.

@deser
Copy link
Author

deser commented Nov 21, 2019

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.

@MartinJohns
Copy link
Contributor

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. :-)

@deser
Copy link
Author

deser commented Nov 21, 2019

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

@MartinJohns
Copy link
Contributor

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.

@deser
Copy link
Author

deser commented Nov 21, 2019

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.

@deser
Copy link
Author

deser commented Nov 21, 2019

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.

@MartinJohns
Copy link
Contributor

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.

@MartinJohns
Copy link
Contributor

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.

@deser
Copy link
Author

deser commented Nov 21, 2019

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?

@MartinJohns
Copy link
Contributor

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.

@deser
Copy link
Author

deser commented Nov 21, 2019

No problem. Sorry for taking your time

@deser
Copy link
Author

deser commented Nov 21, 2019

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 :)

@jcalz
Copy link
Contributor

jcalz commented Nov 21, 2019

Duplicate of #33736 ?

@deser
Copy link
Author

deser commented Nov 21, 2019

not fully sure

@RyanCavanaugh RyanCavanaugh added the Question An issue which isn't directly actionable in code label Nov 22, 2019
@RyanCavanaugh
Copy link
Member

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!

@mdbetancourt
Copy link

mdbetancourt commented Aug 31, 2020

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

@tfoxy
Copy link

tfoxy commented Nov 27, 2020

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.

@RyanCavanaugh
Copy link
Member

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.

@tfoxy
Copy link

tfoxy commented Dec 1, 2020

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!

@carltheperson
Copy link

If anyone else is looking for this, here is a hacky workaround.

type OptionalUnion<T1, T2> = {
	[P in keyof Omit<T1 & T2, keyof (T1 | T2)>]?: (T1 & T2)[P]
} & (T1 | T2)

interface Common {
    name: string
    age: number
}

interface A extends Common{
    address: string
}

interface B extends Common{
    city: string
}


type MyType = OptionalUnion<A, B>

const func = (arg?: MyType) => {
	// (property) B.city?: string | undefined
    const res = arg?.city
}

TS playground

@finnp
Copy link

finnp commented Feb 25, 2022

It's a bit odd to me to close this with the reasoning that this is intented behavior. It might be intented but it's not expected and there is currently no nice solution apart from just casting it to something specific.

The expectation is that on a Union type the optional chaining would only check that at least one of the options fullfills the chaining, not all of them.

Would love for this to be reopened. I think the examples on here already speak for themselves.

The 'string' in example solution also doesn't work if the union is something like SomeType | string.

@gajus
Copy link

gajus commented Jan 9, 2023

With type checking as is, simple code becomes unnecessarily convoluted, e.g.

What could be:

const assignmentKeyEqualsValue =
  parent?.key.name === parent?.value?.name;

becomes:

const assignmentKeyEqualsValue =
  'key' in parent &&
  'name' in parent.key &&
  'name' in parent.value &&
  parent.key.name === parent.value.name;

For no benefit as far as I can tell.

@KantiKuijk
Copy link

KantiKuijk commented Jan 8, 2024

Another way to solve this in a very type-safe way (it being 100.0% correct), would be to define the 'missing' properties on all the types that make up the union, setting them to never when not used. Thus for the very initial example it would be:

interface Common {
    name: string
    age: number
}

interface A extends Common{
    address: string
    city?: never
}

interface B extends Common{
    city: string
    address?: never // not strictly necessary for this example
}

type MyType = A | B

const func = (arg?: MyType) => {
    const res = arg?.city  // Works like expected and intended
}

This works because we have eliminated the possibility of it being any other type than string, when it does in fact exist.
Indeed, 'tricking' typescript by making use of the structural-type system is not possible anymore, since a sneaky type that would try to do this, wouldn't extend A nor B.

const myObj = {
    name: 'Bob',
    age: 50,
    address: 'Fake street',
    city: 200
};
func(myObj)  // Errors because it should

This is also more readable than the hacky workaround mentioned above imho (and did I mention it is more type-safe).
In cases where A and B are very unrelated or distant from one another, it might be weird to make them have seamingly random properties set to never. However, I'd like to think that in that case, it'd be just as weird to have a need for a union type of the two as well.

@MartinJohns
Copy link
Contributor

@KantiKuijk This is not "100 % correct ". A property typed never does not mean "property doesn't exist", it means "property throws upon access". It's legit to have a getter that throws an error.

@KantiKuijk
Copy link

@MartinJohns You are right. I was referring to this comment with my 100.0% sentence, I should have maybe skipped that pun.
If the distinction between a property not existing and a getter with a return value of never because it throws an error is important for your use case, this issue is likely below your pay grade anyways.
You might disagree with using never for a property that you want to 'disable', reserving it for 'throws error'. I think that is up to personal preference. For me, it is an explicit and readable way to indicate that if at a later time I want to use that same property, I need to double check that it doesn't cause runtime errors in unexpected places.

@jcalz

This comment was marked as resolved.

@KantiKuijk
Copy link

KantiKuijk commented Jan 8, 2024

My bad @jcalz, I changed my earlier comment, thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Question An issue which isn't directly actionable in code
Projects
None yet
Development

No branches or pull requests

10 participants