Error handling with data types

While many programming languages use the convention of creating Exceptions as control flow for when things go awry, in Unison it's common to represent the fact that a computation might fail as a data type. (It's also common to represent failures in Unison using Abilities, but we'll be learning about those later.) The reason we might want to represent "things that can go wrong" with a data type has a storied history 🌯. Here, we'll simply make the assertion that wrapping errors in data types makes it easier for you to imagine what's happening in your program and write programs whose behavior is well defined. For folks who are comfortable with functional data types for errors, you might want to familiarize yourself with the sections on bug and Failure, which are more specific to Unison.

Optional values

In Unison, when the result of an expression may or may not be present, we can return the value in the Optional data type. Optional has one type parameter representing the type of the value which or may not be present. If present, the value is wrapped in Some - as in Some "valueHere", and if not the value returned is Optional.None. In some programming languages, the idea that a value might be missing is represented with null or some other keyword which can be returned in the place of any type--Unison does not have any such keyword. Take a look at the example below:

unique type Book = Book [Text]

flipToPage: Nat -> Book -> Optional Text
flipToPage desiredPage book =
  match book with
    Book pages -> List.at desiredPage pages

In this function, we need to represent the fact that the caller of the function can request a page from a book that doesn't exist. We pattern match on the Book type to inspect the variable pages, and then use a function which returns an Optional value, List.at, to see if the desiredPage is a valid index.

Simple enough, for missing value error conditions we can wrap our return value in Optional. But how do callers of these optional functions react to this?

Let's imagine you come across a function signature like this in a Unison codebase:

At a quick glance we can read the type signature of the function and without knowing anything about the internals of the function, we know that there's at least one circumstance in which we, the function callers, receive no cake! 🍰

One way callers of the function can account for that is via pattern matching, in which case we can explicitly change the behavior of the enclosing function based on the data constructors of Optional.

match cakeFactory [Kale, Egg, Sugar] with Some cake -> "🎉" Optional.None -> "😢"
"😢"

Or we can pass the Optional nature of the function's return type along via calls to functions like Optional.map and Optional.flatMap

cakeDescription : Optional Text
cakeDescription =
  map (c -> "For sale, tasty cake!") <| cakeFactory [Sugar, Egg, Flour]

Both Optional.map and Optional.flatMap will apply a given function to the Optional value if it is present, saving you the trouble of doing a check for a value of Optional.None.

To provide a default value in the case that a Optional.None is encountered, we could also use Optional.getOrElse. The first argument to Optional.getOrElse is the "fallback" value and the second is the optional value to inspect. Note that the default value must be the same type as the value wrapped inside the Optional - so a default value of type Nat can't be provided for a value of Optional Boolean.

defaultCake = Cake "Vanilla" Optional.getOrElse defaultCake (cakeFactory [Kale, Sugar, Flour])
Cake "Vanilla"

Optional doesn't provide enriched information beyond whether a value is present or not. If we need to communicate why our value is not there or some other information about what went wrong we'll need to reach for a different error handling data type.

Either success or failure

We've encountered Either before in our introduction to pattern matching. At its core, Either is a data type which represents a computation that can return one of two kinds of values, but you'll often see it used to convey enriched error reporting. This doc introduces a few useful functions on Either to get you started.

🎨
It's a convention to treat the Either.Left side of the Either as a failure case, while the Either.Right side represents the success case in Unison code. This is most obvious in functions like catch or toEither which rely on this pattern to translate between different error representations. You wouldn't want to accidentally create an error type containing your successful values, so it's important to respect this convention.

To apply a function to a value contained in the Either.Right branch of the Either data type, you can use Either.mapRight. To apply a function to the value contained in the Either.Left branch, you can use Either.mapLeft. Here's what calls to those functions look like:

use Nat + myEither : Either Text Nat myEither = Either.Right 10 Either.mapRight (number -> number + 1) myEither

Note that if you call Either.mapLeft on a Either.Right, the value will remain unchanged--the lambda argument to Either.mapLeft can only be applied to a value when the Either argument given is a Either.Left.

use Text ++ myEither : Either Text Nat myEither = Either.Right 10 Either.mapLeft (message -> message ++ "!!!") myEither

If you'd like to run one function if the Either returns a Either.Left and another function when the Either returns a Either.Right, you can use Either.fold. Both the functions need to return the same type, which you can see from the signature of Either.fold: Either.fold : (a ->{e} c) -> (b ->{e} c) -> Either a b ->{e} c

ifLeft : Char -> Text ifLeft c = Char.toText c ifRight : Nat -> Text ifRight nat = Nat.toText nat myEither : Either Char Nat myEither = Either.Left ?z Either.fold ifLeft ifRight myEither
"z"

You can see more of the functions defined for Either with the find command in the UCM.

Bug--for aborting programs

In some circumstances, you may want to stop your program immediately without allowing any caller of the function to catch or handle the error. For that you can use the built in function base.bug in Unison. Unlike Optional or Either - if you call base.bug in a function, you do not need to represent the possibility of the failure in the function's signature as a data type for the caller to manage.

You can provide a term or expression of any type to base.bug and the UCM will render that value to the console.

Calling the function above with superProgram false will abort the running program with the message

💔💥

I've encountered a call to builtin.bug with the following value:

  Failure (typeLink Generic) "Fatal Issue Encountered" (Any false)

🚩 Use base.bug judiciously, as there is currently no way for a program to recover from a call to base.bug.

The Failure Type

One of the types you'll want to familiarize yourself with is Unison's Failure type. You'll see this type in the signatures of some of the functions in the base library, for example catch : '{g, Exception} a ->{g} Either Failure a. Failure is a unique type whose purpose is to capture information about an error.

Let's look at what we need to construct a failure.

As an example, constructing a Failure upon attempting to save a duplicate User database error might look like

failure.example1 : Failure
failure.example1 =
  Failure
    (typeLink DatabaseError)
    "The username column already contains a value for entry: Bob"
    (Any (User.User "Bob"))

The first argument to the data constructor for Failure is a Type.

You'll need to create a Type with the typeLink literal. The typelink literal allows us to represent a Unison type as a first class value.

Commonly, the type that we're linking to is some data type which represents the domain of the error, for example DatabaseError or Generic.

The second argument to Failure is a Text body which should be a descriptive error message.

The third argument is of type Any. Any is another Unison built-in. It wraps any unison value. For example:

Any 42
Any 42

or

You can use Any to capture the value that has produced the error result. If that value is not useful for error reporting, you can always use Unit as a placeholder, Any ().

📚 Unison provides a helpful Generic failure constructor, Generic.failure for when you don't have a particular type that models your error domain. It takes in an error message and a value of any type to produce a Failure: Generic.failure : Text -> a -> Failure

genericFailure : Failure
genericFailure =
  Generic.failure
    "A failure occurred when accessing the user" (User.User "Ada")