/labeltry

A new approach to dealing with exceptions

Primary LanguageNim

Labeled exceptions

This is a small package/experiment to deal with exceptions a bit more ergonomically. Unlike things like wrapping exceptions in an optional Result type this is designed to not interfere with the regular control flow. The idea is that while libraries can define why something went wrong with exceptions, they don't really allow us to filter on what went wrong. The traditional motiving example is something like a traditional web flow:

let user = try:
  getUser(userInfo)
except CatchableError as e:
  echo "Cannot get user: " & e.msg
  return %*{"error": "Cannot get user " & userInfo.name}
let news = try:
  getNewsForUser(user.id)
except CatchableError as e:
  echo "Cannot get news for user: " & e.msg
  return %*{"error": "Cannot get news for user " & userInfo.name}
let relatedNews = try:
  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.value, "relatedNews": relatedNews.value}}

Here we have three actions which simply do three actions and use the results from the past actions while giving fine-grained error messages. This however obscures the actual logic in all the error handling. The alternative is to just have everything in one big try/except and end up with coarse error messages. With labeled exceptions however the programmer can throw in some extra information that allows them to identify the exception later on. This adds the crucial "what went wrong" information we need to decouple the exceptions from the application code:

labeledTry:
  let
    user = getUser(userInfo) |> User
    news = getNewsForUser(user.id) |> News
    relatedNews = getRelatedNews(news) |> Related
  return %*{"data": {"news": news.value, "relatedNews": relatedNews.value}}
except CatchableError as e:
  let error = "Cannot get " &
    case getLabel():
    of User: "user " & userInfo.name
    of News: "news for user " & userInfo.name
    of Related: "related news for user " & userInfo.name
    of NoLabel: "<unknown>" # exception thrown without label
  echo error & ": " & e.msg
  return %*{"error": error}

This shows us doing the same three things, but labeling each one with an identifier. We then have one common exception handler which despite all the errors being the same exception type can distinguish between where in our code the exception came from. The label is created as an enum, so with a case statement you are guaranteed by Nim that all the cases are covered and that you can't have cases for labels which don't exist. It is also possible to use a block statement to label all exceptions from a block of code. And as an added bonus these labels are available in the finally branch so you can also know which parts of your code requires cleanup:

labeledTry:
  let user = getUser(userInfo) |> User
  label(News):
    let
      news = getNewsForUser(user.id)
      relatedNews = getRelatedNews(news)
    return %*{"data": {"news": news.value, "relatedNews": relatedNews.value}}
except CatchableError as e:
  let error = "Cannot get " &
    case getLabel():
    of User: "user " & userInfo.name
    of News: "news for user " & userInfo.name
    of NoLabel: "<unknown>" # exception thrown without label
  echo error & ": " & e.msg
  return %*{"error": error}
finally:
  if getLabel() != NoLabel:
    echo "A Labeled exception was thrown in our code!"

This has mostly been an experiment to see what is possible with exceptions and how flexible Nim macros can be. The language is really well suited for this kind of small experiments where you can play around with language ideas purely within your own code. Whether this model of exception handling is actually useful or not depends to be seen.