Polymorhic variants for railway oriented programming

Hi all! I almost sure I used to succeed with polymorphic variants to pass errors in long processing chains and even read an article (on ReasonML) which compares using regular variants, strings, and polyvariants for the second type argument of result. The polyvariants were clear winners because they are flexible, composable, and type safe.

But now I stumble upon some kind of basic misunderstanding that results in compile errors:


let makeStageA = (x): result<int, [ | #ProblemA1 | #ProblemA2 ]> => {
  switch x {
  | 1 => Error(#ProblemA1)
  | 2 => Error(#ProblemA2)
  | x => Ok(x)
  }
}

let makeStageB = (x): result<int, [ | #ProblemB1 | #ProblemB2 ]> => {
  switch x {
  | 1 => Error(#ProblemB1)
  | 2 => Error(#ProblemB2)
  | x => Ok(x)
  }
}

let makeStageC = (x): result<int, [ | #ProblemC1 | #ProblemC2 ]> => {
  switch x {
  | 1 => Error(#ProblemC1)
  | 2 => Error(#ProblemC2)
  | x => Ok(x)
  }
}

let make = x => {
  let result = x->makeStageA->Result.flatMap(makeStageB)->Result.flatMap(makeStageC)

  // The brute force works but does not scale well with more variants and deepness
  /*let result = switch x->makeStageA {
  | Ok(x') =>
    switch x'->makeStageB {
    | Ok(x'') =>
      switch x''->makeStageC {
      | Ok(_) as ok => ok
      | Error(#ProblemC1) as err => err
      | Error(#ProblemC2) as err => err
      }
    | Error(#ProblemB1) as err => err
    | Error(#ProblemB2) as err => err
    }
  | Error(#ProblemA1) as err => err
  | Error(#ProblemA2) as err => err
  }*/

  switch result {
  | Ok(_x) => Js.log("Ok")
  | Error(#ProblemB1) => Js.log("Solve problem with foo")
  | Error(#ProblemA2) => Js.log("Solve problem with bar")
  | Error(_p) => Js.log("Do something other")
  }
}

Output:

[rescript]   25 │ 
[rescript]   26 │ let make = x => {
[rescript]   27 │   let result = x->makeStageA->Result.flatMap(makeStageB)->Result.flatM
[rescript]        ap(makeStageC)
[rescript]   28 │ 
[rescript]   29 │   // The brute force works but does not scale well with more variants 
[rescript]        and deepness
[rescript]   
[rescript]   This has type: int => result<int, [#ProblemB1 | #ProblemB2]>
[rescript]   Somewhere wanted: int => Belt.Result.t<int, [#ProblemA1 | #ProblemA2]>
[rescript]   
[rescript]   The incompatible parts:
[rescript]     result<int, [#ProblemB1 | #ProblemB2]> (defined as
[rescript]       Belt_Result.t<int, [#ProblemB1 | #ProblemB2]>) vs
[rescript]     Belt.Result.t<int, [#ProblemA1 | #ProblemA2]> (defined as
[rescript]       Belt_Result.t<int, [#ProblemA1 | #ProblemA2]>)
[rescript]   These two variant types have no intersection

So, ReScript sees that [#ProblemA1 | #ProblemA2] and [#ProblemB1 | #ProblemB2] are incompatible. How can I explain it so that it would union the types to [> #ProblemA1 | #ProblemA2 | #ProblemB1 | #ProblemB2] without bulky enumeration of every single possible case?

Bonus obstacle. I’d like not to touch the code/signatures of the makeStage* functions. Imagine they are coming from 3-rd party libraries I have no control on. And actually, their signatures are nice and clear: they indicate they return no more than these specific errors.

Use the > symbol in your polyvar type to mean ‘this set of variants and more’

result<int, [> | #ProblemA1 | #ProblemA2 ]>
3 Likes

Thanks! But what if…

Can this case be handled somehow without code explosion?

My usual recommendation: don’t annotate types at the implementation level. Put type annotations in interfaces. In any case, the annotations you have now are incorrect. It’s a bug that the compiler is pointing out.

Well, it’s a synthetic example to keep it short. In the original code, the annotations are in interfaces. And as I have told, potentially, in other libs regarding the usage place.

Okay, am I understand correctly if one wants his function to be railway-friendly (the author does not know how the function would be used), he’d better annotate the error type as lower-bound, just in case.

To be more specific, I’m the author of the Jzon lib to encode/decode JSON (a quite generic task). Should I replace, for example, these lines: jzon/Jzon.resi at master · nkrkv/jzon · GitHub

let decode: (codec<'v>, Js.Json.t) => result<'v, DecodingError.t>
let decodeString: (codec<'v>, string) => result<'v, DecodingError.t>

with

let decode: (codec<'v>, Js.Json.t) => result<'v, [> | DecodingError.t]>
let decodeString: (codec<'v>, string) => result<'v, [> | DecodingError.t]>

?

I wonder if it idiomatic enough. I mean the decode function can’t result in anything more besides defined as DecodingError.t:

type t = [
    | #SyntaxError(string)
    | #MissingField(location, string)
    | #UnexpectedJsonType(location, string, Js.Json.t)
    | #UnexpectedJsonValue(location, string)
  ]

Is it OK to relax this type to lower-bound, just in case a user would be required to compose these errors with some others/own? Sounds strange to me.

Yes, that is the normal approach. Making it a lower bound so it can be composeable is the point.

1 Like

Ah, found that article: Composable Error Handling in OCaml. It was on OCaml, not ReasonML. And indeed, the author uses [> error] to annotate the result type. Thanks!

1 Like