[RFC] Automatic optional chaining

A question for those requesting an explcit syntax to access values of nested option types:
How would you generalize your suggestion?

Rescript does not have any type specific syntax yet. (AFAIK)
Why do we need one? - While keeping in mind: How should any other nested type be handled?
Like result, promise or any (custom) type.

1 Like

How would you generalize your suggestion?

Rescript does not have any type specific syntax yet. (AFAIK)

Would be nice to be able to implement ?. and other extension methods as user space extensions, it’s very common for mainstream languages like C# or kotlin, is also coming to javasccript.

1 Like

I really like this option after thinking about it. How would this be implemented? I think if we can at a compiler level do not allow nested options (and enforce this), it would be great. If we need any way to describe nested data structures, we can always use regular variants, which is often a lot clearer anyway in these situations. The same restriction of not allowing nesting would be amazing to have for promises, because they also don’t have a runtime representation in JS. This will make the current (naive) promise implementation correct.

This option combined with an explicit syntax for optional field access would be a very nice addition to the language imo. The runtime advantage is also not to be underestimate, especially when working with react, because sometimes the boxing will cause values to not have referential equality, so kicking off effects when it’s not expected.

3 Likes

In fact there’s already a warning in the compiler for nested promises. And the same can be done for options.

2 Likes

This seems pretty cool! I did have some questions though.

How would this work…

  • Would the flattening only occur in cases where you use the optional chaining?
  • Or would all nested options be flattened?

If you’re talking about flattening options in general, wouldn’t this give the same problems as with the promises? (I know the promise discussion is a decided topic regarding the benefits of the zero-cost bindings outweighing the loss of type-safety, but for option, maybe it isn’t decided yet.)

Specific examples

Here are a couple examples that I would be curious to know how they would be resolved if nested options are flattened…

Js.Option.some

An example of a function that cannot be typed if nested options are flattened: Js.Option.some.

It’s current type is 'a => option<'a>. Here is some type “math”:

'a => option<'a>

// 'a can be anything so let 'a = option<'a>, then
option<'a> => option<option<'a>>

// By definition, option<option<'a> = option<'a>, so
option<'a> => option<'a>

So the Js.Option.some function would need to have these two types: 'a => option<'a> and option<'a> => option<'a>, which is impossible.

Belt.Option.flatMap

Here is Belt.Option.flatMap (type sig: (option<'a>, 'a => option<'b>) => option<'b>).

If we bind an option<option<'a>>, then in the binding function we expect to work with the argument of type option<'a>. But with flattening, we could end up with the ground value 'a rather than option<'a>. This would break type safety.

(Well, unless there are some things going on in the compiler where the nested options are always guaranteed to have a flatted representation anyway, then I suppose you wouldn’t run into runtime errors at least…but still, how to deal with it in the code?)

3 Likes

In fact, the option flattening idea does not need to be combined with auto optional chaining, I think.

The idea would be simply: whenever some field needs to be accessed, be it x.f or via pattern matching, if x has option type then this is considered as syntactic sugar for reaching inside the option and re-wrapping the result as required.

I guess this can be done in a way that does not require assuming that options are flattened, as “looking inside the option” and “re-wrapping” has a specified meaning also with the current treatment of options that are wrapped in a special way when nested.

@zth what do you think?

1 Like

One thing I thought of is that in JS optional chaining can improve performance and reduce the bundle size: Performance of JavaScript optional chaining · allegro.tech

Here’s a bit of rescript with pattern matching for an object with optional keys and values:

type foo = { bar?: option<string> }
type bar = { foo?: option<foo> }

let fn = (bar) => switch bar {
 | Some({ foo: Some({ bar: Some(s) })}) => s
 | _ => "nothing"
}

And it compiles to

// Generated by ReScript, PLEASE EDIT WITH CARE

import * as Caml_option from "./stdlib/caml_option.js";

function fn(bar) {
  if (bar === undefined) {
    return "nothing";
  }
  var match = bar.foo;
  if (match === undefined) {
    return "nothing";
  }
  var match$1 = Caml_option.valFromOption(match);
  if (match$1 === undefined) {
    return "nothing";
  }
  var match$2 = match$1.bar;
  if (match$2 === undefined) {
    return "nothing";
  }
  var s = Caml_option.valFromOption(match$2);
  if (s !== undefined) {
    return s;
  } else {
    return "nothing";
  }
}

export {
  fn ,
}
/* No side effect */

Playground:
https://rescript-lang.org/try?version=v10.1.2&code=C4TwDgpgBAZg9nKBeKBvKAjAhgJwPwBcUcYwAlnAHYA8AzsDmZQOYB8UAvgFCiSa7I0sBIWKkKNeHHbcuAGwjBYlQQApsOAJTJ2tAO5lgAYwAW-HGi5QAPlADKcALYRV6KUQfPX5j05e1tDk0gnShaKxsoADkqaCR2ACJKOGATJmYErg4gA

It would be nice if using ?. in ReScript compiled to ?. in the JS.

function fn(bar) {
  return bar?.foo?.bar ?? "nothing"
}

But it’s not supported by older browsers and nodejs versions.

Browser support is at 93%, and Node supports it since v14, which was released 3 years ago. I don’t see how that outweighs the benefit of a smaller bundle.

1 Like

If you need to target older browsers and node versions you could use a bundler. For the frontend I’m sure most people are already doing that.

This argument kind of goes the other way as well - if you need a more compact bundle size you should use a minifier first and foremost.

1 Like

I do agree with your argument, but I wonder what should be the limit for using “modern” JS features in the output. Maybe 93%/node version from 3 years ago is indeed too recent, though :thinking:

Yeah this is definitively something we need to figure out a good strategy for, when it’s worth using newer features and what the bar for introducing them is. In my view, it’s a matter of doing thorough research in at least these areas:

  • How widespread is support for the syntax and what’s our tolerance?
  • Is it faster or at least as fast as the current thing it compiles to? If it’s slower, is it worth adding anyway? Why?
  • What actual benefits does it bring and does the above justify the benefits?

Modern syntax can be great for sure (we compile async/await to actual async/await as an example) but we need to be quite careful and mindful about what we introduce, and why.

5 Likes

That sounds like a great approach to it!
Thanks for the great work you and the others have been doing in the language lately, btw. Every new feature makes me a bit jealous of people who work with ReScript :smile:

4 Likes

This code uses double options everywhere btw.
It should be { bar?: string }

The runtime representation of option chaining is something one can analyse separately.
For now, the focus should be on the ergonomics of the syntax extension, and whether there’s enough interest to support it.

3 Likes

I always vaguely assumed that there’d be a bsconfig option for target ES version, to control the features used in the JS output. Then people could choose to involve a bundling-for-legacy step in their build setup or not. Is that not something that’s planned to be configurable at all?

Something I would LOVE to see, even more than optional chaining, automatic or otherwise, would be optional chaining with pipes…

let example = apple(_)?->orange?->grame("fruit")->basket
3 Likes

This basically allows to skip all the nested switch|None cases and focus on the switch|Some cases, in other words, converts the binary tree of switch|option cases into a simpler linear sequence

2 Likes

I started new topic, but I guess I can say few words here. Note that majority of my experience comes from backend development, so I might have a bit different view from others.

I’m new to ReScript, but coming from other languages that utilize Option/Maybe type I find it hard to use due to missing actually convient syntax for unwrapping option types inside functions. I feel like I wrote implementation of Option in JavaScript directly, but it’s even harder to use.

Rust uses ? - Rust By Example with a caveat that the function using ? operator becomes Result/Option automatically - which makes perfect sense. In that scenario ? works like a short-circuit to a function.

Haskell’s do notation is similar to Rust’s ? but it is more powerful (as it handles all monadic types), at a cost of introducing “alternative” syntax to a function (Rust uses the same syntax, just introduces an operator).

All things considered I think Rust approach is superior here - albeit limited to few use cases, those use cases are contributing to all/almost all of potential use cases in ReScript. Rust developers are using ? operator extensively.

Does it make you “ignore” errors? To some degree, yes. But after all code is properly failing. When it fails you might be asking yourself “why”. Then you go to the codebase, see bunch of ? and say: “one of them failed”. If you wish you might have more explicit handling: you might remap your Option to Result to have explicit error handling, and continue using ? syntax. Having explicit errors is a reason why ? is much more common with Result type, and not the Option type.

I personally don’t buy the argument I’ve heard multiple times “not having a syntax makes me think about error handling”. From my experience in most cases you want to handle errors, but at the same time, don’t really care too much about them. In most cases errors are not part of a business logic. Thus errors should be non-obtrusive, thus any syntax sugar is welcomed. And usually, Option means “there is nothing more to do”, like you are looking for an item in an array, there is not item, you cannot proceed further, and if that’s correct behavior that the item is not there, you just quit the processing.
The syntax sugar is optional. Language is a tool, not a way of life - that should be left to particular developer’s/team’s opinion.

Furthermore, ? behaves a bit like a controlled throw in a sense that it interrupts further processing. And some languages actually use throwing extensively. Not talking about >handling< part here, but it just tells, that “early quitting due to not having what program needs” is a pattern common-enough and useful-enough, that having a syntax for it makes sense.

2 Likes