Susan Potter
software: Created / Updated

A Haskell view of functional programming ("effectful")

Previously we looked at the hidden meanings appended to the term “functional programming” by many Haskell developers that is assumed in their usage of the term that goes beyond most definitions of functional programming.

Today we embark on a winding journey into Haskell’s techniques for containing those troublesome side effects that so bedevil our pursuit of pure functional bliss. But before skipping merrily down this path, let us pause to recall key insights from our prior installment…

In our last post in this series, we explored Haskell’s unyielding commitment to well-typedness , like a monk devoted to maintaining perfect mindfulness. We saw how Haskell empowers developers to construct rich type systems that elegantly capture domain-specific constraints and invariants.

Haskell’s Approach to Containing Side Effects

Yet real-world programs cannot live by purity alone. Applications must interact with imperfect external environments rife with non-determinism and side effects. What is a functional programmer to do? Despair? Abandon hope? Retreat to a mountain cave for contemplation and prayer? Thankfully not! Haskell offers us multiple techniques to isolate and manage side effects in a functional way to varying degrees.

Today we inspect Haskell’s primary tool for segregating impurity: the IO monad. This construct allows us to embed side-effecting actions in a pure functional setting.

Dividing Pure and Impure Code

Haskell2010 draws a clear line between pure and impure code.

Pure functions are only based on their inputs (arguments), without any side effects (or global constants). This means pure functions are independent of the external world, making them easier to reason about, test, and understand.

Impure code interacts with files, databases, the network, or other behavior yielding non-deterministism. In Haskell, (I am using Haskell 2010) we confine side effecting code within the confines of the IO monad (at the lowest level) and call them actions.

Setting up GHC with Nix

Let’s get started with a simple Nix flake that will support our code exploration in this post.

TODO: Fill this in after work tomorrow.

The IO Monad: Isolating Impurity

Let’s illustrate this with an example. Consider the following pure function that takes a String and produces another String uppercased:

upcase :: String -> String
upcase = map toUpper

The type signature is a pure function, taking a String and returning a String.

Now let’s say we wanted to print the computation to the user’s terminal? This requires we interact with the environment beyond the unit of code’s boundary’s, so we have side effects. To use Haskell’s lowest level of encapsulating side effects we can wrap the action and pure computation in IO like so:

upcaseAndPrint :: String -> IO ()
upcaseAndPrint = upcase <&> putStrLn

We can see by inspecting the type signature of upcaseAndPrint that we are wrapping side effecting code yielding a () in the context of IO. But what does this really do for us?

Benefits of the IO Monad over Leaking Side Effects

The IO monad delivers several jolly benefits:

So IO enables harnessing effects while maintaining separation of concerns and reasonability. Well played, Haskell!

A Better Example

Let’s suppose we need to build the simplest document management system. We will assume:

We will assume through all the effect systems that the following data type definitions exist:


newtype UserId = UserId Int

data User 
  = User 
    { userId :: UserId
    , login :: String
    , fullName :: String
    }

data Document
  = Document
    { title :: String
    , content :: String
    }

This roughly translates to the following type signatures:

getUserById
  :: UserId 
  -> User

getDocumentsByUserId 
  :: UserId 
  -> [Document]

The problem with these type signatures is in the case where we need to interact with the world outside our program, like querying a database, a file systems or reading transactional memory.

So we will leverage IO to wrap the return types up to denote this non-determinism, like so:

getUserById 
  :: UserId 
  -> IO User

getDocumentsByUserId 
  :: UserId 
  -> IO [Document]

Let’s assume we wanted to build these based on top of a Linux system where we shelled out to finger to get user data and then read the Linux user’s home directory for a list of files for the documents for that user.

But we might also want to provide another implementation where we query a database. There are many possible choices of database types too.

In each of these cases our type signature remains the same and the application developer would need to know which module to pull implementations from to use for intended behavior.

Reasoning About Purity

With the separation of pure and impure code, we gain the power to reason about program behavior with clarity. We can analyze and test pure functions independently, confident in their deterministic nature. Pure functions become reliable building blocks, free from the entanglements of side effects.

The IO monad facilitates this reasoning by providing a clear sequencing of impure actions through the Monad typeclass. We can comprehend the step-by-step execution of IO actions, understanding the order of side effects and their relationships. This control over the flow of side effects enables us to reason about the behavior of our programs, ensuring that results can be reproduced with ease.

Effect Systems beyond IO

Haskell’s approach of wrapping the main entry point result in an IO wrapper, backed by the Monad typeclass, brings a harmonious balance between purity and control. By separating pure code from impure actions, Haskell allows us to manually reason about program behavior with clarity, test pure functions independently, and understand the flow of side effects in a controlled manner. This philosophy enhances the reliability, maintainability, and predictability of Haskell programs, making them a formidable force in the realm of functional programming.

As we’ve seen, the IO monad provides a basic way to isolate effects in Haskell. But over time, limitations of the single IO monad have become apparent.

MTL-Style

The MTL-style (monad transformer library) allows creating new monads by composing together monad transformers that each add a capability like state, error handling, logging etc.

For example:

newtype AppM a = AppM 
    { runAppM :: ReaderT Config 
               (StateT UserState 
               (ExceptT AppError
               (LoggerT IO))) a }

This stacks together reader, state, error handling and logging transformers to create a custom AppM monad.

We can now write effectful functions that use the capabilities we’ve added:

getUser :: AppM User
getUser = do
  config <- ask 
  state <- get
  maybeUser <- liftIO $ queryConfigDB config
  case maybeUser of
    Just user -> return user
    Nothing -> throwError UserNotFound

MTL-style provides a powerful effect ecosystem through monad transformers but tends to lead to large monad stacks.

Freer

Since new effect system libraries are released on Hackage on the daily now, I’m going to provide a very high-level review one of the better known effect libraries called freer for the purpose of comparing it to MTL and IO approaches using a simple illustration.

The freer package provides the core Effect type along with helper functions for constructing and running effects. This is the low-level foundation.

freer allows modeling effects as data constructors called operations that are interpreted separately.

For example:

data MyEff next where
  ReadFile :: FilePath -> MyEff String
  LogMsg :: Text -> MyEff ()

readFile :: Member MyEff effs => FilePath -> Eff effs String
readFile fp = send $ ReadFile fp

We can interpret these operations separately to IO, or even interpret them differently in testing:

runMyEff :: Eff MyEff a -> IO a
runMyEff = interpret $ \case
  ReadFile fp -> readFileText fp
  LogMsg msg -> putStrLn msg

The freer approach provides more composability and modularity, with the ability to flexibly interpret effect operations. But encoding effects algebraically can require more work up front.

Other Haskell Effect Libraries

Haskell offers numerous libraries for working with freer algebraic effects, each with their own strengths and tradeoffs.

freer-effects

freer-effects builds on top of freer to provide effect instances for common effects like state, error handling, logging etc. This can save time compared to defining them yourself.

freer-simple

freer-simple is an alternative package with a simplified Effect type that doesn’t depend on freer. It also includes common effect instances out of the box.

Competitors

Other effect libraries include polysemy and eff which provide their own take on effects. These both have different tradeoffs.

There is no single best choice - it depends on the project requirements and developer preferences. For quick prototyping, freer-simple may be easiest to start. For production use, freer-effects offers a good balance. Polysemy is also popular with some unique capabilities like higher-kinded effects.

A notable alternative effect library is fused-effects which takes yet a different approach with different tradeoffs with respect to developer ergonomics and performance of programs written this way. At the time of publishing this article (July 2023), fused-effects offered better performance and lower overhead compared to the libraries listed above and greater flexibility in some realms but less of a focus on developer error messages than some of the above which can make it harder to troubleshoot gnarly type errors for the uninitiated.

There are even more effect libraries in Haskell which I haven’t mentioned here so do not consider the above an exhaustive list but rather a well known effect libraries to start exploring the design space levers and options through.

Comparative Advantages

Summarizing the IO monad’s tradeoffs:

Reviewing MTL-style effects:

Comparing the other effect libraries mentions loosely acknowledging there is a large breath of design space they occupy collectively:

Wrapping Up

IO provides a simple container for effects but isn’t very flexible. MTL gives application devleopers more structure but effects can get entangled quickly. The freer approach provides the most composability and modularity, at the cost of defining effects algebraically but there exists effect system libraries all over the design space that may suit your project at the cost of smaller communities, less documentation/examples, or higher complexity.

Haskellers choose their own poison adventure with a dizzying array of choices available yet Haskellers seem to agree that attempting to contain side effects is important whereas other functional languages choose not to as a rule.

If you enjoyed this content, please consider sharing via social media, following my accounts, or subscribing to the RSS feed.