A few tips for designing better error messages

I’ve spent a lot of my time as a developer chasing, handling and catching all sorts of errors โ€“ be it logical or fencepost errors, disk and network failures or compiler errors and runtime exceptions. They all have one thing in common though: Someone has to deal with them.

Before I’ve got into functional programming, I’d never really thought about errors. Sure, I would add some null checks here and there or maybe wrap a piece of code with a try .. catch block. But I didn’t really think about underlying concepts, principles or techniques of handling errors.

However, Haskell and Elm changed that for two reasons.

  1. Haskell has made me familiar with ideas like Maybe, Either and IO.
  2. Elm has shown me what well-designed compiler errors should look like. Here’s an example:

    The type annotation for `testUser` does not match its definition.
    
    6| testUser : User
                 ^^^^
    The type annotation is saying:
    
       { ..., firstName : String }
    
    But I am inferring that the definition has this type:
    
       { ..., firstName : Maybe a }
    

Thanks to these languages, I’m now convinced that the way we design our error messages has a huge impact on the quality of our software. Put differently, we should be able to improve the quality of our software by improving the quality of the error messages that we create.

To have our own error messages match the quality of the Elm compiler, we need to consider how the users of our software will interact with the errors.

Qualities of Well-Designed Error Messages

As a user, I generally don’t want to worry about errors at all. Embracing this, we can derive two important guidelines:

  1. Before showing an error message to a user, think really hard about it. Can you handle the error in your own program, without exposing it to the user? If this is possible, great! But beware: if you hide too much information from your users, debugging will become hard. When in doubt, default to raise an error!

  2. When you show an error message, expose as much relevant information as possible. Help your users to handle it correctly.

When I started following these two guidelines, I’ve noticed that this is much harder than it seems. Well-designed error messages actually require some thought and effort.

Here’s a couple of ideas to help you get started:

  • Be explicit about things that can go wrong. Make potential errors part of the API. Return types like Result, Either or Promise instead of just throwing exceptions. Those types are great because they force your users to handle the error case.
  • Fail early if an error occurs. You don’t want to carry around the error and blow up much later. It should be easy to figure out where an error is coming from.
  • Provide relevant information with every error message. Don’t just say that reading a file failed, but also mention which file couldn’t be read and why the read failed.
  • Stay within the abstraction. When writing to, say, a database, the database library should not directly raise a file or network error. Instead it should return a “write-file error” that contains the actual reason. Use domain terminology.
  • Group related errors into the same namespace / module / file. This makes it easier to get an overview of what else could go wrong.
  • Utilize the type system if you can. Ideally the types tell you what kind of errors can occur for each operation.
  • Recommend solutions in your documentation. Let your users know what they need to do to prevent this error from happening again.

Hopefully, a few ideas of this post can help you to improve your error messages.