Yet another proposal for monadic computations

Hi rescripters.

An idea for monadic computations in rescript is in my mind for a while. I don’t know if it’s even doable syntactically or semantically but I want to share.

This is heavily inspired from Scala’s for comprehensions but since we don’t have inheritance it’s a bit more explicit which I like that.

a simple example:
adding two int options

let maybeX = Some(2)
let maybeY = Some(3)

let maybeAns = 
  for Belt.Option { // require flatMap and map is defined for type of rhs expressions inside this module
    // LHS is a pattern (the syntax is `pattern <- expr`)
    x <- maybeX
    y <- maybeY 
 } yield x + y

// it basically desugars to
let maybeAns = Belt.Option.flatMap(maybeX, x => Belt.Option.map(maybeY, y => x+y))

Intermediate variables

let maybeAns = 
  for Belt.Option {
    x <- maybeX
    y = x * 2
    z <- maybeZ
 } yield x + y + z

example with promises

module Promise = {
   let flatMap = (p, f) => Js.Promise.then_(f, p)
   let map = (p, f) => flatMap(p, x => x->f->Js.Promise.resolve)
}

let readAndConcatFiles = (f1, f2) =>
  for Promise {
   c1 <- readFileAsync(f1)
   c2 <- readFileAsync(f2)
  } yield c1 ++ c2

// equivalent js
let readAndConcatFiles = async (f1, f2) => {
  let c1 = await readFileAsync(f1)
  let c2 = await readFileAsync(f2)
  return c1 + "\n" + c2 
}

example for Result.t from my codebase:

switch (maybeCityId, maybeTargetUser, maybeBookingIds) {
| (Ok(cityId), Ok(targetUser), Ok(bookingIds)) =>
  Ok({
    cityId: cityId,
    targetUser: targetUser,
    bookingIds: bookingIds,
  })
| (Error(_) as e, _, _) => e
| (_, Error(_) as e, _) => e
| (_, _, Error(_) as e) => e
}

// vs
for Belt.Result {
  cityId <- maybeCityId
  targetUser <- maybeTargetUser
  bookingIds <- maybeBookingIds
} yield {
    cityId: cityId,
    targetUser: targetUser,
    bookingIds: bookingIds,
}

Notes about syntax:
I’m not sure about ambiguity but I think it’s parsable. We can also use with instead of for which is already a keyword in rescript too. and maybe if the parser parse yield as an ident instead of keyword (like “to” in rescript’s for-loop syntax) this sytanx would be backward compatible.

with example

with Belt.Result {
  cityId <- maybeCityId
  targetUser <- maybeTargetUser
  bookingIds <- maybeBookingIds
} yield {
    cityId: cityId,
    targetUser: targetUser,
    bookingIds: bookingIds,
}

Performance:
I’m aware of the overhead of closures in this implementation but I think in many cases the readability of this style overweighs the performance overhead. especially if computation cases rely on each other (see example below) and there’s a bit of logic in middle of your “for computation expression”. and if you need the performance you can just use pattern matching for your trivial data types like option and result.

let compile = input => {
  switch Lexer.scan(input) {
  | Ok(tokens) =>
    switch Parser.make(tokens) {
    | Ok(parser) =>
      switch Parser.parseExpr(parser) {
      | Ok(result) => Ok(Codgen.print(result))
      | Error(_) as e => e
      }
    | Error(_) as e => e
    }
  | Error(_) as e => e
  }
}

let compile = input =>
  for Belt.Result {
    tokens <- Lexer.scan(input)
    parser <- Parser.make(tokens)
    ast <- Parser.parse(parser)
    code <- Codgen.print(ast)
  } yield code

What about reason/ocaml style let bindings (or ppx_let):
I found them confusing.

  • conflict between normal let bindings and monadic let bindings
  • defining letops require extra syntax
  • where do letops come in scope for current expression? (compared to referring to correct module containing flatMap and map explicitly which is aligned with current state of belt modules and it’s a bit like async function declration in js)
13 Likes

I write this pattern several times on last night.

If it can simplify should help me a a lot

Looking at your Result example in particular I personally find the code a lot clearer before introducing the monadic bindings.

If you’re looking for a way to linearize your code then I think you can get very far with just some functions and data in ReScript.

For example with a helper for results like this:

module Result = {
  let product = (r1, r2) =>
    switch (r1, r2) {
    | (Ok(a), Ok(b)) => Ok((a, b))
    | (Error(e), _)
    | (_, Error(e)) =>
      Error(e)
    }
}

You’d be able to write your Result example as:

maybeCityId
->Result.product(maybeTargetUser)
->Result.product(maybeBookingIds)
->Belt.Result.map((((cityId, targetUser), bookingIds)) => {
  cityId: cityId,
  targetUser: targetUser,
  bookingIds: bookingIds,
})

Which looks to be about the same line count as your proposed syntax

You can write the equivalent for Promises as:

module Promise = {
  let product = (a, b) => Js.Promise.all2((a, b))
  let map = (p, f)=> p |> Js.Promise.then_(v => f(v)->Js.Promise.resolve)
}
let promiseX = Js.Promise.resolve(4)
let promiseY = Js.Promise.resolve(3)
let promiseXY =
  promiseX
  ->Promise.product(promiseY)
  ->Promise.map(((x,y))=> x + y)

This is not to say language extensions like monadic let bindings are a bad idea, or undesirable.

Just that you have some great options to consider in the language already

6 Likes

You’re right. monadic syntax sugars shine where monadic values/computations depend on each other. of course we can solve the result example with helpers like the one you introduced but having a general way of composing multiple monadic values, both disjointed and nested/dependant values, might be a good idea.
I explicitly raised this imperfect propsoal cause I thought the previous porposal on async await syntax misses the opportunity of having such syntax for different data types.

8 Likes

I like this idea; it’s clear what it does and inspired by Scala so we know the syntax will be easy to understand.

3 Likes

How would error handling look with this syntax? And would the errors compose?

Same as using flatMap.
for result types there’s a common pattern that if different cases in your computation return different errors you just wrap them in variants, that’s how I compose errors if I care about all the error types.

type error = ParserError(...) | LexerError(...) | ....
3 Likes