Either with IO?
When we create IO
actions that may require I/O, we risk running into all kinds of errors.
For example, when we use writeFile
, we could run out of disk space in the middle of writing,
or the file might be write-protected. While these scenarios aren't super common, they are definitely
possible.
We could've potentially encoded Haskell functions like readFile
and writeFile
as IO
operations
that return Either
, for example:
readFile :: FilePath -> IO (Either ReadFileError String)
writeFile :: FilePath -> String -> IO (Either WriteFileError ())
However, there are a couple of issues here; the first is that composing IO
actions
becomes more difficult. Previously we could write:
readFile "input.txt" >>= writeFile "output.html"
But now the types no longer match - readFile
will return an Either ReadFileError String
when executed,
but writeFile
wants to take a String
as input. We are forced to handle the error
before calling writeFile
.
Composing IO + Either using ExceptT
One way to handle this is by using monad transformers. Monad transformers provide a way to stack monad capabilities on top of one another. They are called transformers because they take a type that has an instance of monad as input and return a new type that implements the monad interface, stacking a new capability on top of it.
For example, if we want to compose values of a type that is equivalent to IO (Either Error a)
,
using the monadic interface (the function >>=
), we can use a monad transformer
called ExceptT
and stack it over IO
.
Let's see how ExceptT
is defined:
newtype ExceptT e m a = ExceptT (m (Either e a))
Remember, a newtype
is a new name for an existing type. And if we substitute
e
with Error
and m
with IO
, we get exactly IO (Either Error a)
as we wanted.
And we can convert an ExceptT Error IO a
into IO (Either Error a)
using
the function runExceptT
:
runExceptT :: ExceptT e m a -> m (Either e a)
ExceptT
implements the monadic interface in a way that combines the capabilities of
Either
, and whatever m
it takes. Because ExceptT e m
has a Monad
instance,
a specialized version of >>=
would look like this:
-- Generalized version
(>>=) :: Monad m => m a -> (a -> m b) -> m b
-- Specialized version, replace the `m` above with `ExceptT e m`.
(>>=) :: Monad m => ExceptT e m a -> (a -> ExceptT e m b) -> ExceptT e m b
Note that the m
in the specialized version still needs to be an instance of Monad
.
Unsure how this works? Try to implement >>=
for IO (Either Error a)
:
bindExceptT :: IO (Either Error a) -> (a -> IO (Either Error b)) -> IO (Either Error b)
Solution
bindExceptT :: IO (Either Error a) -> (a -> IO (Either Error b)) -> IO (Either Error b)
bindExceptT mx f = do
x <- mx -- `x` has the type `Either Error a`
case x of
Left err -> pure (Left err)
Right y -> f y
Note that we didn't actually use the implementation details of Error
or IO
,
Error
isn't mentioned at all, and for IO
, we only used the monadic interface with
the do notation. We could write the same function with a more generalized type signature:
bindExceptT :: Monad m => m (Either e a) -> (a -> m (Either e b)) -> m (Either e b)
bindExceptT mx f = do
x <- mx -- `x` has the type `Either e a`
case x of
Left err -> pure (Left err)
Right y -> f y
And because newtype ExceptT e m a = ExceptT (m (Either e a))
, we can just
pack and unpack that ExceptT
constructor and get:
bindExceptT :: Monad m => ExceptT e m a -> (a -> ExceptT e m b) -> ExceptT e m b
bindExceptT mx f = ExceptT $ do
-- `runExceptT mx` has the type `m (Either e a)`
-- `x` has the type `Either e a`
x <- runExceptT mx
case x of
Left err -> pure (Left err)
Right y -> runExceptT (f y)
Note that when stacking monad transformers, the order in which we stack them matters. With
ExceptT Error IO a
, we have anIO
operation that, when run will returnEither
an error or a value.
ExceptT
can enjoy both worlds - we can return error values using the function throwError
:
throwError :: e -> ExceptT e m a
and we can "lift" functions that return a value of the underlying monadic type m
to return
a value of ExceptT e m a
instead:
lift :: m a -> ExceptT e m a
for example:
getLine :: IO String
lift getLine :: ExceptT e IO String
Actually,
lift
is also a type class function fromMonadTrans
, the type class of monad transformers. So technically,lift getLine :: MonadTrans t => t IO String
, but we are specializing for concreteness.
Now, if we had:
readFile :: FilePath -> ExceptT IOError IO String
writeFile :: FilePath -> String -> ExceptT IOError IO ()
We could compose them again without issue:
readFile "input.txt" >>= writeFile "output.html"
But remember - the error type e
(in both the case Either
and Except
)
must be the same between composed functions! This means that the type representing
errors for both readFile
and writeFile
must be the same - that would also
force anyone using these functions to handle these errors - should a user who
called writeFile
be required to handle a "file not found" error? Should a user
who called readFile
be required to handle an "out of disk space" error?
There are many, many more possible IO errors! "network unreachable", "out of memory",
"cancelled thread", we cannot require a user to handle all these errors, or
even cover them all in a data type.
So what do we do?
We give up on this approach for IO code, and use a different one: Exceptions, as we'll see in the next chapter.
Note - when we stack
ExceptT
on top of a different type calledIdentity
that also implements theMonad
interface, we get a type that is exactly likeEither
calledExcept
(without theT
at the end). You might sometimes want to useExcept
instead ofEither
because it has a more appropriate name and better API for error handling thanEither
.