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 onbug
andFailure
,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 theOptional
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 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 withnull
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
_ -> 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 theBook
type to inspect the variablepages
,and then use a function which returns anOptional
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 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:
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 viapattern matching,in which case we can explicitly change the behavior of the enclosing function based on thedata constructorsofOptional
.
Or we can pass theOptional
nature of the function's return type along via calls to functions likeOptional.map
andOptional.flatMap
cakeDescription : Optional Text
cakeDescription =
map (c -> "For sale, tasty cake!") <| cakeFactory [Sugar, Egg, Flour]
BothOptional.map
andOptional.flatMap
will apply a given function to theOptional
value 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 aNone
is encountered, we could also useOptional.getOrElse
.The first argument toOptional.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 theOptional
- so a default value of typeNat
can't be provided for a value ofOptional 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 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 encounteredEither
before in ourintroduction 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 onEither
to get you started.
To apply a function to a value contained in theRight
branch of theEither
data type, you can useEither.mapRight
.To apply a function to the value contained in theLeft
branch, you can useEither.mapLeft
.Here's what calls to those functions look like:
Note that if you callEither.mapLeft
on aRight
,the value will remain unchanged--thelambdaargument toEither.mapLeft
can only be applied to a value when theEither
argument given is aLeft
.
If you'd like to run one function if the Either returns aLeft
and 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 thefind
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 functionbase.bug
in Unison. UnlikeOptional
orEither
- if you callbase.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 tobase.bug
and theUCMwill render that value to the console.
superProgram : Boolean -> Nat
superProgram bool =
if Boolean.not bool then
base.bug (Generic.failure "Fatal Issue Encountered" bool)
else 100
Calling the function above withsuperProgram 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)
๐ฉ Usebase.bug
judiciously, 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'sFailure
type. You'll see this type in the signatures of some of the functions in thebase
library, for examplecatch : '{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 aFailure
upon attempting to save a duplicateUser
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 "Bob"))
The first argument to thedata constructorforFailure
is aType
.
You'll need to create aType
with thetypeLink
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 exampleDatabaseError
orGeneric
.
The second argument toFailure
is aText
body which should be a descriptive error message.
The third argument is of typeAny
.Any
is another Unison built-in. It wraps any unison value. For example:
or
You can useAny
to capture the value that has produced the error result. If that value is not useful for error reporting, you can always useUnit
as a placeholder,Any ()
.
๐Unison provides a helpfulGeneric
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 aFailure
:Generic.failure : Text -> a -> Failure
genericFailure : Failure
genericFailure =
Generic.failure
"A failure occurred when accessing the user" (User "Ada")