You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Optional chaining with non null operator is unsafe, because it could throw an exception #36031
Related Issues: There was a lot of discussion here, but was more focused in the type system, not about the code output that raise wrongly an exception.
Is the precedence of !. vs. ?. defined somewhere? In this case it seems that it's being interpreted as (a?.b)!.c, which seems valid. The alternative might be better though, as I think it is impossible to specify via parens (a?.(b!.c)? a?(.b!.c)? ).
Jackson Kearl (@JacksonKearl) It is the explicit goal of TypeScript that the syntax should have no effect on the runtime behavior so you can’t just add a bracket out of nowhere.
Consider the following case:
functiongetElementById(id: string): Element|nullinterfaceElement{// Only null for Document, DOCTYPE, or a NotationtextContent: string|null}getElementById("div.head")?.textContent!.toUpperCase()
I'll look into it. While the syntax is valid to parse, it is a bit nonsensical. You're asserting that a?.b will always be defined, but that won't be the case when a is possibly undefined.
You're asserting that a?.b will always be defined, but that won't be the case when a is possibly undefined.
I disagree. I think I'm asserting that if a is not nullish, then it is guaranteed to have a non-nullish b property. @proteriax's example matches my feeling perfectly. If we desugar:
Ron Buckton (@rbuckton) The code should run the same with or without TS-specific syntax, right? Currently it won't; the output changes depending on the presence of the !. This is because TS (incorrectly) implicitly groups it as (a?.b)!.c, which through type-elision becomes (a?.b).c, which is clearly not the same as a?.b.c.
This further means that the code with run differently when targeting ESNext (a?.b.c;) vs anything else (((_a = a) === null || _a === void 0 ? void 0 : _a.b).c;).
Reacted by falsandtru, Bruno Macabeus, Alex and Grigory Streltsov
This option is inconsistent with the general TS design goal (as I understand it) that the code should always run the same with or without TypeScript-specific add-ons.
I don't think that's the right way to think about it because ultimately it's not really the same code, it's a distinct syntax that was parsed in a meaningfully different way. The way it's parsed typically implies a certain order of operations. If you want to desugar the current code and build a mental model around it, a?.b!.c is equivalent to
(a?.basNonNullable<typeofa>["b"]|undefined).c
which is downeleveled to
(a?.b).c
And that code does shorten the optional chain expression's reach due to the parenthesized expression.
So the question isn't about erasability because erasure is happening either way. It's about parsing precedence.
If we change the precedence of ! to be lower than OptionalChain, then that changes the type of x2 above to be number | undefined*, which will be surprising for users.
I think this is where we disagree. I expect number | undefined* as the result.
If I wanted to unconditionally assert the return type, I would have written (foo?.bar)!.baz to begin with.
Downleveling foo?.bar!.baz into (foo?.bar).baz is really weird. It's not meaningfully different than downleveling to foo.bar.baz. Whether .bar is the thing throwing (because foo is null) or .baz throws (because foo?.bar returned undefined) is useless.
Reacted by Daniel Rosenwasser, Alex and Bruno Macabeus
If I wanted to unconditionally assert the return type, I would have written (foo?.bar)!.baz to begin with.
Exactly, and given there's no way to represent what a?.b!.c looks like it means, wouldn't it make the most sense to have it default to "parse A", then users can add parens to force "parse B" -- rather than it defaults to "parse B", users can add parens to force "parse B", and "parse A" is unrepresentable?
I don't think that's the right way to think about it because ultimately it's not really the same code, it's a distinct syntax that was parsed in a meaningfully different way.
That's all well and good if you have the TS shift-reduce conflict resolution table memorized, but if you're a random developer who encounters an error like this:
Realizes that they know bar will be defined any time foo is, so adds a !. The error disappears and all seems well, but actually they've taken safe JS and transformed it into error-prone JS because of TS's "warning". This is a really quite bad developer experience.
given they knowbaz will be defined when bar is in this case (but the type system doesn't for whatever reason). The answer has always been add a !, but now that will actually add a runtime error that wasn't in the original code. And given there's no ?() syntax, and a as type assertion would have the same issues, I don't see how a fix is even representable with the current parse strategy.
Yes, that's understandable, but then the argument you're giving is developer experience, it's not that TypeScript isn't providing erasable syntax on top of JS.
Maybe it seems like I'm not empathizing with the problem you're running into (I am!). I'm just trying to clarify why this isn't inconsistent with our design goals and I'd like the arguments presented here to be accurate.
I think next steps will be to
bring this up at another design meeting to clarify whether this behavior is desirable
Ah, I knew there must be something but I blanked on what it was.
It's a poor developer experience because TS isn't providing erasable syntax.
Exactly. It's breaking the contract TS has set up with users that generally goes: "if I add TS syntax elements to my JS the JS will continue to run the exact same".
Are there any other examples in all of TS where adding some TS-specific syntax effects the emit? The only one I can think of is generators, and those at least are very explicit about being runtime features.
The "erasable syntax" case would be handled by forcing parens (e.g., (a?.b)!.c), since we would erase the ! (leaving (a?.b).c). It may not be the best developer experience, but ensures the semantics are well defined.
After our most recent design meeting, we've decided that our solution should be a slightly odd hybrid to satisfy both the a?.b!.c case and the a?.b.c! cases.
! will be special-cased so that when the next token could continue an optional chain, the ! will be parsed as part of the current chain expression (the behavior that users on this issue have wanted). Otherwise, it will still work "as expected" at the end of an optional chain expression and remove undefined from the resulting type of the entire chain.
Activity
DanielRosenwasser commentedon Jan 6, 2020
CC Ryan Cavanaugh (@RyanCavanaugh) Ron Buckton (@rbuckton)
JacksonKearl commentedon Jan 6, 2020
Is the precedence of
!.vs.?.defined somewhere? In this case it seems that it's being interpreted as(a?.b)!.c, which seems valid. The alternative might be better though, as I think it is impossible to specify via parens (a?.(b!.c)?a?(.b!.c)? ).alex-kinokon commentedon Jan 6, 2020
Jackson Kearl (@JacksonKearl) It is the explicit goal of TypeScript that the syntax should have no effect on the runtime behavior so you can’t just add a bracket out of nowhere.
Consider the following case:
JacksonKearl commentedon Jan 6, 2020
Ah my bad, yes I see what you mean.
https://www.typescriptlang.org/play/#code/DYUwLgBAhgXBDeEBGB+O8DGcB2BXAtkiAE4C+pEAPhLtgCYgBmAltiHQFAcYD22AzpEY8eEALzQUAOiQBCKRgDc3PoORRi4iAAoo0pAEp5SlQMhIoALy16ZC5UA
In this playground, adding the
!changes the emit, which it shoudn't.rbuckton commentedon Jan 8, 2020
I'll look into it. While the syntax is valid to parse, it is a bit nonsensical. You're asserting that
a?.bwill always be defined, but that won't be the case whenais possiblyundefined.jridgewell commentedon Jan 8, 2020
I disagree. I think I'm asserting that if
ais not nullish, then it is guaranteed to have a non-nullishbproperty. @proteriax's example matches my feeling perfectly. If we desugar:JacksonKearl commentedon Jan 8, 2020
Ron Buckton (@rbuckton) The code should run the same with or without TS-specific syntax, right? Currently it won't; the output changes depending on the presence of the
!. This is because TS (incorrectly) implicitly groups it as(a?.b)!.c, which through type-elision becomes(a?.b).c, which is clearly not the same asa?.b.c.This further means that the code with run differently when targeting ESNext (
a?.b.c;) vs anything else (((_a = a) === null || _a === void 0 ? void 0 : _a.b).c;).5 remaining items
DanielRosenwasser commentedon Feb 5, 2020
I don't think that's the right way to think about it because ultimately it's not really the same code, it's a distinct syntax that was parsed in a meaningfully different way. The way it's parsed typically implies a certain order of operations. If you want to desugar the current code and build a mental model around it,
a?.b!.cis equivalent towhich is downeleveled to
And that code does shorten the optional chain expression's reach due to the parenthesized expression.
So the question isn't about erasability because erasure is happening either way. It's about parsing precedence.
jridgewell commentedon Feb 5, 2020
I think this is where we disagree. I expect
number | undefined*as the result.If I wanted to unconditionally assert the return type, I would have written
(foo?.bar)!.bazto begin with.Downleveling
foo?.bar!.bazinto(foo?.bar).bazis really weird. It's not meaningfully different than downleveling tofoo.bar.baz. Whether.baris the thing throwing (becausefooisnull) or.bazthrows (becausefoo?.barreturnedundefined) is useless.JacksonKearl commentedon Feb 5, 2020
Exactly, and given there's no way to represent what
a?.b!.clooks like it means, wouldn't it make the most sense to have it default to "parse A", then users can add parens to force "parse B" -- rather than it defaults to "parse B", users can add parens to force "parse B", and "parse A" is unrepresentable?JacksonKearl commentedon Feb 5, 2020
Daniel Rosenwasser (@DanielRosenwasser)
That's all well and good if you have the TS shift-reduce conflict resolution table memorized, but if you're a random developer who encounters an error like this:
https://www.typescriptlang.org/play/?ssl=3&ssc=2&pln=1&pc=1#code/BQMw9mBcAEB2CuAbR0A+0De0BGBDATgPwxZ4BeMwAlNALwB8mAvtC0zQ5gLABQ0-0cGEIA6PPjG4y1XkyA
Realizes that they know
barwill be defined any timefoois, so adds a!. The error disappears and all seems well, but actually they've taken safe JS and transformed it into error-prone JS because of TS's "warning". This is a really quite bad developer experience.And this isn't just some hypothetical example, VSCode 1.42 will ship tomorrow with an instance of this exact bug: https://github.com/microsoft/vscode/blob/master/src/vs/base/browser/ui/tree/asyncDataTree.ts#L272
How would you propose people address the
cant invoke an object which is possibly undefinederror in the below:given they know
bazwill be defined whenbaris in this case (but the type system doesn't for whatever reason). The answer has always been add a!, but now that will actually add a runtime error that wasn't in the original code. And given there's no?()syntax, and aastype assertion would have the same issues, I don't see how a fix is even representable with the current parse strategy.This is my best shot:
...which is to say, get rid of the optional chaining entirely.
DanielRosenwasser commentedon Feb 5, 2020
Yes, that's understandable, but then the argument you're giving is developer experience, it's not that TypeScript isn't providing erasable syntax on top of JS.
Maybe it seems like I'm not empathizing with the problem you're running into (I am!). I'm just trying to clarify why this isn't inconsistent with our design goals and I'd like the arguments presented here to be accurate.
I think next steps will be to
mAAdhaTTah commentedon Feb 5, 2020
There is, but it's
foo?.().I'd suggest it's both: It's a poor developer experience because TS isn't providing erasable syntax.
JacksonKearl commentedon Feb 5, 2020
Ah, I knew there must be something but I blanked on what it was.
Exactly. It's breaking the contract TS has set up with users that generally goes: "if I add TS syntax elements to my JS the JS will continue to run the exact same".
Are there any other examples in all of TS where adding some TS-specific syntax effects the emit? The only one I can think of is generators, and those at least are very explicit about being runtime features.
From TS docs:
rbuckton commentedon Feb 6, 2020
The "erasable syntax" case would be handled by forcing parens (e.g.,
(a?.b)!.c), since we would erase the!(leaving(a?.b).c). It may not be the best developer experience, but ensures the semantics are well defined.JacksonKearl commentedon Feb 6, 2020
Ron Buckton (@rbuckton) I think that's the best option if changing the precedence is too high-impact.
DanielRosenwasser commentedon Feb 14, 2020
After our most recent design meeting, we've decided that our solution should be a slightly odd hybrid to satisfy both the
a?.b!.ccase and thea?.b.c!cases.!will be special-cased so that when the next token could continue an optional chain, the!will be parsed as part of the current chain expression (the behavior that users on this issue have wanted). Otherwise, it will still work "as expected" at the end of an optional chain expression and removeundefinedfrom the resulting type of the entire chain.JacksonKearl commentedon Feb 14, 2020
Love it.
falsandtru commentedon Mar 4, 2020
A bug of CFA #36958 should be fixed at the same time or the patch should be possible easily to be fixed after.