Skip to content

Optional chaining of unknown should be unknown #37700

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
5 tasks done
hktonylee opened this issue Mar 31, 2020 · 41 comments
Closed
5 tasks done

Optional chaining of unknown should be unknown #37700

hktonylee opened this issue Mar 31, 2020 · 41 comments
Labels
Declined The issue was declined as something which matches the TypeScript vision Suggestion An idea for TypeScript

Comments

@hktonylee
Copy link

hktonylee commented Mar 31, 2020

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.
@hktonylee hktonylee changed the title Optional chaining of unknown should be allowed Optional chaining of unknown should be unknown Mar 31, 2020
@RyanCavanaugh RyanCavanaugh added In Discussion Not yet reached consensus Suggestion An idea for TypeScript labels Mar 31, 2020
@ilogico
Copy link

ilogico commented Mar 31, 2020

This is as safe as allowing property access on {} (unknown is pretty much the same as {} | null | undefined) or allowing access to unknown properties in interfaces in general.
I don't think you should be able to do something with a type and not with its sub-types (we already have any for these things).

And if this would be allowed, why should it be restricted to the optional chaining syntax?

function f(value: unknown): unknown {
  if (value != null) return value.someProp;
  return undefined;
}

I believe it is semantically equivalent.

@RyanCavanaugh
Copy link
Member

I think the real weirdness is that it'd break the operational directionality of unknown. Normally for any type S that's a subtype of T, if an operation is allowed on T then it's definitely allowed on S, but now unk?.prop is valid when e.g. ({ x: 1 })?.prop isn't even though { x: 1 } is by definition a subtype of unknown.

This would lead to some subtle breakage in the future, e.g. let's say you wrote

function fn(x: unknown) {
  if ('y' in x) {
    console.log(x?.z);
  }
}

The proposal is to make this code OK, but then in the future it'd be a breaking change for us to narrow x to { y: unknown } in the body of the if, which seems bad - narrowing should only ever increase the set of things you're allowed to do.

@falsandtru
Copy link
Contributor

Should use x?.['y'] instead.

@hktonylee
Copy link
Author

hktonylee commented Apr 1, 2020

I think the real weirdness is that it'd break the operational directionality of unknown. Normally for any type S that's a subtype of T, if an operation is allowed on T then it's definitely allowed on S, but now unk?.prop is valid when e.g. ({ x: 1 })?.prop isn't even though { x: 1 } is by definition a subtype of unknown.

This would lead to some subtle breakage in the future, e.g. let's say you wrote

function fn(x: unknown) {
  if ('y' in x) {
    console.log(x?.z);
  }
}

The proposal is to make this code OK, but then in the future it'd be a breaking change for us to narrow x to { y: unknown } in the body of the if, which seems bad - narrowing should only ever increase the set of things you're allowed to do.

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.

interface Action {
    actionType: string;
}

interface KeyboardAction {
    actionType: 'keyboard';
    key: string;
}

let genericHandler: (payload: Action) => void;

function isAction(a: unknown): a is Action { return true; }

function isKeyboardAction(a: unknown): a is KeyboardAction { return true; }

function test(unknownAction: unknown) {
    // This will report error for subclass
    if (isKeyboardAction(unknownAction)) {
        type Action = typeof unknownAction;
        const handler: (action: Action) => void = {} as any;
        genericHandler = handler; // <-- Error here
    }

    // Same code as above, but this will *NOT* report error for superclass
    if (isAction(unknownAction)) {
        type Action = typeof unknownAction;
        const handler: (action: Action) => void = {} as any;
        genericHandler = handler;
    }
}

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 unknown of in operator, we should be free from introducing breaking change.

@omeid
Copy link

omeid commented Dec 17, 2020

function f(value: unknown): unknown {
  if (value != null) return value.someProp;
  return undefined;
}

I think the real weirdness is that it'd break the operational directionality of unknown. Normally for any type S that's a subtype of T, if an operation is allowed on T then it's definitely allowed on S, but now unk?.prop is valid when e.g. ({ x: 1 })?.prop isn't even though { x: 1 } is by definition a subtype of unknown.

Isn't this also true for any?

This would lead to some subtle breakage in the future, e.g. let's say you wrote

function fn(x: unknown) {
  if ('y' in x) {
    console.log(x?.z);
  }
}

I think you meant console.log(x?.y)?

The proposal is to make this code OK, but then in the future it'd be a breaking change for us to narrow x to { y: unknown } in the body of the if, which seems bad - narrowing should only ever increase the set of things you're allowed to do.

It does increase it from unknown to { y: T } which means we can safely do things like typeof x.y which wouldn't be possible on unknown.

@omeid
Copy link

omeid commented Dec 17, 2020

For a concrete example, the famous react-router's Route component's children prop's location state can not be known and so it rightly sets the location param to Location<unknown>.

Now referring to the state is pretty common, here is an example similar to the official documentation:

function LoginPage() {
  const location = useLocation();

  const { from } = location.state || { from: "/" } };
  ....
  };

Unpacking from with typescript when state is unknown becomes pretty cumbersome without allowing optional chaning to write:

const { from } = typeof state?.from === "string" ? state : { from: "/" }`

Or maybe there is a way to handle this simply without optional chaining on unknown?

@omeid
Copy link

omeid commented Dec 18, 2020

@ilogico

function f(value: unknown): unknown {
  if (value != null) return value.someProp;
  return undefined;
}

Considering that this is possible with any, why shouldn't it be possible with unknown? At least with unknown unlike any, it is clear that nothing is known about the return value of this function and you must narrow the type before using it, unlike if it had returned any.

@RyanCavanaugh
Copy link
Member

Considering that this is possible with any, why shouldn't it be possible with unknown?

Why should two types designed for completely opposite purposes (any being the most-permissive, unknown the least) behave the same?

@ljharb
Copy link
Contributor

ljharb commented Dec 18, 2020

@RyanCavanaugh becauae the entire purpose of ?. is to be able to navigate through all JS types - ie, x?.foo is guaranteed to always be unknown, in JS, when x is unknown. 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 as x.foo.

In other words, the only difference between x.foo and x?.foo should be when x can be null or undefined - in which case, x?.foo results in undefined. As it is now, TS codebases are forced to use any instead of unknown when using optional chaining, which makes the code less type-safe.

@RyanCavanaugh
Copy link
Member

@ljharb I can't tell what you're trying to say with that.

If you desugared

function f(value: unknown): unknown {
  return value?.foo;
}

into

function f(value: unknown): unknown {
  return value == null ? undefined : value.foo;
}

TypeScript would error on that, same as we would error on x.foo if x were object or { }. 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.

@ljharb
Copy link
Contributor

ljharb commented Dec 18, 2020

@RyanCavanaugh value.foo, specifically, would error, you're right, but let's say it was value?.toString - that shouldn't error - it'd either be undefined or a toString function.

@omeid
Copy link

omeid commented Dec 19, 2020

@RyanCavanaugh

Considering that this is possible with any, why shouldn't it be possible with unknown?

Why should two types designed for completely opposite purposes (any being the most-permissive, unknown the least) behave the same?

any isn't actually more permissive, it is TypeScript's coercion rules that allows you to use it as any type you like. Both any and unknown indicate lack of type information.

The reason the parallel is important is because as already mentioned, with unknown you can communicate the unsafe nature of your code and afford a degree of type-safety that isn't possible with any. So it is important to improve the ergonomics of working with unknown. This is consistent with TypeScript design goal of ergonomics over soundness.

@RyanCavanaugh
Copy link
Member

Nit: Ergonomics isn't necessarily more important than soundness, it's just that it's allowed to be considered (in contrast to languages that would never make a soundness/ergonomics trade-off).

@RyanCavanaugh
Copy link
Member

that shouldn't error - it'd either be undefined or a toString function.

What's the rule that says it's OK for toString but not OK for foo? Not all non-null non-undefined JS values have a toString, which is equally true of foo

@ljharb
Copy link
Contributor

ljharb commented Dec 22, 2020

@RyanCavanaugh that's true, but since TS is incapable of properly handling things with a [[Prototype]] of null, everything in TypeScript that's not null or undefined does in fact have a .toString :-)

@omeid
Copy link

omeid commented Dec 24, 2020

@RyanCavanaugh
Nit: Ergonomics isn't necessarily more important than soundness, it's just that it's allowed to be considered (in contrast to languages that would never make a soundness/ergonomics trade-off).

Even better, that means since unknown allows more soundness (by reducing the instances of runtime values not matching the inferred type during type check), we should make it possible to use unknown in more places which people otherwise would opt for any.

@openreply-dleinhaeuser
Copy link

openreply-dleinhaeuser commented Jan 20, 2021

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 for null or undefined. It returns undefined whenever 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.

  • If the type of the LHS value is unknown or any or otherwise has no information about a property of that name (but allows for its possible existence), the result type is unknown.
  • If the type of the LHS value excludes the existence of such a property, the result type is undefined (which should be a linter warning).
  • If the type of the LHS value knows the type of a property of that name, the result type is the type of that property (if the property is not optional, a linter should arguably generate a warning here)

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.

@WIStudent
Copy link

Every time i need to handle an error I am landing back at this issue. Considering that the usage of unknown in the catch clause is on the roadmap for 4.3, I think the calls for optional chaining with unknown will only increase. Take a look at this example I am currently facing in my code base:

const handleError = (error: unknown): void => {
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  const errorType: unknown = (error as any)?.graphQLErrors?.[0]?.errorType;
  if (errorType === 'MyErrorType') {
    handleMyError();
    return;
  }
  throw error;
};

try {
  makeApolloGraphqlClientQuery();
} catch (error) {
  handleError(error);
}

The apollo graphql client puts the errorType that is returned by the backend deep inside it's own custom error object. Without setting error to any in the function definition the only solution I have come up with so far is to cast error to any at the start of the optional chain and then casting the result back to unknown.

@bacebu4
Copy link

bacebu4 commented Jul 9, 2021

Every time i need to handle an error I am landing back at this issue. Considering that the usage of unknown in the catch clause is on the roadmap for 4.3, I think the calls for optional chaining with unknown will only increase. Take a look at this example I am currently facing in my code base:

const handleError = (error: unknown): void => {
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  const errorType: unknown = (error as any)?.graphQLErrors?.[0]?.errorType;
  if (errorType === 'MyErrorType') {
    handleMyError();
    return;
  }
  throw error;
};

try {
  makeApolloGraphqlClientQuery();
} catch (error) {
  handleError(error);
}

The apollo graphql client puts the errorType that is returned by the backend deep inside it's own custom error object. Without setting error to any in the function definition the only solution I have come up with so far is to cast error to any at the start of the optional chain and then casting the result back to unknown.

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

@WIStudent
Copy link

WIStudent commented Jul 9, 2021

you should be able to rewrite that line like that in order to not disable eslint

const errorType: unknown = error?.['graphQLErrors']?.[0]?.errorType;

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

@bacebu4
Copy link

bacebu4 commented Jul 10, 2021

you should be able to rewrite that line like that in order to not disable eslint
const errorType: unknown = error?.['graphQLErrors']?.[0]?.errorType;

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

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

@hraban
Copy link

hraban commented Sep 25, 2021

@ljharb I can't tell what you're trying to say with that.

If you desugared

function f(value: unknown): unknown {
  return value?.foo;
}

into

function f(value: unknown): unknown {
  return value == null ? undefined : value.foo;
}

TypeScript would error on that, same as we would error on x.foo if x were object or { }. 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.

Perhaps if we looked at it from a different perspective: value.foo isn't the problem; using its value without checking it, would be. And that's handled properly by TS, because the value is unknown.

Basically, unknownThing?.foo guarantees that you get something, but you don't know what. That's what the unknown type is for, so as long as the resulting expression is also unknown, it'd make sense.

Or another perspective: the desugaring is in JS semantics, not TS semantics. We expect x?.foo to desugar into (x == null) ? undefined : x.foo in JavaScript, but that's because x.foo in 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.

@kralphs
Copy link

kralphs commented Oct 5, 2021

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 still unknown, and if it doesn't then it's undefined, but unknown | 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 Map might seem a nice candidate, but you can't access the underlying information without making chainable calls to get so 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 unknown is the entire space and never is 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 unknown is 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 from unknown.

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 any is used, and all bets are off.

@nkappler
Copy link

nkappler commented Oct 5, 2021

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 any is used, and all bets are off.

I think you can't mention this often enough, as it outweighs all the counter-arguments in my opinion... 😅

@ESRogs
Copy link

ESRogs commented Oct 6, 2021

Every time i need to handle an error I am landing back at this issue. Considering that the usage of unknown in the catch clause is on the roadmap for 4.3, I think the calls for optional chaining with unknown will only increase.

Just chiming in to say that @WIStudent's prediction has come true. I am one of the people who has updated their TypeScript version and now wishes they could use optional chaining in catch clauses.

@DanielRosenwasser
Copy link
Member

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 for unknown, 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.

@DanielRosenwasser DanielRosenwasser added Declined The issue was declined as something which matches the TypeScript vision and removed In Discussion Not yet reached consensus labels Oct 11, 2021
@ljharb
Copy link
Contributor

ljharb commented Oct 11, 2021

What bugs did that introduce?

@DanielRosenwasser
Copy link
Member

Basically the effort of turning on that flag was too high to casually completely adopt it in our testing suite - that's part of the problem. As you depend on this behavior accidentally, you can't easily remove it in a refactor - but you can see a bit of when I started it here. #41784

I found interfaces where properties that didn't exist were being accessed, or were declared as a boolean on the plausible original type, but were actually strings at runtime. The code became a game of whack-a-mole to get the types right, and depended on falsy semantics that could break in certain scenarios. In the end, it's not immediately clear how many were true runtime bugs because the fixes were involved, but were definitely cases where you would have hoped that TypeScript had guided us in the right place from the beginning. That's my take at least.

@hraban
Copy link

hraban commented Oct 12, 2021

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 for unknown, object, and {} prevents them enough (nor would it feel good).

Thanks for the explanation. Could I ask what exactly you mean? Why are the proposed semantics for propagating unknown through ?. 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 any which 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 are unknown, without casting to any and 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 on unknown less important. Using any isn't it, because I'm introducing bugs today. But currently, casting JSON.parse() output to unknown is unwieldy.

@ljharb
Copy link
Contributor

ljharb commented Oct 12, 2021

@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?

@kralphs
Copy link

kralphs commented Oct 12, 2021

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 ?. and unknown.

For example, from #46314

How far do you go? unknown + unknown is unknown too.

No, actually, it's not... just take it back to the math. To say unknown + unknown is unknown is to say the the + operator is a map from unknown x unknown, i.e. all of the ordered pairs of elements in unknown, into unknown. This is false as + is not defined on that. + is defined on string | number x string | number. And although you could say that number | string + number | string is unknown that's not particularly helpful as technically all operations map into unknown since all types are a sub-type of unknown.

As far as I can think of, ?. is the unique operator whose domain and range are both unknown. If you can find any other operators that are defined on all n-tuples of unknown with range unknown, 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.

@Pimm
Copy link
Contributor

Pimm commented Oct 12, 2021

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 are unknown, without casting to any and running the risk of forgetting a ?., or forgetting a type check on the last value?

What I do, is upcast to a conservative version of the type I expect.

In your JSON decoding example, I would do:

type Unreliable<T> = { [P in keyof T]?: Unreliable<T[P]> } | undefined;

const decodedData = JSON.parse(data) as Unreliable<{ a: { b: { c: number }}}>;

decodedData?.a?.b?.c; // Type is number | undefined

The compiler enforces the use of the ?. operator ‒ the . operator is not allowed, unlike with any.

If you would like the compiler to enforce the use of the typeof operator as well, you could do:

JSON.parse(data) as Unreliable<{ a: { b: { c: unknown }}}>;

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

@omeid
Copy link

omeid commented Nov 10, 2021

This is another place where "technically works" doesn't mesh well with our philosophy of preventing real errors that you would write.

This will allow people to actually use unknown where they use any now. I do not understand how the philosophy works here. Can you please explain?

@hraban
Copy link

hraban commented Dec 24, 2021

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.

Thanks for your reply, I just wanted to focus on the toStirng part: aren't typos in type declarations always a problem? If you force type declarations (e.g. @Pimm's proposal) you end up shifting the typo from point of use to point of declaration.

The comment you linked to (#46314) says short-circuiting would create the following risk:

if (a?.foo) {
    a?.boo.yadda(); // oops
}

Maybe someone has a more realistic example, because this is precisely the kind of code you use optional chaining to avoid. You'd write:

a?.foo.yadda();

that's the point of null coalescing member access, as I understand it. But how would you do this currently? Cast to any? Same problem. Type casting to a custom type with explicit checking? Same problem, right?

I guess my question is: Is there a solution to handling unknown in TS, currently, which is safer than optional chaining? I don't see optional chaining as a concession on any front, I'd love to understand what we'd be giving up.

@kralphs
Copy link

kralphs commented Dec 24, 2021

Thanks for your reply, I just wanted to focus on the toStirng part: aren't typos in type declarations always a problem? If you force type declarations (e.g. @Pimm's proposal) you end up shifting the typo from point of use to point of declaration.

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.

@Jcparkyn
Copy link

Jcparkyn commented Feb 8, 2022

For those interested, you can somewhat emulate this behaviour like this:

type SafeAny = {
  [key: PropertyKey]: SafeAny,
} | undefined | null;

const x = null as SafeAny;
console.log(x?.children?.[0]?.age); // This is allowed
console.log(x?.children[0]?.age); // This is an error

The caveats here are:

  • You need to explicitly cast most types to SafeAny (... as unknown as SafeAny)
  • This doesn't allow function calls, with or without ?.().
  • Type narrowing is difficult, because typeof doesn'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.

@Alexandre-Fernandez
Copy link

Alexandre-Fernandez commented Apr 29, 2022

this is how I manage this problem (feel free to use it or improve it, for now it only works for one level deep properties) :

export const getUnknownProperty = <T extends Typeof>(
  unknown: unknown,
  propertyKey: PropertyKey,
  typeofProperty: T
): TypeSafePropertyValue<T> | undefined => {
  if (!unknown) return undefined;
  if (typeof unknown !== "object") return undefined;
  const unknownAsRecord = unknown as Record<PropertyKey, unknown>;
  if (typeof unknownAsRecord?.[propertyKey] === typeofProperty) {
    return unknownAsRecord?.[propertyKey] as TypeSafePropertyValue<T>;
  }
  return undefined;
};

type TypeSafePropertyValue<T extends Typeof> = T extends "bigint"
  ? number
  : T extends "boolean"
  ? boolean
  : T extends "function"
  ? Function
  : T extends "number"
  ? number
  : T extends "object"
  ? object | null
  : T extends "string"
  ? string
  : T extends "symbol"
  ? symbol
  : undefined;

type Typeof =
  | "bigint"
  | "boolean"
  | "function"
  | "number"
  | "object"
  | "string"
  | "symbol"
  | "undefined";

@yukinotech
Copy link

decodedData?.a?.b?.c; // Type is number | undefined

decodedData?.a?.b?.c should be unknown in JSON.parse() ,or it is not safe

const c = decodedData?.a?.b?.c  // Type is number | undefined
if(c){
   c // number , but is it really a number type in js runtime ? 
}

type Unreliable<T> = T extends object
  ? {
      [key in keyof T]-?: Unreliable<T[key]>
    }
  : unknown

@adrian-gierakowski
Copy link

adrian-gierakowski commented Sep 30, 2022

Thanks for this @Alexandre-Fernandez!

I'm using it with a little tweak: instead of

  : T extends "object"
  ? object | null

I use:

  : T extends "object"
  ?  { [key: string | number]: unknown } | null

which makes the following safer:

const prop = getPropFromUnknownType(someUnknown, 'prop', 'object')        
const subProp = prop?.['subProp']

When using object, subProp's type is implicitly assigned an any type (which is obviously unsafe) and causes an error when noImplicitAny is enabled.

@zanminkian
Copy link

function isTom(cat: unknown) {
  const firstName = cat?.name?.first
  if(typeof firstName === 'string' && firstName === 'Tom') return 'Yes'
  else return 'No'
}
isTom('Jerry') // No
isTom(null) // No
isTom(123) // No

Code above will cause a compiling error in TS.
To solve this problem, I have to change unknown to any, or use type assertion. But I believe we should not encourage to use any and type assertion in TS.

@NicholasAntidormi
Copy link

Disallowing optional chaining on unknown reduces its utility and forces unnecessary boilerplate. There are many common scenarios where this limitation becomes frustrating. For example:

  try {
    // some code
  } catch (err: unknown) {
    console.log(err?.message); // Not allowed
  }
  const response: unknown = await getResponse();
  const isOk = response?.status === 'ok'; // Not allowed
  if (isOk) ...
  const data: unknown = await getData();
  const nestedValue = data?.some?.nested?.value; // Not allowed
  if (isWhatINeed(nestedValue)) ...

Is it possible to reconsider this issue?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Declined The issue was declined as something which matches the TypeScript vision Suggestion An idea for TypeScript
Projects
None yet
Development

No branches or pull requests