When we're writing functional code involving errors, we often find ourselves
reaching for a type like Either
(usually ExceptT
): we put our "success
type" on the Right
, and our "error type" on the Left
. When our code gets
more complicated, however, we're going to find ourselves introducing multiple
error types (see Matt Parsons'
blog
for a nice introduction to this practice). This is great, but the solution is
also a new problem: our error types are not all the same! In order to use the
monad instance, we need all our results to have the same Left
type. How do we
have both?
One solution is the nested Either
type. As our error catalogue grows, so does
our type signature:
Possible errors | Type |
---|---|
1 | ExceptT a IO () |
2 | ExceptT (Either a b) IO () |
3 | ExceptT (Either a (Either b c)) IO () |
4 | ExceptT (Either a (Either b (Either c d))) IO () |
... | ... |
This is fine: we can use some type synonyms to hide all this noise (type Errors = Either ...
), or maybe even alias Either
(type (+) = Either
) to
something smaller. Both are acceptable, but it comes with a big maintenance
burden. The structure of the Either
type is quite fragile, and adding more
errors to the catalogue will invariably break other code (what was once added
with Right . Right . Right
is now Right . Right . Left
). Add to that the
fact that it's just noisy. What if we had...
Either | Variant |
---|---|
a |
Variant '[a] |
Either a b |
Variant '[a, b] |
Either a (Either b c) |
Variant '[a, b, c] |
Either a (Either b (Either c d)) |
Variant '[a, b, c, d] |
With the Variant
type, we declare (in the type) the list of possible values,
just as we do with Either
. The only real difference at this point is that the
syntax is nicer! Still, there must be more to it; what can we do with a
Variant
?
The library also defines
VariantF
, which works in the same way, but the type also mentions a type constructor, and the list of types are applied to it. For example,VariantF IO '[Int, String]
is actually eitherIO Int
orIO String
. We can think ofVariant
as the special case ofVariantF Identity
.
Typically, a module involving a Variant
may need some of the following
extensions, depending on what you're doing with it:
{-# LANGUAGE
DataKinds
, FlexibleContexts
, MonoLocalBinds
, RankNTypes
, ScopedTypeVariables
, TypeApplications
, TypeOperators #-}
throw :: xs `CouldBe` x => x -> Variant xs
Given some variant of types xs
(e.g. '[Int, String, Bool]
), if we have some
type x
in that variant, we say that the variant could be x
. throw
lets
us lift any type into a variant that could be that type! In other words:
eg0 :: Int -> Variant '[Int]
eg0 = throw
eg1 :: Bool -> Variant '[Bool, String]
eg1 = throw
eg2 :: IO () -> Variant '[Int, IO (), Bool]
eg2 = throw
Now, why do we call it throw?
catch :: Catch x xs ys => Variant xs -> Either (Variant ys) x
The catch
function effectively "plucks" a type out of the constraint. In
other words, if I catch @String
on a Variant '[Int, String, Bool]
, the
result is Either (Variant '[Int, Bool]) String
. This allows us to remove
errors from the catalogue as we go up up the call stack.
The name is a reference to the throw
/catch
exception systems in other
languages. In Java, I may see a definition like this:
public static void whatever() throws ExceptionA, ExceptionB
The equivalent in Haskell using this library would be:
main
:: ( e `CouldBe` ExceptionA
, e `CouldBe` ExceptionB
)
=> String -> Either e ()
The interesting thing about the above two functions is that you should almost
never see the Catch
constraint in one of your signatures. Let's see an
example:
data NetworkError = NetworkError
data UserNotFoundError = UserNotFoundError
getUser
:: ( e `CouldBe` NetworkError
, e `CouldBe` UserNotFoundError
)
=> String
-> ExceptT (Variant e) IO String
getUser = \case
"Alice" -> throwM NetworkError
"Tom" -> pure "Hi, Tom!"
_ -> throwM UserNotFoundError
We've got ourselves a fresh (and extremely contrived) bit of business logic! Notice that, according to the constraints, a couple things could go wrong: we could have a network error, or fail to find the user!
Now, let's say we're calling this from another function that does some more contrived business logic:
renderProfile :: e `CouldBe` NetworkError => ExceptT (Variant e) IO ()
renderProfile = do
name <- catchM @UserNotFoundError getUser \_ -> do
liftIO (putStrLn "ERROR! USER NOT FOUND. Defaulting to 'Alice'.")
pure "Alice"
liftIO (putStrLn name)
Here, we've tried to call getUser
, and handled the UserNotFoundError
explicitly. You'll notice that, as a result, this signature doesn't mention
it! Thanks to some careful instance trickery, a CouldBe
and a Catch
constraint will actually cancel each other out!
This library gives us all the benefits of Haskell's type system, forcing us to be explicit about all the possible errors we encounter, but doesn't force us to stick to a concrete error stack throughout. Our code is less fragile, our functions are decoupled, and error-handling is actually bearable!