Using polymorphic variants for the error type poses some challenges.
Here are some things to think about. (The following code assumes `open
Async.Std` because it already has the necessary combinators).
Error Unification
# let x = return (Error `Bad);;
val x : ('a, [> `Bad ]) Result.t Deferred.t = <abstr>
# let f ok = if ok then return (Ok "all good") else return (Error
`Error);;
val f : bool -> (string, [> `Error ]) Result.t Deferred.t = <fun>
# x >>=? f;;
- : (string, [> `Bad | `Error ]) Result.t =
Core_kernel.Std.Result.Error `Bad
So that works nicely. Two different error types get unified. Notice
the inferred types of x and f are open. Forcing a closed type breaks
things:
# let x : (bool, [`Bad]) Result.t Deferred.t = return (Error `Bad);;
val x : (bool, [ `Bad ]) Result.t Deferred.t = <abstr>
# x >>=? f;;
Error: This expression has type bool ->
(string, [> `Error ] as 'a) Result.t Deferred.t but an expression was
expected of type
bool -> (string, [ `Bad ]) Deferred.Result.t
Type (string, 'a) Result.t Deferred.t is not compatible with type
(string, [ `Bad ]) Deferred.Result.t =
(string, [ `Bad ]) Result.t Deferred.t
The second variant type does not allow tag(s) `Error
So is the annotation on x wrong? Well, it type checks, so it's not
disallowed. But is it just bad style? Is there never a reason to force
a closed type? Could adding functions like Bunzli's open_error_msg
resolve the matter easily enough?
Unification At Scale
In large code bases, you can't get around the problem because you'll
surely introduce incompatible types.
# let x = return (Error (`Bad "uh oh"));;
val x : ('a, [> `Bad of string ]) Result.t Deferred.t = <abstr>
# let f ok = if ok then return (Ok "all good") else return (Error
(`Bad 42));;
val f : bool -> (string, [> `Bad of int ]) Result.t Deferred.t = <fun>
# x >>=? f;;
Error: This expression has type bool ->
(string, [> `Bad of int ] as 'a) Result.t Deferred.t
but an expression was expected
of type
bool -> (string, [> `Bad of string ] as 'b) Deferred.Result.t
Type (string, 'a) Result.t Deferred.t is not compatible with type
(string, 'b) Deferred.Result.t = (string, 'b) Result.t
Deferred.t
Types for tag `Bad are incompatible
Now you have to manually handle the errors every time x and f are used
in the same context. Should some support for that be standardized? We
started defining functions like
val bind_and_handle_error
: ('a, 'e1) Result.t Deferred.t
-> ('a -> ('b, 'e2) Result.t Deferred.t
-> merge_error:('e1 -> 'e2 -> 'e3)
-> ('a, 'e3) Result.t Deferred.t
It's 3 arguments, so no more infix operator. If you end up with
bind_and_handle_error everywhere instead of (>>=?), you soon get annoyed.
Writing MLI Files
The goal is to maintain precise error information, so you diligently
define unique variants for all your functions. Now you've got all
kinds of polymorphic variant types, getting unified, and growing into
larger and larger types. Every function's signature is thus something like
(int, [`Err1 of int | `Err2 of string | .... | `Err20 of unit] Result.t
Type inference gives you this error the first time, but now you
copy/paste into your mli. When some upstream code changes, your module
no longer compiles because the error type changed. So you have to keep
copying/pasting the updated error type. That gets annoying very fast.
An idea to avoid that is to name your error types:
module A : sig
type err1 = [`Err1 of string]
val f : int -> (string, err1) Result.t Deferred.t
end
module B : sig
type err2 = [`Err2 of int]
val g : unit -> (string, [err1 | err2]) Result.t Deferred.t
end
Now, even if you change type err1, the signature for module B remains
valid. However, you've introduced a different annoyance; you have to
name all your error types. Also, you've only avoided one case. If g's
implementation is modified to start using yet another function, then
you do have to change g's signature. Note this is very different from
the usual case. Normally, calling out to additional code in a
function's implementation doesn't change that function's return type.
Now you're in a situation where return types change all the time.
Worse, if g's implementation stops using f, the signature of g should
be changed but the compiler won't tell you that. Quickly you'll have
functions that say they can return error `Foo but actually they cannot.
Coding Expertise
Historically beginners have been advised to avoid polymorphic
variants. Although this seems to be less true now on, it is certainly
the case that coding with them is a bit harder. Example:
With this style, you end up wanting to define a function like:
val with_file :
string ->
f : (t -> (‘a, ‘b) Result.t ->
(‘a, [> err | ‘b]) Result.t
The idea is that opening the file might return an error err. If the
file is opened successfully, you call f, and f might return error `b.
Thus, the result type should have errors [err | `b]. However, the
above doesn't type check. The signature actually has to be (thanks to
Sebastien Mondet for teaching me this years ago):
val with_file :
string ->
f : (t -> (‘a, [> err] as ‘b) Result.t ->
(‘a, ‘b) Result.t
Do you want your user and developer base to deal with such signatures?
It is unclear what this signature is saying. We now have err mentioned
in the return type of f, but in our original intention err has nothing
to do with f. Of course, there is an explanation that makes sense of
it (but I'll skip it).
I seem to be arguing strongly against the use of polymorphic variants,
but I'm not. I'm really unsure what the right answer is. A lot of the
problems I'm mentioning would go away if there was a project wide
standardization of the particular polymorphic variants used. The value
of using them is high, so actually I hope they will be used.