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)