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)