Rescript frustration

I think it’d be a nice addition to Core APIs.

i’m having trouble understanding this take. this happens to be part of the cli, but it could be anything else, let’s say network stream packet parsing where you want to be able to resume anyway.

i think the “carrying results around” way-of-thinking here stems from the fact there you’re forced to carry them around in rescript.

my prior code code have been something like:

  let result = switch str->split {
  | Ok(parts) => {
      parts->Array.reverse

      let rec processArgs = (acc) => {
        let Some(name) = argv->Array.pop else return Error(NoMoreArgs)
        let Some(arg) = args->Dict.get(name) else return Error(UnrecognizedArg(`Unrecognized argument: '${name}'`)
        switch processArg(arg, argv) {
        | Ok(value) =>  {
          acc->Dict.set(name, value)
        }
        |  Error(e) => Error(e)
        }
      }

      processArgs(Dict.make())
    }

this kind of early return sanity checks, or guards, make life so, so much easier, and you don’t end up with either a huge, hard-to-read switch statement, or ridiculous nesting.

I think result handling is definitely an area I think can be improved, but when faced with something like this I’d just use exceptions and hide it away in some kind of util function that does the throwing for me

let result = str->Argv.process((name, acc) => 
  switch name {
  | "a" => getA(acc)
  | "b" => getB(acc)
  }
})

I use a json decoding lib that does it this way and quite like it, and handling multiple results is usually done in one place at the bounds

1 Like

I totally understand the appeal for sanity checks, guards, etc.

What I’m saying is that you should generally aim at having the least complex type possible, so if you only care about the presence or absence of a value and not about the error, then use an option<'a> instead of a result<'a, 'error>, but if your code can only work with the presence of this value, then just use 'a and raise an exception when it’s not there. Otherwise, what’s the point of carrying around the error if you never actually read it?

To me, results are great when you do want to take into consideration both ok and error cases, by using switch you get exhaustiveness checks (I try to pattern match every case of the error variant for example), this way, the day you add a new error case, you get helped by the compiler. If you start using results and just throw the error information at the beginning of each function with sanity checks, you lose this exhaustiveness check and don’t really use your result type. I know I got bitten by this quite a few times using let* in ocaml.

It makes you focus on the happy path, if it’s used only in a few places it’s ok, but if you never care about the error cases, it can become a smell.

One more time, I’m not saying this makes early returns useless but it should make you think about how pervasive and automatic their usage should be. And a good language should take this into consideration.

2 Likes

help me understand this. the point of carrying around errors is you can handle them at the boundary and list them all, print them out with sane error messages instead of crashing hard. there might also be the case of having mutated states in files or databases that need to be handled differently depending on the error.

we’re making a distinction BECAUSE rescript is lacking certain monadic features. early returns, or the ? operator, would mitigate this, and we would be discussing a distinction without a difference.

I’m not talking about crashing hard, you can fortunately catch exceptions and list and print out the sane error messages exactly at the same place as if you had used result:

switch processRequest() {
| Error(UnrecognizedArg({argName})) =>
  Console.error(`Unrecognized argument: '${argName}'`)
  Process.exit(2)
| Error(TooManyOccurrences({argName})) =>
  Console.error(`Argument '${argName}' must only be specified once`)
  Process.exit(3)
| Ok(result) => Console.log(`success: \n${result}`)
}

vs

switch processRequest() {
| exception CliExn(error) =>
  switch error {
  | UnrecognizedArg({argName}) =>
    Console.error(`Unrecognized argument: '${argName}'`)
    exit(2)
  | TooManyOccurrences({argName}) =>
    Console.error(`Argument '${argName}' must only be specified once`)
    exit(3)
  }
| result => Console.log(`success: \n${result}`)
}

Playground link

The distinction does not come from the lack of monadic features but from the semantics and intended usage: will I process the error on the boundary of my program only or will I reuse the error somewhere else in my program flow?

If you use the same type for different usage, you’re likely going to misuse it, typically in this case by ignoring error cases that should have been checked before the boundary if you take the habit of unwrapping every result at the beginning of each function.

But sometimes, even though you know you need the error cases in different places of your program, there are functions where you only care about the ok case and there unwrapping results shines, I totally agree.

Carrying around Results or Options can feel odd at first, but once you understand the types it’s hard to not use them.

This is a great talk about how to use ADTs to contain data and pass it along in a program.

1 Like

i’ve actualy seen that particular video before. no matter, i’m not sure who you’re talking to - i’m the guy promoting carrying results and options around in this discussion! :>

@tsnobip help me understand more what you mean. why shouldn’t i use exceptions as results(errors)? why is it bad if i don’t need to move the errors around my program code?

also, on the contrary, if we did have early returns, why would i prefer to use exceptions (except panics) over results?

Not really responding directly to anyone in particular, I just wanted to add some information to the thread for anyone who comes across it.

I’ve been meaning to write up a blog post on using Railway Oriented Programming in ReScript. I did one for Typescript many years ago.

3 Likes

I may not have expressed myself clearly, I fully support the use of results and options. In fact, I use them all the time and find them highly beneficial.

I’m just saying that if there are some results you use only to short-circuit to their ok value and check the error cases only on the boundaries, it’s a smell you might be better off using a more direct style and use exceptions instead of result for these values. Especially given their exceptions can be statically tracked in rescript with reanalyze. In my experience, those cases are the most common use cases for ? or equivalent operators.

Using early returns instead of pattern matching with exhaustiveness check can reduce the help you get from the compiler. When you add a case to your error variant, the early return would just swallow it while the compiler would force you to handle it with pattern matching.

Using a more direct programming style and simpler types also make error messages easier to read for example, code easier to grasp, etc.

I know that when dealing with code that uses “advanced” features like complex monadic infix or let* operators, I need much more time to get familiar with it, so I’d rather use advanced concepts as little as possible.

2 Likes

I am a fan of figuring out how to enable more complex FP patterns, but we should make sure that they aren’t the norm and we scare away JS devs. We want JS devs to like ReScript, but we should also work to gain traction from devs who use Rust, Elm, etc… and offer ReScript as an alternative to TypeScript for devs who want a more sane type system and features they like.

1 Like

can you elaborate on this further? help me see how you make the distinction here. can you give examples?

experimenting for a bit and the only conclusion i can sort of draw in the end is that exceptions are interesting ONLY because I cant do early returns. i dont understand why you’re saying the compiler cant be as helpful with early returns. if you do early returns with e.g. the ? operator, you’re moving exhaustiveness checks to the caller. if you dont want to do that, you can still switch.

also since we don’t even have early returns, i can only do certain loops as recursion anyways, so i might as well recurse on results. i hate to say it but i feel rescript has picked the “worst of both worlds” approach here. :<

My 2 cents, given you’re relatively new to rescript, if you feel like only wrong design decisions have been made, it’s likely that you’ve been writing non-idiomatic code and you’ve been fighting the language.

Now if you know how to implement the things that are missing to better cater to your coding style without hurting the existing idioms, contributions are welcome!

i don’t mean to sound harsh, it just feels like an incomplete toolbox. could you help me distinguish between exceptions and results? coming from rust, i’m used to only doing results.

I find results work best when they work on the same type – something like validating a string in a form.

For something like processing args in a CLI app, if you create some kind of context object to use instead of retaining values in a function scope, writing the functions you need gets much easier and then you end up with the nice “railway”

type errors =
  | DuplicatedArgumentError(array<string>)
  | UnrecognizedArguments(array<string>)

type mode = Production | Development

type options = {
  cache: bool,
  mode: mode,
}

type cmd = {
  argv: array<string>,
  options: options,
}

let defaultOptions = {
  cache: false,
  mode: Production,
}

let processArgs = args => {
  let argv = String.split(args, " ")
  Array.reverse(argv)
  {argv, options: defaultOptions}
}

let noDuplicateArgs = cmd =>
  switch duplicates(cmd.argv) {
  | [] => Ok(cmd)
  | duplicates => Error(DuplicateArgumentError(duplicates))
  }

let allArgsUsed = cmd =>
  switch cmd.argv {
  | [] => Ok(cmd)
  | items => Error(UnrecognizedArguments(items))
  }

let flag = (tag, f) => cmd => {
  argv: cmd.argv->Array.filter(x => x !== tag),
  flags: if cmd.argv->Array.includes(tag) {
    f(cmd.flags)
  } else {
    cmd.flags
  },
}

let getCacheOption = flag("--cached", flags => {...flags, cache: true})

let getModeOption = flag("--dev", flags => {...flags, mode: Development})

let parseArgs = args =>
  processArgs(args)
  ->noDuplicateArgs
  ->Result.map(getCacheOption)
  ->Result.map(getModeOption)
  ->Result.flatMap(allArgsUsed)
  ->Result.map(cmd => cmd.options)
3 Likes

i have spent the entire weekend (yes, literally the entire weekend so far) rewriting the arg parser in 5-6 different iterations just to get a grasp on what would work, be readable and i guess idiomatic.

i’ve arrived at a few conclusions:

railway oriented programming is definitely the best way to go with rescript, BECAUSE it lacks break and return (and while-let, if-let let-else and so on, but those are not as important).

HOWEVER, the standard library does not facilitate railway oriented programming. this is why i ended up feeling “worst of both worlds”. i rolled my own, trivial micro-lib as a replacement for Result, and added loop/repeat functionality (with break support by returning an error in an iteration), and now things are falling into place.

this is meant as constructive criticism, and i have to say this also resonates with the feeling that rescript gives me, “worst of both” or that it’s basically in some sort of cognitive dissonance fight with itself.

@tsnobip do you get what i’m trying to communicate here or does it still feel foreign or unfair to you?

for anyone who wants to see my micro-lib:

// Put `t` into a module so others can `open R.Types` to have it available
// without bringing all the functions into scope.
module Types = {
  type t<'a, 'b> = Ok('a) | Err('b)
}
include Types

let unit = (_: t<'a, 'b>): t<unit, 'b> => Ok()

let fromOptionOr = (opt, err) =>
  switch opt {
  | Some(x) => Ok(x)
  | None => Err(err)
  }

let fromOptionOrElse = (opt, fn) =>
  switch opt {
  | Some(x) => Ok(x)
  | None => Err(fn())
  }

let ok = res =>
  switch res {
  | Ok(x) => Some(x)
  | Err(_) => None
  }

let okExn = (res, ~message=?) =>
  switch res {
  | Ok(x) => x
  | Err(_) => panic(message->Option.getOr("R.okExn called for Err value"))
  }

let err = res =>
  switch res {
  | Ok(_) => None
  | Err(x) => Some(x)
  }

let errExn = (res, ~message=?) =>
  switch res {
  | Ok(_) => panic(message->Option.getOr("R.errExn called for Err value"))
  | Err(x) => x
  }

let getOr = (res, err) =>
  switch res {
  | Ok(x) => x
  | Err(_) => err
  }

let getOrElse = (res, fn) =>
  switch res {
  | Ok(x) => x
  | Err(_) => fn()
  }

let map = (res, fn) =>
  switch res {
  | Ok(x) => Ok(fn(x))
  | Err(x) => Err(x)
  }

let mapErr = (res, fn) =>
  switch res {
  | Ok(x) => Ok(x)
  | Err(x) => Err(fn(x))
  }

let andThen = (res, fn) =>
  switch res {
  | Ok(x) => fn(x)
  | Err(x) => Err(x)
  }

let orElse = (res, fn) =>
  switch res {
  | Ok(x) => Ok(x)
  | Err(x) => fn(x)
  }

let isOk = res =>
  switch res {
  | Ok(_) => true
  | Err(_) => false
  }

let isErr = res =>
  switch res {
  | Ok(_) => false
  | Err(_) => true
  }

let flatten = res =>
  switch res {
  | Ok(Ok(x)) => Ok(x)
  | Ok(Err(x)) => Err(x)
  | Err(x) => Err(x)
  }

let transpose = res =>
  switch res {
  | Ok(Some(x)) => Some(Ok(x))
  | Ok(None) => None
  | Err(x) => Some(Err(x))
  }

let tap = (res, fn) =>
  switch res {
  | Ok(x) =>
    fn(x)
    Ok(x)
  | Err(x) => Err(x)
  }

let rec repeat = (res, fn) =>
  switch res {
  | Ok(x) =>
    switch fn(x) {
    | Ok(Some(x)) => repeat(Ok(x), fn)
    | Ok(None) => res
    | Err(x) => Err(x)
    }
  | Err(x) => Err(x)
  }

let forEachWithIndex = (res: t<array<'a>, 'b>, fn) =>
  switch res {
  | Ok(a) =>
    let _ = Ok(0)->repeat(i => {
      switch a->Array.get(i) {
      | Some(v) => fn(v, i)
      | _ => Ok()
      }->map(() => i < a->Array.length ? Some(i + 1) : None)
    })
    Ok(a)
  | Err(x) => Err(x)
  }

let forEach = (res: t<array<'a>, 'b>, fn) => res->forEachWithIndex((x, _) => fn(x))

in particular, forEach and repeat are helpful

this lets me do e.g. this, which i came up with as iteration #6:

type argType = String
type arg = {t: argType, name: string, minCount?: int, maxCount?: int}

let string = (name, ~required=false) => {
  t: String,
  name,
  minCount: required ? 1 : 0,
  maxCount: 1,
}

exception InvalidConfig(string)

let checkConfig = args => {
  // Keep track of names so we can detect duplicates.
  let argNames = Set.make()

  // Raises an exception if the specified arg has an invalid config.
  let checkArg = (arg): R.t<unit, 'a> => {
    if argNames->Set.has(arg.name) {
      raise(InvalidConfig(`${arg.name}: argument names must be unique`))
    }
    argNames->Set.add(arg.name)

    // We can just assume 0 and 1 for config sanity check purposes.
    let min = arg.minCount->Option.getOr(0)
    let max = arg.maxCount->Option.getOr(1)

    if min < 0 {
      raise(InvalidConfig(`${arg.name}: minCount cannot be negative`))
    }
    if max < 1 {
      raise(InvalidConfig(`${arg.name}: maxCount must be at least 1`))
    }
    if min > max {
      raise(InvalidConfig(`${arg.name}: minCount cannot exceed maxCount`))
    }

    Ok()
  }

  Ok(args)->R.forEach(checkArg)
}

type parseError = MissingParam(string)

let parseArgs = (argv, args) => {
  // Turn the arg vector into a stack which we can pop names/params from.
  let argv = argv->Array.toReversed

  let argMap = args->Array.map(arg => (arg.name, arg))->Dict.fromArray
  let argParams = Dict.make()

  let parseStringArg = (arg): R.t<JSON.t, parseError> =>
    switch argv->Array.pop {
    | Some(param) => Ok(param->JSON.Encode.string)
    | None => Err(MissingParam(`Argument '${arg.name}' requires exactly one parameter`))
    }

  let storeParam = (arg, param) =>
    switch argParams->Dict.get(arg.name) {
    | Some(JSON.Array(a)) => a->Array.push(param)
    | Some(p) => argParams->Dict.set(arg.name, [p, param]->JSON.Encode.array)
    | None => argParams->Dict.set(arg.name, param)
    }

  let parseArg = argv =>
    switch argv->Array.pop {
    | Some(name) =>
      let arg = argMap->Dict.getUnsafe(name)
      let param = switch arg.t {
      | String => parseStringArg(arg)
      }

      param->R.tap(storeParam(arg, _))->R.map(_ => Some(argv))
    | None => Ok(None)
    }

  Ok(argv)->R.repeat(parseArg)->R.map(_ => argParams)
}

let parse = (argv, args) => Ok(args)->R.andThen(checkConfig)->R.andThen(parseArgs(argv, _))

feel free to comment on my approach here and why it’s good or bad. i do not agree with the take that we can just throw in exceptions instead of errors here (for config checks we can because they’ll likely be hardcoded, but the arg params are given by the user of the program)

2 Likes

Can you give some examples of what functions you feel are missing? It’s on my list of TODOs to add some more functions around Option.

sure, i’d like to think i can:

for option:

let getOrElse = (opt, fn) => switch opt {
  | Some(val) => val
  | None => fn()
}

also repeat/reduce (see below):

for result:

let getOr = (res, err) =>
  switch res {
  | Ok(val) => val
  | Err(_) => err
  }

let getOrElse = (res, fn) =>
  switch res {
  | Ok(val) => val
  | Err(_) => fn()
  }

let map = (res, fn) =>
  switch res {
  | Ok(val) => Ok(fn(val))
  | Err(err) => Err(err)
  }

let mapErr = (res, fn) =>
  switch res {
  | Ok(val) => Ok(val)
  | Err(err) => Err(fn(err))
  }

let andThen = (res, fn) =>
  switch res {
  | Ok(val) => fn(val)
  | Err(err) => Err(err)
  }

let orElse = (res, fn) =>
  switch res {
  | Ok(val) => Ok(val)
  | Err(err) => fn(err)
  }

let rec repeat = (res, fn) =>
  switch res {
  | Ok(val) =>
    switch fn(val) {
    | Ok(Some(ret)) => repeat(Ok(ret), fn)
    | Ok(None) => res
    | Err(err) => Err(err)
    }
  | Err(err) => Err(err)
  }

i’ll think some more about option and see what else i can come up with. the looping (repeat//reduce) functionality is important because there’s no good way to break or return from loops

There is this ticket that seems related to what you’re asking for: fill out the Option module? · Issue #85 · rescript-lang/rescript-core · GitHub

4 Likes