Erik Explores

Erik Explores

Share this post

Erik Explores
Erik Explores
Handling Side Effects in Unison
Languages

Handling Side Effects in Unison

How to separate pure functions from functions with side effects

Erik Engheim's avatar
Erik Engheim
Jan 20, 2023
∙ Paid

Share this post

Erik Explores
Erik Explores
Handling Side Effects in Unison
Share

In the world of pure functional programming, the big bogeyman is functions with side effects. What exactly do we mean by side effects? A pure function works like functions in mathematics. The output of the function is defined exclusively by its input arguments.

However, in programming, some functions are sneaky and alter some internal state or produce output by reading some mutable state. Mutating state simply means to alter some data. If this sounds abstract, let me give a simple example: When you add or remove values from a collection such as a dictionary or array, you are mutating state.

Functions which read or alter shared state are bad because they make programs harder to test and debug. They also make running code in parallel very difficult. If two functions running at the same time try to alter and read from a shared state simultaneously, we obviously get into trouble.

The problem is that we cannot avoid functions with side effects. We need to be able to read input from users or write to files or network sockets. Pure functional programming languages have tried to minimize this issue by cleanly separating functions which don't alter state, so-called pure functions, from those that do. Haskell infamously uses Monads to achieve this. Monads are a concept which has left many developers scratching their heads over the years.

Thus, I was intrigued when I read that Unison is a Haskell-ish language which takes a different approach called Algebraic Effects, which is supposed to make writing code with side effects much easier. The questions is: Does Unison deliver on that promise?

Down the Rabbit Hole of Algebraic Effects

I don't know if I can give a straight answer to that question. Over the last few days, I have gone down the rabbit hole to explore this strange new world of Algebraic Effects in Unison. I am uncertain if this stuff is really hard to get or if I am just getting old.

What Unison is promising is making it easier to write code once you understand Abilities. An Ability is a Unison concept for implementing Algebraic Effects. Based on my current experience with Unison, that claim seems reasonable to me. Mainstream languages have over the last few years inherited Monad style functionality to deal with errors and optional values. My main experience is within the Swift programming language. Your milage may differ, but in my experience it can get quite clunky with wrapping and unwrapping of optional values. Let us contrast this experience with how reading and writing text to the console works in Unison:

main : '{IO, Exception} ()
main _ =
    printLine "What is your name?"
    name = console.getLine ()
    printLine ("Hello " ++ name ++ "!")
    printLine "What is your age?"
    age = console.getLine ()
    printLine ("You are " ++ age ++ " years old!")

The code works like regular Unison code, despite doing I/O which requires side effects. We can compare with a similar example in Haskell. I just grabbed some quick examples from the Haskell wiki on I/O for comparison. The first version of the code shown looks like the following:

main = putStrLn "Hello, what is your name?"
      >> getLine
      >>= \name -> putStrLn ("Hello, " ++ name ++ "!")

The use of the >> and >>= operators makes this code look somewhat cryptic. Haskell guys simplify this kind of code by using the do keyword, which performs some magic which makes the >> and >>= operators somehow evaporate.

main = do putStrLn "Hello, what is your name?"
          name <- getLine
          putStrLn ("Hello, " ++ name ++ "!")

Admittedly, I have forgotten how most of this works in detail, and it may not be obvious to you how it works either, but that is not relevant. The key takeaway is that you can observe that I/O in Haskell requires using several funky operators and writing code in an unusual way. For instance, with the do keyword you have to use <- to assign to a variable, instead of the normal equal = symbol.

So, Unison makes writing code with side effects easier. The catch is that understanding the machinery behind this capability is tricky, but doable with practice and dedication. The official Unison Web page already has an introduction to Abilities, which you might want to read. My aim with this article is to point out the stumbling blocks I experienced in the hope that it helps you along while trying to wrap your head around this new functional programming magic.

The Handle-With Statement

One of the things that tripped me up when reading the official description about abilities is that the role of the expression handle f with h was buried so deep. It is at the core of how you deal with side effects. It ties a function f with side effects together with a handler function h. The hfunction handles each instance of side effects produced by function f.

I like to think about f and h as two coroutines, which the handle f with h expression manages through some kind of coroutine scheduling (only one function runs at a time. f is suspended while h handles requests). f and h have to communicate with each other through some shared interface. If you look at two coroutines in Go or Julia, they will typically share a channel object of a specific type. The channel type says what kind of objects you can push through from one coroutine to another. Catering to the pedantic: Yes, Go doesn't use coroutines, but goroutines, but the difference is irrelevant to this example.

In Unison, there are no channels. Instead, you have an Ability type which defines the types of requests which can be sent from coroutine f to coroutine h and what type of object h can respond with.

NOTE: Unison docs do not explain this relation in terms of two coroutines, and so it may be an inaccurate description. I just think this helps in conceptualizing what is going on.

Allow me to clarify the concept with an example. The following is an ability for getting individual values; either a natural number or a character.

structural ability Getter  where
  getChar : {Getter} Char
  getNum  : {Getter} Nat

It is a stupid way of writing such an interface, but I am deliberately making it elementary to reduce the number of things you have to keep in mind when following my description.

getChar and getNum are the operations of the Getter ability. You can call them as functions taking no arguments and returning a Char and Nat value respectively. When you call them, they each construct a different request object. The type of the request object will be dependent on what your f function returns. If your side effects function f returns say an array of natural numbers (type [Nat]) then calling getNum will create a request object of type Request {Getter} [Nat] which gets sent to the handler h.

The handler performs pattern matching on the received request object to figure out whether it is getting a getChar or getNum request. When the f function completes, it will return a value which is passed on as a request to the handler h. That terminates the exchange between the two functions (coroutines). I have illustrated the exchange between a side effect function called doStuff and a handler called handler below. In this illustration, the handler responds with the number 42 when it receives a getNumrequest.

Communication between a side-effect function doStuff and a handler function.
Communication between a side-effect function doStuff and a handler function.

I lied a little bit when I said we connect a side effects function f or doStuff with a handler h. To be more precise, I should have written handle e with h, where e is and expression. You can think of e as akin to the code within a try-catch statement in a mainstream programming language such as Java. The try clause lets you write some code which you want to evaluate and which potentially throws an exception. Similarly, handle lets you write some code which may have side effects. Thus, my illustration of communications between doStuff and handler is set in motion with the statement:

handle !doStuff with handler

Which is a shorthand for:

handle (doStuff ()) with handler

In other words, we are not merely passing a doStuff function object to handle. Instead, we are actually executing the doStuff function in the context of the handle-with statement.

Think of it this way: Whenever you want to run a function or code with side effects, it has to happen within the context of a handle-with statement. The handle-with statement will evaluate to the result from your side effect function doStuff.

You can, however, run code with side effects as long as the function running this code is marked as having side effects. In Unison, that is done by attaching one or more Ability types to the function type signature. Think of how try-catch works in Java. A method in Java cannot call another method which throws an exception unless the enclosing method is also marked as throwing the same exception. The requirement to signal that you throw an exception thus bubbles up the call stack. It is the same with side effect functions. You can only call a function with side effects from another function marked has having side effects (a function with one or more abilities in its signature).

So, how do you stop this whole bubbling up chain? In Java, you stop it by having a function enclose throwable code with try-catch. Likewise, in Unison, you stop the side effect requirement from bubbling up further by enclosing your side effect code within a handle-with statement.

In reality, you will not see a lot of handle-with statements when writing Unison code because it has been tucked away in higher-level functions handling all the details. How this work will become clearer if we look at how we actually write a handler function for the Getter ability.

Writing a Handler Function

We are going to write a handler to deal with a simple side effect function written as follows:

doStuff : () -> {Getter} [Nat]
doStuff _ = [getNum, getNum, getNum]

Here, doStuff is a function taking no arguments and returning a list of natural numbers obtained by calling getNum 3 times. Because doStuffreturns a list of numbers, our handler also has to do the same.

handler : Request {Getter} [Nat] -> [Nat]
handler req = match req with
    { getNum -> resume } -> 
       handle   
          (resume 42)    -- send 42 back to doStuff
       with 
          handler        -- resume has side-effects
    { result } -> 
          result         -- the list of numbers

The type of the input req is Request {Getter} [Nat] and the return type of handler is [Nat] because that is what doStuff returns. The handler is supposed to return the result eventually for doStuff.

The handler gets both a request such as getNum and continuation called resume. You could call the continuation whatever you want. resume is just a common convention. You can think of it as getting passed a yield function which when invoked resumes execution of the paused coroutine which sent it. The current coroutine yields (the handler) and hand over control to the doStuff coroutine by passing it the value it requested; 42 in this case.

One thing that tripped me up at first was that handler functions tend to be recursive. However, that is not surprising given that resume must have side effects. Keep in mind that resume 42 is the same as running doStuffwhich is a function with side effects. Remember that after we process the first getNum we will get two more getNum requests.

For this reason, resume must also be enclosed in a handle-with statement. All these recursive calls have to be terminated at some point, since eventually the doStuff function completes execution and returns its result. That triggers the final request to the handler, but this time there is no continuation passed over because there is no execution to continue. This last request is represented with the {result} -> result statement, which terminates the execution and returns result from the handler.

Running Side Effects Code with a Handler

So, we got a handler. That means we can finally run our doStuff function.

> handle !doStuff with handler
  ⧩
  [42, 42, 42]

This code is pretty useless because we are always returning the same value 42. We need to have a more generic handler which can supply us with different numbers.

Writing a More Generic Handler

Functional programming tends to be a lot about higher-order functions: Functions which return other functions or take other functions as arguments. To get a more generic handler, we will write a higher-order function which takes as input a function with side effects and the value we want to supply to this function. Let us call it runNumGetter:

runNumGetter : Nat -> '{Getter} [Nat] -> [Nat]
runNumGetter n doStuff =
    handler : Request {Getter} [Nat] -> [Nat]
    handler = cases
        { getNum -> resume } -> handle (resume n) with handler
        { result } -> result  
    handle !doStuff with handler 

Here is an example of using runNumGetter to supply the number 5 each time getNum is called:

> runNumGetter 5 doStuff
  ⧩
  [5, 5, 5]

What changes did we make to the previous handler? We replaced resume 42 with resume n, where n is the first argument to runNumberGetter. Let us look at the function signature:

runNumGetter : Nat -> '{Getter} [Nat] -> [Nat]
runNumGetter n doStuff

What the signature tells us is that n has type Nat and doStuff has type '{Getter} [Nat]. runNumGetter returns a value of type [Nat]. doStuffis a function taking no arguments and returning an array of natural numbers while producing side effects. Formally, that is expressed as () ->{Getter} [Nat]. However, using the single quote gives us a shorthand '{Getter} [Nat]. The reason this shorthand is useful is that a lot of functional programming deals with delayed computations. If I write 42 it will be evaluated immediately. But if I wrap it in a function () -> 42 then that value will not be evaluated until the function is called.

You will find the concept of delayed computations in many mainstream languages with functional inspiration. One example is Swift, which support lazy properties and where global variables are always lazily initialized. In Swift, one can tag a function argument as an autoclosure. It means Swift will automatically wrap an argument at the call site in closure. It makes it possible to implement things like assert more easily.

Quoting and Delayed Computations

Delayed computations give us a way of passing around expressions with side effects until they can be run in the context of a handle-with statement. You cannot evaluate doStuff where runNumGetter is called. Instead, you need to delay the evaluation. Thus instead of writing runNumGetter [getNum, getNum] handler, you write runNumGetter '[getNum, getNum] handler because that delays the evaluation of the expression with side effects by stuffing it inside a function taking no arguments. Conceptually, it makes sense to talk about this as quoting an expression or delaying and expression.

Handling Character Requests

The handler example I have given is unrealistic because it doesn't handle every request possible. For instance, it doesn't handle getChar. That is because it would change the return type of the handler. You have to return one type every time. Let me give an example of a getChar handler to clarify:

runCharGetter : Char -> '{Getter} [Char] -> [Char]
runCharGetter ch doStuff =
    handler : Request {Getter} [Char] -> [Char]
    handler = cases
        { getChar -> resume} -> handle (resume ch) with handler
        { result } -> result  
    handle !doStuff with handler 

Notice this handler doesn't return a list of natural numbers, but a list of characters ([Char]). Here is an example of running it:

> runCharGetter ?A '[getChar, getChar, getChar]
  ⧩
  [?A, ?A, ?A]

Normally, you would place these different ability operations in separate ability types. So, why did I mix them? For educational purposes, I wanted to make it clear to you how the return type of the handler is determined. You can see here that depending on the values you handle, you return a [Nat] or a [Char]. Once you work with more generic handlers, it becomes harder to keep track of what all the type parameters are. Allow me to clarify what I mean by type parameters. The Unison dictionary type Mapis defined as:

unique type Map k v

Here, k and v are the identifiers used to refer to the type parameters for the key and value type. Thus, Map Char Int would refer to a concrete dictionary type with Char keys and Int values. To make more useful abilities, we would use type parameters.

Generic Handlers Using Type Parameters

To make the Getter ability more useful, it should allow for any kind of value to be gotten, not just a Char or a Nat value. The handlers should also be written to deal with any of these values. Unison already has such an ability defined:

structural ability Ask a where 
    ask : {Ask a} a

Here, Ask uses the type parameter a to define the type of the value which should be returned when the ask ability operation is executed.

The handler made to work with this ability called provide.handler is also much more generic in that it allows you to run side effect functions with any return type:

provide.handler : a -> Request {Ask a} r -> r
provide.handler a =
  h req = match req with
    { r }           -> r
    {ask -> resume} -> handle (resume a) with h
  h

Notice how the value you will get is of type a which means it can be anything. By providing a value to the handler, you also bind the a type parameter used with the Request object. That means your doStufffunction has to request values of type a. The return type r is also parameterized. The [Nat] and [Char] return types are gone. The handler r type will match the return type r of the doStuff function. The Request type binds these types together. Because doStuff will produce requests of type Request {Ask a} r we have a way of capturing what the return type of doStuff is in the handler.

This explains why Request must contain the return type of the side effect function called. If it didn't, there would be no way for us to bind this type to the r type parameter in the handler. In our very first example, acorresponded to the Nat type, while the returned value type r was the [Nat] type.

To use the handler, Unison gives us the provide function (I simplified the definition):

provide : a -> '{Ask a} r -> r
provide a asker = 
	handle !asker with (provide.handler a)

The provide function allows us to run a quoted expression asker of type '{Ask a} r which has side effects. This expression evaluates to a value of type r. Alternatively, you could phrase this as running a function of type () ->{Ask a} r.

Thus, with provide we can have expressions with all kinds of different return types:

> provide 12 '(ask + ask)
  ⧩
  24

> provide 42 '[ask, ask, ask]
  ⧩
  [42, 42, 42]

> provide 67 '(Char.fromNat ask)
  ⧩
  Some ?C

And naturally the provided value doesn't need to be a number, as in the examples above. It could be anything. Here I am feeding provide a string and a dictionary.

> provide "Hello" '(ask ++ ask)
  ⧩
  "HelloHello"

> dict = Map.fromList [(1, "one"), (2, "two")]
> provide dict '(Map.lookup 2 ask)
  ⧩
  Some "two"

A lot of the examples I have shown thus far are like toy examples without any clear usage apart from showing how abilities, side effects and handlers are used together. Let us look at something useful.

Consider becoming a free or paid subscriber. Paid subscription is available as a free trial and gives you full access to the rest of this article and others.

More Useful Handlers

The Store ability is a bit more useful than Ask because you can get back values you have previously stored. It supports two ability operations, put and get.

structural ability Store a where
  put : a ->{Store a} ()
  get : {Store a} a

The get operation takes no arguments but returns a value of type a. put is the opposite. It takes a value of type a and returns nothing. You can see a great example of how the Store is used to create a stack machine in the Unison standard library documentation. In the example they define three functions push, pop and add which all have side effects:

stack.push : a ->{Store [a]} ()
stack.push n =
  use List +:
  Store.put (n +: Store.get)

pop until push takes no argument but return a value of type a

Keep reading with a 7-day free trial

Subscribe to Erik Explores to keep reading this post and get 7 days of free access to the full post archives.

Already a paid subscriber? Sign in
© 2025 Erik Engheim
Privacy ∙ Terms ∙ Collection notice
Start writingGet the app
Substack is the home for great culture

Share