Is there any consensus on which type to unify on for errors in result types? What's your preference?

I like result types. I try to use them for (almost) any non-fatal error. This is one of the reasons I tend to prefer Jane Street standard libraries—i.e. they much prefer returning results to raising exceptions.

I’ve learned from experience—though I could have also learned it from Real World OCaml if I’d paid better attention—that having different error types in your results makes monadic binding a PITA. It is instead useful to standardize on one error type. There are generally two things you want to do with errors: report them (meaning they need to become a string at some point) or recover from them (for which packing along some information can be useful)

I see four possibilities:

  1. string / lazy string
  2. a serialization format (like Base.Sexp.t)
  3. Exceptions
  4. Polymophic variants

pros and cons of each

string

  • pros
    • strings are simple
    • obvious way to print
  • cons
    • can’t very well pack along data that can be used for recovery. This factor is sort of disqualifying from my perspective.

serialization

  • pros
    • most can be easily string-ified
    • easy to pack along data which may be used in recovery
  • cons
    • sometimes the string representation is ugly—not a super fan of visually parsing s-expressions, for example.
    • the shape of data isn’t obvious from the type.
    • the string representation is tied to the bundled data.

exceptions

  • pros
    • It’s a type designed for encoding errors, and it’s pretty good both for encoding data and printing.
  • cons
    • pattern matching is not exhaustive.
    • feels weird to pass around exceptions instead of throwing them?

polymorphic variants

  • pros
    • shape of the data is unambiguous.
    • pattern matching is exhaustive
  • cons
    • less obvious how to stringify than other types.
    • types can get ugly.

For some reason my “heart” is kind of drawn towards serialization formats for this purpose, but I don’t have a good reason. I think objectively speaking, exceptions make the most sense.

However, what I really would like most is for the OCaml community to standardize on one type for errors, and if there were a consensus, I would be happy to use it. I still use Jane Street’s Error.t (based on Sexp) when I’m using Base or Core, just because it integrates well with everything else.

Anyone else have thoughts or insights into this quandary?

4 Likes

How important are stack traces to you? If you get an error, do you want to see the sequence of calls that arrived at this error?

2 Likes

I like stack traces, but you can always get them at the site of the error with Printexc and bundle them with the rest of your error data. I also don’t think they are as necessary with result types, since it’s already pretty explicit in the code where possible errors can arise.

In general, one of the reasons I like result types so much is that I rarely have unexpected runtime failure. In the case of runtime failure, I’ve already made it explicit in the code where that can happen, so the stack trace becomes less valuable. On the other hand, I have yet to work on any very large OCaml project, and I can imagine stack traces maybe become more important again in larger code bases.

But of course there are always ways to incorporate that stack trace into other error types when desirable.

1 Like

Haven’t tried it but serializing into something like Message Templates seems very useful for logging purposes. Maybe it would fit your needs?

3 Likes

I suspect you are seeking definitive answers where there aren’t really.

In general I define bespoke error types for error and then provide a stringifier for them. That way the uninterested client can just interpose a Result.map_error error_to_string @@ where appropriate to work in a more general monad.

What I sometimes do is to also directly provide the latter as combinator:

type M.error = … 
val M.string_error : (t, M.error) result -> (t, string) result

Also if the error case is anyway too specific to be expected to be part of a monad I provide two versions of the function, one with a bespoke error and a primed one with a string one (example).

In general I have moved away of using polymorphic types for error cases as it’s often unuseful to carry structured information about local errors once they leave their context (one notable exception being translation to a localised error message) and it tends, as you noticed, to be a bit unergonomic.

Basically I work with bespoke errors types and strings and make sure I can move bespoke ones to strings without too much fuss as your functions get at the top of your call stack. That sometimes entails a bit of bureaucacy (Result.map_error interposition) but it’s not too horrible.

Also I’d just mention that there are more uses for result than you suppose here. For example in Webs I use them to delineate error responses from those that are not to eventually retract them before giving it to the connector that talks to the client. This allows to nicely tuck your 400 away with the error monad when you are treating a request.

8 Likes

+1. Unifying error types in OCaml could streamline library integration and provide guidance to library authors.

I want to acknowledge @dbuenszli’s expertise in crafting interfaces that can be adopted in various contexts, including by base and stdlib users alike, in the absence of such consensus in today’s ecosystem (I know less of others envs such as batteries or containers). For example, I was lately very pleased by the integration of fpath into my work. It was quite easy to inject fpath’s result types into Base.Or_error.

His work serves as an excellent model for library authors seeking to maximize adoption. Thank you!

I like result types. I try to use them for (almost) any non-fatal error. This is one of the reasons I tend to prefer Jane Street standard libraries—i.e. they much prefer returning results to raising exceptions.

I currently feel the same way.

I also find Or_error particularly useful for its way of combining with ppx_sexp_value, allowing for the creation of on-the-fly context-embedded errors. I suspect it would be hard for me to consider migrating towards another pattern if it didn’t come along with, and encouraged such construct/style.

I’d be interested in considering the potential benefits of using json over sexp for serialization syntax. As mentioned earlier, this would require a ppx_json_value extension and a significant rework of interfaces and types currently exposing [@@deriving sexp_of], to add a [@@deriving json_of]. That is such a big lift compared to what’s currently available, that it’s probably disqualifying at the moment. But I’m curious if others have considered this or have experience with it.

From a base user’s perspective, polymorphic types or serializable types can be easily injected into the Or_error monad. Unfortunately, the reverse side of that story is more problematic: non-base users might be discouraged from using an API based on Or_error, since it would require using a module from Base to manipulate its results. If the type was standard, this would help solve this issue (assuming there’d be less push-back in case of a base library integration if the dependency is only a linking one).

Lastly, I’d like to bring attention to the potential game-changer of tracking exceptions in the type system, as discussed in Eio’s documentation. This refers to the ability to specify in the type system which exceptions a function can throw. I find this paragraph in Eio’s documentation very well written and interesting. This could shift the preference away from result types, alleviating the tension around the lack of consensus on error types.

Just to temper on the above, I find it challenging though to code today under the hopes of a future world that I know little of, and can’t really assess. I’d much rather settle on something that makes sense today, and refactor when we get there. Obviously, refactoring and breaking everything, is a luxury I can afford in my pet projects, whereas a central project like Eio has huge benefits from potentially avoiding large breaking changes down the road, so I find this part of Eio’s documentation quite convincing.

Anyways, I wanted to say thank you to @ninjaaron for opening this discussion, and tell you that I was also interested about this topic. I’d love to hear any thoughts or feedback from the community. Thanks!

3 Likes

I’m not convinced by the rationale there.

My problem with exceptions is not so much that they are untracked but that they:

  1. Disturb you control flow in rather disruptive manners. This makes reasoning about the code more difficult.
  2. Do not prepare the calling context for the occurence of errors. This often entails non-local and because of 1. more difficult to understand changes when the error strategy has to be refined.

None of this changes with tracked exceptions.

The advantage of having errors in your results is that the control flow is obvious and that it forces the calling context to prepare for the occurence of errors. While when you write the first version of your code this may just be about propagating up (a.k.a. Result.bind) and may result in subpar results (e.g. error messages lacking context), it is very easy to subsequently refine your error strategy by simply interposing Result.map_error (for example to add more context) at the right place without disturbing the rest of the code.

2 Likes

Do you have the same opinion about effects ie they are a non-local control flow and make reasoning about the code more difficult?

It’s not an opinion and not more difficult: it’s a fact and impossible. As it stands, in OCaml, if your code performs effects you can’t reason on your code without knowing the handler it will be subjected to.

(But that’s OT)

3 Likes

My impression was that the whole point of algebraic effects was that the client gets to choose the handler.

I see the point that it makes control flow considerably more convoluted in the name of simplifying client code—very likely a bad trade in many cases—but the client does ultimately opt in to the handler. It’s not like it’s a black box.

I’m all in on polymorphic variants. My pattern is I define a type for the error a function can return. I use deriving show to make stringable. Then I match polymorphic variants with the Error (#Mod.err as err) pattern and then you can use Mod.show_err to make it a string and print.

4 Likes

So you still use a result type but encode the errors as polymorphic variants? Alternatively you can drop the result type all together and just do

type error = [`Bad of int | `Also_bad of bool]
type ok = [`Ok of string]
type t = [ok | error]

but then you truly lose composition niceties as there’s no good way to implement map or bind with this approach.

If you want to use polymorphic variants while remaining reasonably user friendly you can follow these design guidelines (mostly it’s about keeping your variants closed but providing a function to open them when you come to a point where you need to unify two different error monads).

5 Likes

Yes, I result type with the error side being polymorphic variants.

2 Likes

I have been reading the guide on error handling at Error Handling · OCaml Documentation to see what that says on the point.

Overall I thought there was good advice, but I noticed a couple of errors in the code example using the result type with the let* bind operator (the example passes a Yaml.value entity to Result.map_error instead of a (Yaml.value, [`Msg of string]) result entity ; and the function passed to Result.map_error is of type string -> string by partial application of Printf.sprintf instead of type [`Msg of string] -> ....

This could be quite confusing for beginners. Does anyone know if is a git repository for the page source where I can post an issue?

Yes, here is the source. They accept PRs and are responsive to issues.

Thanks. I have posted an issue.

This discussion inspired me to explore a better distinction between a raising and non-raising API for my vcs project. I wanted to say a special thanks to @orbitz and @dbuenzli for their mention of the polymorphic variants pattern. I recall I had seen the rresult guidelines a while ago, but had long forgotten about it. I ended up creating a new module for Vcs based on Rresult (See here). Thanks!

None of this changes with tracked exceptions.

I was surprised to read that. I must cautiously say, I don’t feel that I would be a good person to make justice of what we’ll be able to expect from tracked exceptions, as I know very little about it. So, the following is very speculative from me.

I was hoping that you’ll be able to adapt your rresult guidelines to say:

  • Re-uses the error type you had minted for your module

  • Define a single exception to your interface E of error

  • Switch your function returning ('a, error) Result.t to 'a raises [ E of error ] (pardon my French - “c’est quoi la syntaxe?”)

From there, I would hope to be able to take benefits from the direct parallel that would exists between both styles for expression combining several modules using this pattern. By “direct parallel”, I mean the equivalence between:

  • non-raising: an expression of type ('a, [> `A of A.error | `B of B.error | `C of error ]) Result.t

  • raising: an expression of type 'a raises [ A.E of A.error | B.E of B.error | C.E of C.error ]

I would think that both prepare equally the call site for the occurrence of errors, and allow similar pattern matching user experience (?).

It seems to me the single exception pattern is what’s in Eio (Eio.Io), and I am exploring doing something similar in Vcs error-handling.

Again, this is all very speculative from me!

If you don’t handle the exception at the call site you still allow for the context to be a “happy path” only. The day you want to handle less happy paths comes with more problems than if you were already forced to recognize that an error could have occured and that you had to treat it (if only by Result.bind).

I think I get what you are saying. Perhaps this relates to the motivation behind reraise_with_context.

Comparing reraise_with_context with Result.bind I supposed you are trading compiler assistance against the ability to carry actual backtraces.

If this patterns proves popular, perhaps there’d be an opportunity for sharing that too. For example, I’ve adapted reraise_with_contexts steps here.