There are a few common data-types that represent when a computation may fail. The reason we might 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 identify 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:
cakeFactory : [Ingredient] -> Optional Cake
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
.
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.
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⧨Either.Right 11
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⧨Either.Right 10
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:
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")