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 hasa 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 onbugandFailure,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 theOptionaldata type.Optionalhas one type parameter representing the type of the value which or may not be present. If present, the value is wrapped inSome- as inSome "valueHere",and if not the value returned isNone.In some programming languages, the idea that a value might be missing is represented withnullor 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
    _ -> None

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 theBooktype to inspect the variablepages,and then use a function which returns anOptionalvalue,List.at,to see if the desiredPage is a valid index.

Simple enough, for missing value error conditions we can wrap our return value inOptional.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 viapattern matching,in which case we can explicitly change the behavior of the enclosing function based on thedata constructorsofOptional.

match cakeFactory [Kale, Egg, Sugar] with Some cake -> "๐ŸŽ‰" None -> "๐Ÿ˜ข"
โงจ
"๐Ÿ˜ข"

Or we can pass theOptionalnature of the function's return type along via calls to functions likeOptional.mapandOptional.flatMap

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

BothOptional.mapandOptional.flatMapwill apply a given function to theOptionalvalue if it is present, saving you the trouble of doing a check for a value ofNone.

To provide a default value in the case that aNoneis encountered, we could also useOptional.getOrElse.The first argument toOptional.getOrElseis 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 theOptional- so a default value of typeNatcan't be provided for a value ofOptional Boolean.

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

Optionaldoesn't provide enriched information beyond whether a value is present or not. If we need to communicatewhyour 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 encounteredEitherbefore in ourintroduction to pattern matching.At its core,Eitheris 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 onEitherto get you started.

๐ŸŽจ
It's a convention to treat theLeftside of theEitheras a failure case, while theRightside represents the success case in Unison code. This is most obvious in functions likecatchortoEitherwhich 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 theRightbranch of theEitherdata type, you can useEither.mapRight.To apply a function to the value contained in theLeftbranch, you can useEither.mapLeft.Here's what calls to those functions look like:

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

Note that if you callEither.mapLefton aRight,the value will remain unchanged--thelambdaargument toEither.mapLeftcan only be applied to a value when theEitherargument given is aLeft.

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

If you'd like to run one function if the Either returns aLeftand another function when the Either returns aRight,you can useEither.fold.Both the functions need to return the same type, which you can see from the signature ofEither.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 = Left ?z Either.fold ifLeft ifRight myEither
โงจ
"z"

You can see more of the functions defined for Either with thefindcommand 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 functionbase.bugin Unison. UnlikeOptionalorEither- if you callbase.bugin 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 tobase.bugand theUCMwill render that value to the console.

superProgram : Boolean -> Nat
superProgram bool =
  if not bool then
    base.bug (Generic.failure "Fatal Issue Encountered" bool)
  else 100

Calling the function above withsuperProgram falsewill 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)

๐Ÿšฉ Usebase.bugjudiciously, as there is currently no way for a program to recover from a call tobase.bug.

The Failure Type

One of the types you'll want to familiarize yourself with is Unison'sFailuretype. You'll see this type in the signatures of some of the functions in thebaselibrary, for examplecatch : '{g, Exception} a ->{g} Either Failure a.Failureis 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 aFailureupon attempting to save a duplicateUserdatabase error might look like

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

The first argument to thedata constructorforFailureis aType.

You'll need to create aTypewith thetypeLinkliteral. 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 exampleDatabaseErrororGeneric.

The second argument toFailureis aTextbody which should be a descriptive error message.

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

Any 42
โงจ
Any 42

or

Any [1, 2, 3]
โงจ
Any [1, 2, 3]

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

๐Ÿ“šUnison provides a helpfulGenericfailure constructor,Generic.failurefor 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 aFailure:Generic.failure : Text -> a -> Failure

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