Technical groupsOpen sourceCareersResearchBlogContactConsulting Platform
Global consulting partnerEuropean product partnerGlobal Atlassian transformation leader
The three kinds of Haskell exceptions and how to use them

16 April 2020 — by Arnaud Spiwack

Exceptions, in Haskell, are surprising, sometimes puzzling, always difficult. But exceptions, in Haskell, are an unavoidable fact of life. And, as such, you need to learn to love them and to care for them, despite their flaws.

In this blog post, I’d like to explain how I recommend understanding and using Haskell’s exceptions. The fact that these recommendations are somewhat personal, rather than best practices that permeate the community, is a good indication that exceptions really are difficult. For instance the safe-exceptions package makes different recommendations.

Three flavours of exceptions

There are three different kinds of exceptions in Haskell: one is imprecise; two are precise, of which one is synchronous, and one asynchronous. These were all identified in foundational Haskell papers1. I’d like to go over what these different kinds of exceptions mean.

Imprecise exceptions

Imprecise exceptions are exceptions like error "foo", which can be used in pure code. They are so named because it is not determined which exception will be thrown by (error "foo") + (error "bar"). And, in fact, the compiler is very much allowed to change which error will be thrown. You’ll find plenty more details in the original paper: A semantics for imprecise exceptions.

Imprecise exceptions are known to cause distress to Haskell newcomers: if Haskell is a pure language, how come there are exceptions, which are impure? The truth of the matter is that imprecise exceptions are both necessary and unavoidable.

Consider integer division: what should 42 `div` 0 return? There are basically three choices:

  • An (imprecise) exception
  • Nothing
  • An arbitrary value. Say 0.

It would be pretty painful to have to do all arithmetic computation in a Maybe monad. The cost on performance would also be unacceptable. Returning 0 is an option, but you probably didn’t want to divide by 0 to begin with, so it’s just going to obscure your error: in a compound computation, it will just return a nonsense answer. Throwing an exception is the sane thing to do.

It’s also useful to realise that imprecise exceptions don’t add any expressiveness to the language: instead of throwing an exception, I could just return an infinite loop (such as let bot = bot in bot). An imprecise exception is just a more readable infinite loop. The truth of the matter is that throwing exceptions was never a problem for purity: the problem was catching exceptions2. Accordingly, Haskell doesn’t provide a pure catch.

So what are imprecise exceptions for? They denote programming errors. These are situations which are not supposed to occur: someone wrote some code which they were not supposed to write. This could be a division by 0, taking the head of an empty list, de-referencing an array index out of bounds, etc… Basically, whenever a function has a precondition which cannot be checked by the type checker, violating it would ideally throw an imprecise exception (sometimes such a function will return an unspecified value for performance reasons, but it makes contract violation really non-obvious: avoid it as much as possible).

Synchronous exceptions

Synchronous exceptions are thrown with functions such as throwIO: throwing a synchronous exception is an IO action; the exception interrupts the current computation.

Synchronous exceptions are very much like exceptions in most other programming languages. But contrary to what is common in other programming languages, you shouldn’t use them to denote programming errors: we have imprecise exceptions for that.

Synchronous exceptions denote exceptional behaviour, a disk being full, a file being corrupted, etc… Something that you don’t really want to think about when you are writing the body of a function, but you may want to have some exceptional logic to handle these cases (such as a retry logic when a connection fails).

Asynchronous exceptions

Asynchronous exceptions are thrown with functions such as throwTo. Just like synchronous exceptions, throwing an asynchronous exception is an IO action, but an asynchronous exception interrupts the computation in another thread.

The dual of this is that you have to be ready to receive asynchronous exceptions anywhere in IO code, since these exceptions are thrown by someone else. This is especially true of library code, which cannot know whether it will be used in a multi-threaded program or not.

Asynchronous exceptions denote thread cancellation; they are the Haskell equivalent of Unix signals.

Asynchronous exceptions are a common source of problems in Haskell code, which is probably why the safe-exceptions library puts a particular emphasis on asynchronous exceptions. An alternative to supporting asynchronous exceptions would have been to let the thread cancel itself by polling a mailbox, but that approach comes with its own set of problems (e.g. it’s impossible to interrupt a long pure computation). At any rate, Haskell chose the path of asynchronous exceptions, so we have to deal with it.

How to use Haskell’s exceptions

It’s pretty nice that Haskell has given us three different kinds of exceptions to denote three different kinds of behaviours. But the truth is that it didn’t: there is really one kind of exception, and three ways to throw them. It’s really not that easy to distinguish between the three kinds of exceptions in a catch: the intention has been lost.

This is why, I think, exceptions in Haskell are difficult. But here is my methodology to recover a bit of the lost intention.

Imprecise exceptions


Catch

There was a programming mistake, what can you do? Not much. It is time to let the program crash. Maybe you’re running a long-lived application, like a server, then it’s a bad idea to crash the program altogether, instead you only want to let the current task or request to crash.

Concretely, it means that if you are catching an imprecise exception with try or catch, you want to re-throw it, except in a toplevel catch which is responsible for cleanly exiting the current task and reporting an error. Such a toplevel catch should be a blanket catch: catch @SomeException (I find that catch works really well with visible type applications).

In most cases, you are better off not using catch or try, and use bracket, or ResourceT instead: these will properly clean your resources, and re-throw the exceptions for you.

Throw

Since we are not going to catch the error, we don’t need a structured type for imprecise exceptions, so imprecise exceptions might as well be strings. Therefore, simply use error to throw imprecise exceptions.

This is true even in monadic code:

do
  x <- error "foo"case x of
  Good -> returnBad -> error "bar"

both work quite fine. As long as you are reporting a programming error.

There are really two situation where error shows up:

  • There is an impossible case in my function that the typechecker doesn’t recognise, something like
    case prime_number_sieve of
      a:as ->[] -> error "There are infinitely many prime numbers"
  • I’m writing a function which has a precondition that the typechecker can’t express
    head (a:_) = a
    head [] = error "Head on an empty list"

In the latter case: when there is a precondition that users of my function will need to think about, always add a HasCallStack constraint to the function

head :: HasCallStack => [a] -> a

This will ensure that if the function is misused and the precondition is not respected, you will have a stack trace pointing you to the function which doesn’t honour the contract. In other words, use HasCallStack to mark partial functions.

Asynchronous exceptions


Catch

When you receive an asynchronous exception, your thread has to crash. It is the semantics of asynchronous violation. If you don’t let the thread crash, then you will have broken the expectation of the sender.

So, like imprecise exceptions, make sure that if you catch an asynchronous exception, you rethrow it. The best way to achieve this is still to avoid catch and use bracket or ResourceT.

Additionally, you should always be prepared for asynchronous exceptions. The best advice I can give you is: always allocate your resources with bracket or ResourceT, otherwise they may not be properly released.

Throw

The best way to throw an asynchronous exception is by using the cancel function of the async package, which implies that you should not use forkIO to create threads, but one of the functions of the async package as well.

Synchronous exceptions


Catch

You often do want to catch synchronous exceptions with catch or try. But since we want to avoid accidentally catching imprecise or asynchronous exceptions, you need to make sure that you always call catch at a specific exception type (that is: do not use SomeException), which you know can be thrown synchronously

catch @SpecificExceptionType
  body
  handler

If you make sure to always throw imprecise exceptions with error and asynchronous exception with cancel, then you won’t catch any of them. If you are writing library code, you don’t fully control what asynchronous exceptions you can receive, but hopefully, client code won’t go and raise a disk-is-full exception asynchronously.

You can also use HasCatch from the capability library: it will ensure that you only catch exceptions thrown by a corresponding HasThrow instance.

Throw

Because you want to be able to write catch @SpecificExceptionType, make sure that you use specific (not SomeException) types for your synchronous exceptions. In particular: don’t use IO’s fail method.

Defining a new exception type is easy

{-# LANGUAGE DeriveAnyClass #-}

import Control.Exception

data MyException
  = Exception Int Int String
  deriving (Show, Exception)

Throw synchronous exceptions with throwIO or with capability’s HasThrow.

On the safe-exceptions classification

A well-known framework to classify Haskell exception is the framework of the safe-exceptions library. For the most part, my recommendations share the same philosophy as the safe-exceptions framework. We only differ on the handling of imprecise exceptions.

In the safe-exception framework, imprecise exceptions are classified together with synchronous exceptions as the group of exceptions which you want to recover from. I recommend, on the contrary, to consider that imprecise exceptions are more similar to asynchronous exceptions. There is no exceptional behaviour in pure code: pure code is deterministic. And, like asynchronous exceptions, you should expect imprecise exceptions anywhere: imprecise exceptions are bugs.

A small point on terminology: in the safe-exceptions framework, imprecise exceptions are called “impure exceptions”. I am not fond of this terminology: as I’ve argued at the beginning of this post, imprecise exceptions are no more impure than infinite loops. They are just more useful.

In conclusion

Haskell exceptions require a bit of care. But when used carefully they can be very effective.

  • The three kinds of exceptions correspond to how exceptions are thrown, but can’t be fully identified when they are caught. Therefore they require some discipline.
  • Use imprecise exceptions for programming errors. Throw with error, catch with bracket. Use HasCallStack for partial functions.
  • Use synchronous exceptions for exceptional behaviour. Throw specific error types with throwIO, catch specific error types with catch.
  • Asynchronous exceptions cancel threads. Throw with cancel, catch with bracket.
About the authors
Arnaud SpiwackArnaud is Tweag's head of R&D. He described himself as a multi-classed Software Engineer/Constructive Mathematician. He can regularly be seen in the Paris office, but he doesn't live in Paris as he much prefers the calm and fresh air of his suburban town.
If you enjoyed this article, you might be interested in joining the Tweag team.
This article is licensed under a Creative Commons Attribution 4.0 International license.

Company

AboutOpen SourceCareersContact Us

Connect with us

© 2023 Modus Create, LLC

Privacy PolicySitemap