TypeScript: Optional chaining with non null operator is unsafe, because it could throw an exception
TypeScript Version: 3.7.2
Search Terms: optional chaining, non null operator
Code
This input
a?.b!.c
will compile to
((_a = a) === null || _a === void 0 ? void 0 : _a.b).c;
But it’s unsafe, because if a is null or undefined, will throw an exception:
((_a = a) === null || _a === void 0 ? void 0 : _a.b).c
=> ((_a = null) === null || _a === void 0 ? void 0 : _a.b).c
=> (null === null || _a === void 0 ? void 0 : _a.b).c
=> (true ? void 0 : _a.b).c
=> undefined.c
IMO we should not raise this exception.
Expected behavior:
Would be better to compile to
(_a = a) === null || _a === void 0 ? void 0 : _a.b.c;
So a could be null or undefined and it’ll not raise an exception anymore.
Playground Link: Playground
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.
- https://github.com/microsoft/TypeScript/issues/34875
- https://github.com/microsoft/TypeScript/issues/35025
- https://github.com/microsoft/TypeScript/issues/35071
Also this bug was reported in Babel and already there is a PR to fix that in Babel:
In this issue I’m saying only about the output, not about the type system.
About this issue
- Original URL
- State: closed
- Created 4 years ago
- Reactions: 4
- Comments: 23 (10 by maintainers)
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:@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;).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.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.@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.
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?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.