TypeScript: Optional chaining of unknown should be unknown
Search Terms
Optional chaining, unknown
Suggestion
Currently it is forbidden to use optional chaining on unknown. But for all possible types in JavaScript, optional chaining will return undefined if it does not apply or cannot find the field. So we should safely regard optional chaining of unknown type to be unknown as well.
This is the verification of optional chaining on all possible types in JavaScript:
const num = 5;
const str = '6';
const undef = undefined;
const nul = null;
const bool = true;
const sym = Symbol('sym');
const obj = {};
const func = () => {};
console.log(num?.field); // print undefined
console.log(str?.field); // print undefined
console.log(undef?.field); // print undefined
console.log(nul?.field); // print undefined
console.log(bool?.field); // print undefined
console.log(sym?.field); // print undefined
console.log(obj?.field); // print undefined
console.log(func?.field); // print undefined
Use Cases
This is to simplify type checking on nested object with unknown type. For example, we should be able to do something like this:
function getFieldFromJson(json: unknown): undefined | number {
const field = json?.fieldA?.fieldB?.fieldC; // <--- this
if (typeof field === 'number') {
return field;
}
}
Currently we will need to cast explicitly on someUnknown, someUnknown.fieldA, someUnknown.fieldA.fieldB and someUnknown.fieldA.fieldB.fieldC. People will find this is unnecessary verbose and may just fallback to use any.
Examples
As above
Checklist
My suggestion meets these guidelines:
- This wouldn’t be a breaking change in existing TypeScript/JavaScript code
- This wouldn’t change the runtime behavior of existing JavaScript code
- This could be implemented without emitting different JS based on the types of the expressions
- This isn’t a runtime feature (e.g. library functionality, non-ECMAScript syntax with JavaScript output, etc.)
- This feature would agree with the rest of TypeScript’s Design Goals.
About this issue
- Original URL
- State: closed
- Created 4 years ago
- Reactions: 54
- Comments: 40 (14 by maintainers)
What I do, is upcast to a conservative version of the type I expect.
In your JSON decoding example, I would do:
The compiler enforces the use of the
?.operator ‒ the.operator is not allowed, unlike withany.If you would like the compiler to enforce the use of the
typeofoperator as well, you could do:It’s a bunch of extra characters, but comes with the advantage of only allowing access to properties you declared as potentially existing. (No
?.toStirng.)I was just saying that’s the best argument against, not that I was a good argument against lol.
I’m personally advocating for optional chaining to be allowed on unknown for mathematical reasons. Set theory, the basis of the type system itself, and the nature of the
?.operator seem to indicate optional chaining should be allowed. It both provides justification for its use, and a concrete ground to argue against it being expanded to other operators, thus avoiding the slippery slope that gets bandied about.The case of typos is a valid concern, but it becomes a question of what’s more important: a logically consistent type system or avoiding a class of run-time errors. The type system is supposed to help avoid the latter, but does that make it less important? I don’t think so, but others clearly disagree.
But even the argument about avoidance of runtime errors has to be taken as what actually is going to prevent more run time errors, not what might. That ignores the human element. If more of the community stops casting to any because of optional chaining on unknown, that’s safer code.
I think you can’t mention this often enough, as it outweighs all the counter-arguments in my opinion… 😅
I mentioned this in a related thread, but I think just coming at this from a set theory/mathematical perspective should carry some weight. No matter how we want to “think” about
unknown, it represents the entire type space. So if we want to know what the result of optional chaining should be, it should be the range of the optional chaining operator i.e. the result of it acting on all types. If the property exists, the result is stillunknown, and if it doesn’t then it’sundefined, butunknown | undefined === unknown.Granted, I’m playing a little loose with the math… we’d have to consider the operator acting on an infinite union of sets and whether that’s the infinite union of the operator acting on those sets… but I think it’s safe to say that when you’re dealing with the range being all types, you can at least intuitively conclude that the range as being all types. But hey, if you can find an example of a type that optional chaining can’t map to, then you win. Yes, I get that
Mapmight seem a nice candidate, but you can’t access the underlying information without making chainable calls togetso that’s off the table.Personally, I feel like the set theoretical interpretation of the type system is foundational. It’s really helped me to explain to new devs how
unknownis the entire space andneveris the empty set, and draw Venn diagrams to explain why union and intersection types act the way they do.Likewise, keeping this in mind we can see that using
unknownis not type narrowing, it’s type expanding. That’s the whole reason why if you attempt an assertion when two types don’t have sufficient overlap, you are encouraged to cast to unknown first because all types can be narrowed down to fromunknown.And although it’s been mentioned multiple times, I just want to quickly reiterate how this would improve type safety as the compiler would then force us to use optional chaining, as opposed to when
anyis used, and all bets are off.Thanks for the explanation. Could I ask what exactly you mean? Why are the proposed semantics for propagating
unknownthrough?.a big / not good hammer? Is it unsound?Do you know of a real error that people would write with this? I hate to ask for an example of a theoretical problem, but on the other hand, I’m literally writing real errors now, without this feature, by casting to
anywhich allows me to skip?.checks and type check on the final value. E.g. in JSON decoding.How do you propose writing a safe JSON decoding routine? E.g., access
{"a":{"b":{"c":123}}}from an external source, iow including checking every field and final type. Or how does one access deep fields on errors which areunknown, without casting toanyand running the risk of forgetting a?., or forgetting a type check on the last value?Maybe there’s a common idiom I’m missing which makes
?.propagation onunknownless important. Usinganyisn’t it, because I’m introducing bugs today. But currently, casting JSON.parse() output tounknownis unwieldy.Every time i need to handle an error I am landing back at this issue. Considering that the usage of
unknownin the catch clause is on the roadmap for 4.3, I think the calls for optional chaining withunknownwill only increase. Take a look at this example I am currently facing in my code base:The apollo graphql client puts the
errorTypethat is returned by the backend deep inside it’s own custom error object. Without settingerrorto any in the function definition the only solution I have come up with so far is to casterrorto any at the start of the optional chain and then casting the result back tounknown.This will allow people to actually use
unknownwhere they useanynow. I do not understand how the philosophy works here. Can you please explain?This is another place where “technically works” doesn’t mesh well with our philosophy of preventing real errors that you would write. Allowing
?.on any value is a big hammer, and we don’t think a special case forunknown,object, and{}prevents them enough (nor would it feel good).One anecdote I’ll provide here - a while back, there was a team decision to allow property accesses on any object with a string index signature; after all, any property access (
foo.bar) is functionally the same as a bracketed element access (foo["bar"]). That made a bunch of things easier, but it also introduced bugs - bugs that actually showed up in our own compiler! You can’t put that genie back in the lamp without a flag, so now you have--noPropertyAccessFromIndexSignature, an option that someone who’s been on the team for over 7 years literally cannot remember the name of.@RyanCavanaugh becauae the entire purpose of
?.is to be able to navigate through all JS types - ie,x?.foois guaranteed to always beunknown, in JS, whenxisunknown. It may be that TS’s design means that that’s not a valid property to extract based on the type of the LHS and the name of the property on the RHS - but I’m not sure why that would result in anything but the same result asx.foo.In other words, the only difference between
x.fooandx?.fooshould be whenxcan be null or undefined - in which case,x?.fooresults inundefined. As it is now, TS codebases are forced to useanyinstead ofunknownwhen using optional chaining, which makes the code less type-safe.Based off the mention in #46314, I would guess that the potential bug would in the class of misspellings of properties i.e.
a?.toStirng()would almost certainly be an error, but would not cause a run-time error leading to a difficult to find mistake. That’s probably the best argument I’ve seen against it.Counter-argument:
?.is a non-default method of accessing properties and as others have noted, it’s presence communicates a lack of logical safety (type safety is still 100% maintained). Tooling along this line could even be created to mitigate this. Detect anagrams of the 100 or 1000 most commonly used properties and underline them with a blue squiggly or just use a spell checker and add methods to your dictionary as they come up.In my opinion, pretty much any other argument can be boiled down to a misunderstanding of the nature of
?.andunknown.For example, from #46314
No, actually, it’s not… just take it back to the math. To say
unknown + unknownisunknownis to say the the+operator is a map fromunknown x unknown, i.e. all of the ordered pairs of elements inunknown, intounknown. This is false as+is not defined on that.+is defined onstring | number x string | number. And although you could say thatnumber | string + number | stringisunknownthat’s not particularly helpful as technically all operations map intounknownsince all types are a sub-type ofunknown.As far as I can think of,
?.is the unique operator whose domain and range are bothunknown. If you can find any other operators that are defined on all n-tuples ofunknownwith rangeunknown, then sure, they should allowed to act on those n-tuples as well. So when the slippery slope fallacy starts getting thrown around asking “where do we stop?” I say, we stop when it’s not descriptive of the mathematics of the type system under the operators defined in TypeScript. That imposes a pretty tight, objective limit.To sum up, I really want to press the point that rejecting this proposal is not based on the mathematics of the type system. This is choosing an idiom for the language… which I’m actually okay with. I’m a big fan of Go largely because of how idiomatic it is.
My information is, that it works like this:
Using
.?on a value with insufficient type information is what it is for. It does not simply do a check fornullorundefined. It returnsundefinedwhenever a property with a name equal to its right hand side argument can not be resolved from its left hand side argument for whatever reason. Be it because such a property does not exist or the RHS value is of a type that does not have properties. The operation is perfectly well defined for values of any type whatsoever.unknownoranyor otherwise has no information about a property of that name (but allows for its possible existence), the result type isunknown.undefined(which should be a linter warning).I don’t know of any way to make
?.not work. There is IMHO no change in permissiveness since it is always allowed. It is designed to be always allowed.anyisn’t actually more permissive, it is TypeScript’s coercion rules that allows you to use it as any type you like. Bothanyandunknownindicate lack of type information.The reason the parallel is important is because as already mentioned, with
unknownyou can communicate the unsafe nature of your code and afford a degree of type-safety that isn’t possible withany. So it is important to improve the ergonomics of working withunknown. This is consistent with TypeScript design goal of ergonomics over soundness.I think the real weirdness is that it’d break the operational directionality of
unknown. Normally for any typeSthat’s a subtype ofT, if an operation is allowed onTthen it’s definitely allowed onS, but nowunk?.propis valid when e.g.({ x: 1 })?.propisn’t even though{ x: 1 }is by definition a subtype ofunknown.This would lead to some subtle breakage in the future, e.g. let’s say you wrote
The proposal is to make this code OK, but then in the future it’d be a breaking change for us to narrow
xto{ y: unknown }in the body of theif, which seems bad - narrowing should only ever increase the set of things you’re allowed to do.For those interested, you can somewhat emulate this behaviour like this:
The caveats here are:
SafeAny(... as unknown as SafeAny)?.().typeofdoesn’t work correctly on this type (technically it is correct, but because we’re lying to the compiler it makes the wrong assumptions). You can work around this by using type guards instead.This does not work for me, I get the error “Object is of type ‘unknown’.(2571)”. Trying it in playground: https://www.typescriptlang.org/play?ts=4.3.5#code/MYewdgzgLgBApgJwSBAuGBXMBrMIDuYMAvJmACZwBmAlmHOQNwCwAUKJLALZwQQCGAczgl4SFAH4AdAG0A5Dz5C4cgLotWQA
Perhaps if we looked at it from a different perspective:
value.fooisn’t the problem; using its value without checking it, would be. And that’s handled properly by TS, because the value isunknown.Basically,
unknownThing?.fooguarantees that you get something, but you don’t know what. That’s what theunknowntype is for, so as long as the resulting expression is alsounknown, it’d make sense.Or another perspective: the desugaring is in JS semantics, not TS semantics. We expect
x?.footo desugar into(x == null) ? undefined : x.fooin JavaScript, but that’s becausex.fooin JS has its own meaning. In TS that would be(x as any).foo. So in TS we could say, the desugaring of x?.foo is semantically equivalent to (for example)(x == null) ? undefined : (x as typeof x & { foo: unknown }).foo. This still fits the JS spec of?., if I understand it correctly?I don’t want to change a spec post-hoc, I’m just trying to explain where I come from re intuition of semantics of this feature. Curious to hear your thoughts.
@DanielRosenwasser so am i understanding properly that then, as well as now, you’re thinking that the migration pain isn’t worth the bugs it will catch, but that it should have been this way from the start?
What bugs did that introduce?
Got it working on Quokka but apparently it won’t compile Though it have been compiled on my project where deps are over 2 years old. But even after replicating them on freshly created project that is still not working
Sorry for misleading information…
you should be able to rewrite that line like that in order to not disable eslint
const errorType: unknown = error?.['graphQLErrors']?.[0]?.errorType;Thanks @falsandtru for help in figuring this out
@ljharb I can’t tell what you’re trying to say with that.
If you desugared
into
TypeScript would error on that, same as we would error on
x.fooifxwereobjector{ }. Are you saying it should error, or should not error?It seems like “New syntax constructs should behave like their desugarings” would be an uncontroversial interpretation but that’s not what I’m hearing here.
For a concrete example, the famous react-router’s
Routecomponent’s children prop’s location state can not be known and so it rightly sets thelocationparam toLocation<unknown>.Now referring to the state is pretty common, here is an example similar to the official documentation:
Unpacking
fromwith typescript whenstateis unknown becomes pretty cumbersome without allowing optional chaning to write:Or maybe there is a way to handle this simply without optional chaining on unknown?
Hi Ryan thank you for your excellent example. I agree that narrowing should only ever increase the set of things that are allowed to do. But I can think of a counterexample involving the use of contravariance.
As you see class narrowing does not always increase the set of actions that are allowed to do. I believe this
?.operator also faces similar situation.Beside theoretical issue, in practice use, people will just be fine to fix the optional chaining of a variable when they decide to narrow the type. Comparing to using
any, this feature will indeed benefit more than the harms.Regarding the breaking change concern, if this proposal is published along with the type narrowing of
unknownofinoperator, we should be free from introducing breaking change.Should use
x?.['y']instead.