[RFC] Automatic optional chaining

We’d like to introduce a feature we’ve been experimenting with recently - automatic optional chaining.

Automatic optional chaining is similar to optional chaining in TypeScript (someVal?.someField?.otherField?.something), but with a twist - because of ReScript’s type system, we can do the optional chaining automatically without the developer needing to indicate where to unwrap options.

Let’s explain how this works with an example:

type thing = {something: option<int>}
type otherRecord = {thing: option<thing>}
type targetRecord = {otherRecord: option<otherRecord>}

let x: targetRecord = ...

// Today you'll need to do something like this:
// option<int>
let something = switch x {
| {otherRecord: Some({thing: Some({something: Some(something)}) }) } => Some(something)
| _ => None
}

// How you could do it with automatic optional chaining:
// option<int>
let something = x.otherRecord.thing.something

Notice how there’s no unwrapping of each optional level, neither via switching nor with ? - you tell which value you want to access via dot access, and the compiler will help you get there without the need of manual plumbing (unwrapping each optional level yourself). This also means options in the chain will be automatically flattened.

Rationale

Our reasoning for this feature can be summarized roughly like this:
You know what deeply nested optional value you’re after accessing. Getting there is just manual plumbing. Thanks to the type system, the compiler can do that unwrapping automatically for you.

It’s also a purely additive feature. You can continue switching at any level of the optional value chain if you want. This is a first, exploratory step in seeing how we can ease working with optionals. We’re intentionally starting small, only focusing on solving easy access of deeply nested optional data (via records).

Editor integration

This will be fully integrated with the editor extension, meaning autocomplete will also automatically unwrap options. This will hopefully make the feature feel natural. Most users will probably not even notice they’re using automatic optional chaining.

Testing

You can test this yourself by locally installing the prebuilt ReScript npm package from here. If you want to test how it feels with proper editor extension integration (highly recommended), download and install this vsix.

Feedback

We’re looking for your general feedback and comments on this feature. Test it, and let us know how it feels. Whether it makes sense directly, or if it’s confusing. And so on.

We look forward to hearing your thoughts and feedback! More exploration in this space will follow.

27 Likes

Oh, that’s sounds like a really desirable QoL feature, especially for people with Typescript background. :+1:

2 Likes

Honestly, I’m afraid of the magical optional chaining. It feels like having explicit ?. would be a more reliable option, which is familiar for JS developers. Maybe I’ll change my mind after trying it out, but right now, I’m skeptical about the feature.

18 Likes

Will remove a lot of cruft from [our] codebase for sure.
Does seem to strike against the monomorphic nature of other operators though. does it fit into an infix operator policy at all?

I am also highly skeptical of implicit behaviour. And especially so when it changes the meaning of existing syntax.

And more generally, I think the more important litmus test is whether a language feature makes code easier to read (and understand), than whether it makes it easier to write. This proposal makes code significantly harder to read because it introduces ambiguities, while only making it negligibly easier to write compared to a ?. operator, for example.

I’m also not sure what real world use cases there are for this. I don’t think I’ve ever had to interact with nested optionals in a way that this would have been a significant improvement. But might be that certain kinds of JS interop make this pattern more prevalent?

16 Likes

We have tons of nested optional fields coming back from our graphql back end. e.g.
Very common to have couple-two-three levels of bind to dig out a particular leaf

2 Likes

Maybe an operator “suffix” for things that do more/conditional work depending on context? .*, +*?

So only VSCode support for now?

Also, as others have said, I find it confusing that happens automatically, and I would prefer a visual clue telling me what is happening. ?. is well known

3 Likes

+1 for a separate operator indicating this behavior; the variant in the top post seems too magical to me

4 Likes

Lets call it >>= :wink:

4 Likes

I think I agree with others on being explicit, but would like to try it out in my codebase before being totally against it. Though, I’m not often dealing with options nested inside other options; the thing I do use all the time is unwrapping with Belt.Option.getWithDefault(() => {}) and would love to see a QOL improvement on that (Maybe the elvis operator ?:)?

1 Like

Or the nullish coalescing operator familiar from JS: ??.

3 Likes

Thank you all for your feedback!

I’ve updated the original post to include a link to a prebuilt npm package you can download and install locally in your project to test.

Many different discussions to have here, both philosophical and practical. If we start by focusing on the practical aspects, we’re curious to understand what your experience is when trying it on real projects.

So, please test it in your code base in its current form, and report your feedback, preferably via concrete examples. Strongly recommend testing it with the editor integration as well.

5 Likes

This syntax looks magical but reasonable to me. Great work!

1 Like

Just aliasing getWithDefault to or makes things a bit more legible?

How does the compiled JS output look like in different scenarios?

1 Like

Please do test it in your codebase, as @zth suggests.

There are some practical aspects that only become clear in that way. For example, if one wants to be completely explicit, then 2 operators, not 1, are required. Conceptually corresponding to map and flatmap.

On that topic, and this will be explored separately, there’s the opportunity to make option types closer to the way they are natively in JS.

  • Basically, one can explore making option<option<t>> the same as option<t>
  • The price to pay is that there won’t be a way to distinguish between None and Some(None). So that’s a semantic change. Natively, JS does not have that distinction.
  • The advantage is that the distinction between map and flatmap should just just disappear.
  • The other change is that the whole mechanism for runtime representation of nested options, with its own compiler optimisations which sometimes fire and sometimes do not, will disappear. Basically, it’s always optimised by definition as there won’t be boxed optionals.
7 Likes

Do we need an operator for flatmap? It doesn’t look relevant to the problem of accessing fields in optional nested records.

Please do test it in your codebase

I’ll try to find some time during the week :ok_hand:

2 Likes

See the type of b and the implementation of getB in the 2 cases below.
Those are the 2 get operators.

module Example1 = {
  type c = {c?: int}
  type b = {b?: c}
  type a = {a?: b}
  let getA = x =>
    switch x {
    | None => None
    | Some(y) => y.a
    }
  let getB = x =>
    switch x {
    | None => None
    | Some(y) => y.b
    }
  let getC = x =>
    switch x {
    | None => None
    | Some(y) => y.c
    }
  let foo = x => x->getA->getB->getC
}

module Example2 = {
  type c = {c?: int}
  type b = {b: c}
  type a = {a?: b}
  let getA = x =>
    switch x {
    | None => None
    | Some(y) => y.a
    }
  let getB = x =>
    switch x {
    | None => None
    | Some(y) => Some(y.b)
    }
  let getC = x =>
    switch x {
    | None => None
    | Some(y) => y.c
    }
  let foo = x => x->getA->getB->getC
}
1 Like

So much boilerplate over writing accessors…why?

module Example1 = {
  type z = {c?: int}
  type y = {b?: c}
  type x = {a?: b}
  
  // derivable, or easily provided by language?
  let getA = x: x => x.a
  let getB = y: y => y.b
  let getC = z: z => z.c

  let getYC = y => y->getB->Option.flatMap(getC)
  let getXC = x => x->getA->Option.flatMap(getYC)
  let foo = getXC
}