Rescript frustration

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