In a recent thread on Twitter, I discussed the key elements of functional programming in Haskell. It’s important to note that this is not an all-encompassing definition of functional programming across all languages, but rather a quick overview based on insights from my own Haskell experience across multiple teams.
Source: https://twitter.com/SusanPotter/status/14442745556111360028
Let’s explore the essential aspects that I consider important when designing software in Haskell which gives it that special functional flavor:
- Type Safety (aka Well Typed)
- Ensuring that the types used in the program accurately represent the domain and restrict the construction of invalid values. Exceptions may exist in complex domains, but in general, type safety guarantees the validity of values. Read the rest of this post for illustrations and discussions of this characteristic.
- Control over Side Effects (Effectful)
- Minimizing the leakage of side effects during program construction by employing a single “escape hatch” explicitly or implicitly, such as the main function, which transitions the program into runtime execution. This is not specific to effect systems but is related to the concept. I wrote about effectful programming in Haskell to elaborate on this further.
- Compositionality
- Leveraging abstraction combinators to combine values in various ways, allowing the construction of programs from smaller components or the creation of larger structures from smaller ones, ensuring coherence and logical connections between the components.
- Generic
- Applying generic programming techniques to identify appropriate abstract interfaces for describing the problem domain, and utilizing these interfaces consistently throughout the codebase.
- Type-Level Techniques
- An encompassing category that includes techniques such as datatype-generic programming, certain aspects of dependent types, and concepts like Linear Haskell.
Outside of the Haskell context, the compositional aspect mentioned above is usually the primary focus when referring to “functional programming” by non-Haskellers.
This post will elaborate on the well-typed characteristic.
My Haskell Backstory
Once upon a time, in a land not so far away in the past, I found myself entangled with Haskell unexpectedly.
Picture this: I inherited a risk management library and a mishmash of data pipelines from a developer who, in a fit of frustration, had ragequit one fine day. As I delved into the code, I soon realized that understanding Haskell would be no walk in the park for a noob like me. It took me not hours, not days, but a torturous three weeks just to reproduce the builds from source with no documentation. Minor amendments? Well, they were more like major headaches!
You see, I had spent the past twelve years of my professional life building software with the comforting trio of C++, Java, and Python. I thought I had seen it all, but Haskell…oh, Haskell was an entirely different beast. If you think finding Haskell documentation today is a challenge, back then it was like trying to find a needle in a haystack the size of Mount Everest!
Working with Haskell in those circumstances was a dance with frustration. It was as if the programming gods had conspired against me, throwing obscure concepts and unfamiliar paradigms in my path. My journey into the world of Haskell was like stumbling through a dense forest blindfolded, hoping to find a glimmer of understanding amidst the chaos.
But time has a funny way of healing wounds and softening grudges. Four years later, I found it in my heart to give Haskell another chance. In retrospect I realized it wasn’t the fault of the programming language. It was the chaotic management that left me as the lone soul entrusted with the secrets of risk management without documentation or training. I had also gained some experience building distributed systems using Erlang which is also considered a functional language. How hard could it be to conquer this purely functional programming language now that I had danced with another functional beast?
Well, it was still no walk in the park.
Here we delve into the intricacies of Haskell’s functional programming style, specifically the characteristic I am labeling “well typed-ness”.
We will focus on what is common practice in Haskell today with examples to illustrate techniques and design choices we might make for one specific problem domain.
Well-Typedness
Source: https://twitter.com/SusanPotter/status/1444274556827492358
When we write software, we are trying to solve a specific problem, usually one that lives inside a problem domain. We might be building an investment performance reporting engine, a news discussion site with nested comment threads, or a service configuration library that parses configuration files on service startup.
With the latter problem, our domain is concerned with things like hostnames, port numbers, file paths, protocols, timeout durations, etc.
Readers already familiar with Haskell or Haskell-like programming style (e.g., FP Scala, PureScript, etc.) will want to skip ahead to the third code block below as I take those not acquainted with Haskell on a short thought journey first.
Let us try with a first approximation to describe the problem domain with simple type aliases to get started:
type Protocol = String -- e.g., "http", "https", etc.
type Port = Int -- port represented as a signed integer
type Hostname = String -- e.g., "sub.example.com"
type Path = String -- String of absolute or relative path
type Duration = Int -- Representing seconds for now
Using these as placeholders is fine just to get something quick and dirty working, but when we want to write a function that takes a String after lexing the configuration file that converts the String for a port number to a Port value, then we essentially have functions like:
parsePort :: String -> Port -- Port is Int
parseProtocol :: String -> Protocol -- Protocol is String
parseHostname :: String -> Hostname -- Hostname is String
parsePath :: String -> Path -- Path is String
parseDuration :: String -> Duration -- Duration is Int
The problem with, say, parsePort
, is that not all String values correspond to a value in the output type Int. So what should we do? At this point, here are some questions to ask yourself if you don’t know how to refine the types:
- What about the case where the configuration file contains that special error value of Int that we map all non-parsing Strings to?
- What about all the Int values that are not valid Port values?
- Do we want to inform the caller of possible errors?
- Do we want to distinguish between different categories of errors?
- What different categories of errors might we care about in this domain to help the struggling developer understand the problem with the configuration file?
- Do we want to allow relative paths when parsing path values?
- Do we want to restrict the protocols to a known subset to allow?
- What happens when we need to pass on the timeout durations to lower-level libraries in microseconds or milliseconds instead of seconds?
- Is an empty string a valid protocol, hostname, or path?
One approach I have seen even in old Haskell codebases is to declare a special value of Int that acts as a catch-all for all the String values that couldn’t be reasonably parsed to an Int. In that case, we would be passing along the extra work to distinguish that to the caller of the function. This distributes value handling code across a codebase instead of consolidating it in a way that would ensure the interpretation of that result is consistent.
Instead, in modern Haskell codebases, we often use the Maybe
or Either
type constructors that can contain any type inside of it to denote that sometimes there isn’t a value of the underlying type inside. In the case of Maybe, no value is described without any error value, whereas with an Either, we can add an error value to describe what went wrong in producing the underlying value.
There are many points in the design space that we could land on from here, but the following is a quick refinement of the types above for a service configuration library. I made some decisions based on my experiences with describing service configuration, and I added comments to the code block to describe my thoughts on when, where, and how I would add additional definition to the types in this domain:
module WellTypedness.Examples where
{-| Initial example that we improve upon in the rest of the code.
type Protocol = String -- e.g., "http", "https", etc.
type Port = Int -- port represented as a signed integer
type Hostname = String -- e.g., "sub.example.com"
type Path = String -- String of absolute or relative path
type Duration = Int -- Representing seconds for now
parsePort :: String -> Port -- Port is Int
parseProtocol :: String -> Protocol -- Protocol is String
parseHostname :: String -> Hostname -- Hostname is String
parsePath :: String -> Path -- Path is String
parseDuration :: String -> Duration -- Duration is Int
-}
-- Importing type representing a 16-bit unsigned integer
import Data.Word (Word16)
-- Importing Either denoting errors in parsing
import Data.Either (Either (..))
import Data.Maybe (maybe, Maybe (..))
import Text.Read (readMaybe)
-- When we know our services only ever use a fixed set of
-- protocols, we can fix that set using a sum type like this
data Protocol = Http | Https | WebSockets
-- Here we want to provide more definition around bounds
-- of a Port number. I decided an unsigned 16-bit integer
-- worked well enough for our purposes and reused an existing
-- type to wrap around (Word16).
newtype Port = Port Word16 -- 0 to 65,535
-- My concern for Hostname is less about parsing from a String
-- and more about other Strings that could be passed as an argument
-- when a Hostname was expected. Therefore, here I use the
-- newtype construct in the language that forces callers to
-- wrap their hostname strings in the Hostname constructor
-- explicitly.
newtype Hostname = Hostname String
-- For this type, I decided that I would only refine its type
-- further than an alias to a String when the need arose.
type Path = String
-- Since our config library is agnostic to the network
-- libraries our callers might use (I just made up that
-- requirement to demonstrate a new technique), we need to
-- provide a way for callers to designate time units to use.
-- I don't want to box the user in too much, so we want to
-- enable the caller to extend with the time units they care
-- about. We are also tagging the type with an extra type
-- upon construction by the caller. For instance, the caller
-- could define an empty data type called PicoSeconds, which
-- they might use for all time durations declared in their
-- service configurations.
newtype Duration unit = Duration Int
-- utility function
note :: String -> Maybe b -> Either String b
note error = maybe (Left error) Right
-- We will not export the Duration data constructor
-- (not shown in this snippet) via explicit module
-- export syntax in the containing module and expose the
-- following smart constructor giving callers the ability
-- to produce a Duration if the Int passed in meets our sniff test
makeDuration :: Int -> Either String (Duration unit)
makeDuration n
| n >= 0 = Right (Duration n)
| otherwise = Left "Not zero or above"
-- Below are outlines of how I might
parseProtocol :: String -> Either String Protocol
parseProtocol "http" = Right Http
parseProtocol "https" = Right Https
parseProtocol "ws" = Right WebSockets
parseProtocol _ = Left "Protocol not valid"
parsePort :: String -> Either String Port
parsePort s
= note "Port not a number" (readMaybe s)
>>= \num ->
if num > 0 && num <= 65535
then Right (Port num)
else Left "Port not within bounds"
{-|
parseHostname :: String -> Either String Hostname
parsePath :: String -> Either String Path
-}
parseDuration :: String -> Either String (Duration unit)
parseDuration s
= note "Port not a number" (readMaybe s) >>= makeDuration
A developer with different goals for the library might have described these types differently than I did above. I already regret only representing a Path as a String alias and not a newtype wrapper, but I wanted to show that you can choose the granularity and expressiveness of the type definitions as needed for your software needs. :)
Now let’s compare the before and after of these type definitions for the library.
Defining a Sum (Coproduct) Type for Protocol
Before:
type Protocol = String
After:
data Protocol
= Http
| Https
| WebSockets
Protocol has been drastically reduced from an almost infinite number (if we had infinite memory) of values down to three. The code using Protocol in the first set of definitions allowed any String value, meaning that all code handling Protocol values would need to check validity.
In the second definition, we only have three possible protocols that are deemed valid by the library author (me). It is a closed type, meaning only the library authors can expand it. This may or may not be the right call, depending on the goals of your library and what it should support. However, by using a sum type in this way, we have drastically reduced the number of Protocol values to only the values we want to support at the library level. If we had functionality in the library relating to the different protocols, we could use exhaustivity checking of the Haskell typechecker to help eliminate a class of possible defects and remove defensive coding from the caller side. The second definition of the Protocol data type has the trade-off that the library author needs to expand the possible set of values when needed, anytime the consumers of the API have a new protocol they want supported.
Newtype-ing and Right-Sizing the Port data type
Before:
type Port = Int
After:
newtype Port
= Port Word16 -- 0 to 65,535
In our problem domain (configuration that needs to describe port numbers among other things), a port number is typically constrained to unsigned integer values between 0 and 65,535 inclusive. So we have eliminated all negative values and any positive integer above 65,535 that are invalid according to the domain’s definition.
However, what does port 0 mean? Usually, it is reserved by the system, so it could make sense to eliminate that value from our type definition. For this example, I chose to let the user set a port number to 0 if they really want. Another consideration not made in the new type definition is differentiating between privileged/system ports (1-1023), registered ports (1024-49151), and ephemeral ports (49152-65535) for the purpose of our library’s domain. I could have defined the following type:
data Port
= SystemPort ???
| RegisteredPort ???
| EphemeralPort ???
Now I would need to find efficient representations of those numeric ranges (thus why you see ???). As the library author, I made the call that I would not make this differentiation as there was no functionality in my library surface area that cared to know the difference between these classes of ports.
This discussion illustrates that there are many possible valid definitions of a port number in the design space we could have made for this data type and it depends on what the library author and consumers deem important.
Newtype for Hostname
Before:
type Hostname = String
After:
newtype Hostname = Hostname String
The difference here is small but potentially significant. Here we made it so that the caller knows when it received a result, it is intended to represent a hostname and not any other possible String value. If I left this type definition as a type alias, then I might have consumers of the library confusing Path and Hostname values as they use the values parsed.
Also, if our library accepted Hostname as an argument to a function or other data constructor, our library would know that the caller either has a value our library already produced as a hostname or explicitly constructed a Hostname value using the data constructor. We cannot just pass this to a function that accepts a String.
You want to right-size or right-type your domain for your needs when defining your types. Don’t add more than you need.
Aliasing types when it doesn’t matter much
Before:
type Path = String
After:
type Path = String
Since I wrapped Hostname values with a newtype, I decided I didn’t need to wrap the Path data type. The parsePath function can still check the format of the path to ensure it is valid, but in terms of representation, I chose to just keep Path as a type alias to a String. You may also want to consider adjacent libraries that consumers of your library might use with the values produced by your library. In Haskell, multiple libraries that deal with file paths also alias to a String, so this improves ergonomics for the consumer of the library.
Newtype-ing the Duration type with Smart Constructor
Before:
type Duration = Int
After:
newtype Duration unit = Duration Int
makeDuration
:: Int
-> Either String (Duration unit)
makeDuration n
| n >= 0 = Right (Duration n)
| otherwise = Left "Not zero or above"
Usage:
-- Empty data types used as "phantom types" and
-- passed to the Duration type constructor
data PicoSeconds
data MicroSeconds
data MilliSeconds
data Seconds
data Minutes
data Hours
data Days
data Weeks
data Months
data Years
minuteInSeconds :: Duration Seconds
minuteInSeconds = Duration 60
daysInHours :: Duration Hours
daysInHours = Duration 24
weeksInDays :: Duration Days
weeksInDays = Duration 7
In the updated code, we have defined several empty data types (PicoSeconds
, MicroSeconds
, etc.) to act as “phantom types” that are passed as type parameters to the Duration
type constructor. These phantom types don’t have any values associated with them; they exist solely to provide additional type information at the type level.
By using phantom types, we can introduce compile-time type safety and prevent mixing durations of different units. For example, let’s define a function addDurations
that adds two durations:
addDurations
:: Duration a
-> Duration a
-> Duration a
addDurations (Duration d1) (Duration d2)
= Duration (d1 + d2)
The function addDurations
takes two durations of the same phantom type a
and returns their sum as a duration of the same phantom type. This ensures that durations of different units cannot be mixed up.
Here’s an example usage of addDurations
:
duration1 :: Duration Seconds
duration1 = Duration 30
duration2 :: Duration Seconds
duration2 = Duration 45
-- This will work since the durations have
-- the same phantom type
result :: Duration Seconds
result = addDurations duration1 duration2
-- This will result in a type error since
-- the durations have different phantom types
-- result = addDurations duration1 daysInHours
In the example, we successfully add two durations of the same phantom type Seconds
. However, if we try to add a duration of type Seconds
with a duration of type Hours
(e.g., addDurations duration1 daysInHours
), we will get a type error because the phantom types don’t match.
Using phantom types in this way provides stronger static guarantees about the correctness of our code and helps catch potential bugs at compile-time.
Concluding thoughts about Well-Typedness in Haskell
The techniques discussed above shed light on the significance of well-typedness in Haskell’s flavor of functional programming. By leveraging strong static typing, we can enforce domain-specific constraints and ensure that values constructed within our programs are valid, or at least the majority of the time in non-trivial domains while balancing this need with library consumer ergonomics.
The notion of well-typedness goes beyond mere correctness. It enables us to reason about our code with confidence, as the type system acts as a powerful ally, catching large categories of errors at compile-time and reducing the likelihood of runtime surprises. By constraining the set of possible values, we effectively reduce the space of potential bugs and increase the reliability of our software.
Well-typedness also enhances code maintainability and understandability. With well-defined types, the structure and behavior of our programs become explicit, facilitating comprehension for both ourselves and other developers. It becomes easier to track data flow and understand the intentions behind operations (as in the phantom type case and smart constructor example).
Well-typedness promotes robust composability, ensuring values fit together in a type-safe manner, so that we can confidently combine smaller components into larger and more complex data types.
However, it is important to recognize that achieving well-typedness is not always a straightforward process, especially in complex domains. It requires careful design and thoughtful consideration of the types used to represent the problem space. Balancing the desire for type safety with the need for expressiveness and flexibility can be a delicate dance yet the ability for Haskell to allow us to cheaply define our data types or placeholders over time gives library designers the ability to iterate to just-enough expressiveness without needing to renegoiate on deadlines.
By embracing strong static typing and employing the techniques illustrated above, we can harness well-typedness to describe a problem domain from a specific viewpoint with ease.
So, go forth and embrace the world of well-typedness as I define it above and leverage Haskell’s expressive type system to unlock the most suitable point for your aims in the design space available.
If you enjoyed this content, please consider sharing this link with a friend, following my GitHub, Twitter/X or LinkedIn accounts, or subscribing to my RSS feed.