Labelled exceptions for smoother error handling
1st November 2023 - Language design , Nim , Programming
Error handling, a contentious and oft-discussed topic. Each language has some way of dealing with errors, be it error codes, result types, exceptions, something entirely different, or a mix. How to properly deal with things going wrong in our programs has been important pretty much ever since we started writing programs. Papers have been written about it, talks have been held on the topic, and countless libraries have been written to bring one languages way of dealing with errors into other languages.
But this won’t be an article about every possible way of dealing with error handling. Instead I’ve experimented with an addition to the common exception way of dealing with errors. Exceptions, as you’re probably aware, is a special control-flow mechanism where a procedure can say that something went wrong to the point where it won’t be able to return anything sensible and the program should deal with this in a different way. For example if we’re trying to write to a hard-drive but the drive is full we will probably get an IO error thrown at us telling us that the write can’t possibly succeed and must be dealt with in a different way. These errors are great at telling us why something went wrong, but not very good at telling us what went wrong. By this I mean that they will tell us that a write operation failed because of an IO error (or even more specific types in some cases), but not which of our write operations it was that failed. The stack trace will tell us where in our code the exception occurred, but sadly this information is rarely available to us as we deal with the exception. In order to provide fine-grained error messages this means that every section of our code has to be wrapped in its own try
/except
section.
try
/except
is the Nim terminology for this, most languages calls ittry
/catch
instead.
In many languages this also means that variables needs to first be declared outside the try
block, then assigned within it, and then used afterwards. And it also opens up the pitfall of forgetting to return in the error handling part which would lead to the code carrying on with an erroneous state. It also means that we end up with a lot of copy/pasting of our error handling, which makes refactoring harder and clutters up our code. Let’s imagine a simple flow in a web-server, a user comes in and wants to get the latest news, with related stories:
let
user = getUser(userInfo)
news = getNewsForUser(user.id)
relatedNews = getRelatedNews(news)
return %*{"data": {"news": news, "relatedNews": relatedNews}}
Very simple and obvious flow there, now let’s add some fine-grained error handling:
var user: User
try:
user = getUser(userInfo)
except CatchableError as e:
echo "Cannot get user: " & e.msg
return %*{"error": "Cannot get user " & userInfo.name}
var news: seq[News]
try:
news = getNewsForUser(user.id)
except CatchableError as e:
echo "Cannot get news for user: " & e.msg
return %*{"error": "Cannot get news for user " & userInfo.name}
var relatedNews: Table[NewsId, seq[News]]
try:
relatedNews = getRelatedNews(news)
except CatchableError as e:
echo "Cannot get related news for user: " & e.msg
return %*{"error": "Cannot get related news for user " & userInfo.name}
return %*{"data": {"news": news, "relatedNews": relatedNews}}
Not only has our code grown from 5 to 19 lines, but it’s now also considerably harder to tell at a glance what this code does since it’s mostly error handling. If we instead use coarse error handling it looks better:
try:
let
user = getUser(userInfo)
news = getNewsForUser(user.id)
relatedNews = getRelatedNews(news)
return %*{"data": {"news": news, "relatedNews": relatedNews}}
except CatchableError as e:
echo "Cannot get content for user: " & e.msg
return %*{"error": "Something went wrong fetching content for user " & userInfo.name}
Only 9 lines and the flow is clear, but the error message is now much less detailed making our job of supporting users with issues much harder. The problem here is that all these three statements can fail with the same errors, perhaps an IO error if we don’t have network, or a token expired error if we need to refresh our API access token, or any other granularity of error cases. This means that having more except
blocks can give us more information about why our code failed, but not which part of it actually failed.
Labelled exceptions
The experiment I’ve tried out is a way to label, or tag a statement in a way that makes the statement responsible for the exception available in the error handler. This allows us to add contextual information about what went wrong to our error messages, while allowing our code to stay readable, safer, and more easily refactorable. Let’s label our statements in our coarse error handling example:
labeledTry:
let
user = getUser(userInfo) |> UserErr
news = getNewsForUser(user.id) |> NewsErr
relatedNews = getRelatedNews(news) |> RelatedErr
return %*{"data": {"news": news, "relatedNews": relatedNews}}
except CatchableError as e:
const msgs: array[Label, string] =
[NoLabel: "content", UserErr: "user details", NewsErr: "news", RelatedErr: "related news"]
echo "Cannot get " & msgs[getLabel()] & " for user: " & e.msg
return %*{"error": "Cannot get " & msgs[getLabel()] & " for user " & userInfo.name}
We’ve added the small postfix |> LabelName
to parts of our code which can fail, assigning them each a label. Then in our exception handler we have an array set up with messages that maps to each of the possible labels. Notice that since we’ve specified that this is an array of Label
and string
we know that the array must cover all the error messages, assigning a string to each. The following array is a bit verbose in listing out each of the values of the enum along with the string, but it keeps us from accidentally reshuffling them. We then pick out the relevant message for both our log line and the user error making sure that we have the context of what actually went wrong in our error message.
The original flow is kept quite clean, only having small label postfixes. And the error handling can now distinguish between all our labelled error origins and the special NoLabel
case for exceptions thrown in an unlabelled location. This gives us this final and crucial piece of information that allows us to build good error messages. Since the labels are built as an enum we can also be guaranteed that we cover all the cases with a case
statement (or an array indexed by the labels as seen above), so adding or removing a label in the try block will create a compilation error in our exception handler. It also makes it obvious to the reader where and how each error is handled. This is something which is hard to do in regular exception handling code as the exceptions are defined within the procedures and are generally invisible to the reader without editor tools.
In cases where we have more than one statement which can throw exceptions that are all related to the same thing we also have a block variant of the labelling system which takes a label and a block of code and every part of that code which throws an exception will get the same label applied to them. This allows us to control the granularity of the labelled exceptions to our liking.
Conclusion
All in all I believe this kind of tagging or labelling system can provide a very interesting addition to typical exception systems. The syntax here was chosen fairly arbitrary for testing purposes, and the current implementation might not be perfect, but what’s important is the feature itself. If you want to play around with it the code is available as a library for Nim over on my GitHub. Also a big thanks to user ElegantBeef in the Nim community who was a great help in puzzling together how to achieve this.
This is one of the many reasons I really like the Nim programming language. Apart from how easy it is to write, how fast it runs, and how it can run pretty much everywhere. This kind of flexibility that it offers through the macro system which makes it possible to write language experiments like this as simple libraries is astounding. The way you’re able to build your own specialised syntax, and improve the language as you see fit is a great power. Of course with great power comes a certain responsibility, and especially when working with others it’s important to not go overboard with this kind of stuff. But it certainly is a nice and powerful tool to have in your belt for when you need it. If you want to read more about this kind of meta-programming I have an older, but still valid article here. Note that the project mentioned in that article just went under it’s third revision and the macro developed in that article is still doing it’s job absolutely perfectly!