Chapter 6: Combine your Functions

  1. Introduction
  2. What is Function Composition?
  3. Functors: Mapping Over Contexts
  4. Monoids: Combining Values Consistently
  5. Semigroups: Combining Values Without an Identity
  6. Applicatives: Combining Independent Effects
  7. Monads: Chaining Dependent Effects
  8. Recap & Exercises

1. Introduction

Haskell is often described as a language that thrives on function composition. This concept is one of the most powerful principles of functional programming, enabling us to build complex logic by combining small, reusable, and pure functions. Before diving into Functors, Monoids, Applicatives, and Monads, let’s lay the groundwork by understanding function composition and why it’s so critical.

2. What is Function Composition?

In mathematics, function composition is the process of applying one function to the result of another. If we have two functions, f and g, their composition f ∘ g applies g first and then applies f to the result.

In Haskell, function composition is represented by the (.) operator:

(.) :: (b -> c) -> (a -> b) -> a -> c

This means:

Here’s a simple example:

addOne :: Int -> Int
addOne x = x + 1

double :: Int -> Int
double x = x * 2

combined :: Int -> Int
combined = addOne . double

main :: IO ()
main = print (combined 3) -- Output: 7

In this example:

Functional Composition vs Object Modularity

Object-oriented languages often emphasize modularity, where we create independent pieces of code (functions, classes, modules) and integrate them. While modularity allows for separation of concerns, it lacks the seamless composability of functional programming.

Modularity vs. Composability

Example: Combining Functions in OO vs. FP

In an OO language (e.g., Python or Java):

def double(x):
    return x * 2

def add_one(x):
    return x + 1

def combined(x):
    temp = double(x)
    return add_one(temp)

print(combined(3)) # Output: 7

Here, you have to manually "wire" the functions together using intermediate variables like temp.

In Haskell:

combined = addOne . double

No intermediate variables, no manual wiring—just composition.

Notice how we didn't need to handle the arguments on these functions. We've composed these functions together to give us a new function with the type signature Int -> Int. This is a fairly simple example, but this concept applies to even the most complex of functions, making it much easier to combine functionality of multiple functions together in a type-safe manner.

Why Composability Matters

  1. Safety: In Haskell, function composition works seamlessly because of the type system. The compiler ensures that the output type of one function matches the input type of the next. If there’s a mismatch, you’ll get a compile-time error.
  2. Efficiency: Composability allows you to express complex logic succinctly, without introducing boilerplate code or intermediate state.
  3. Team Collaboration: When working in teams, composable functions are easier to reason about and reuse. Each developer can focus on building small, pure functions that integrate seamlessly into the larger codebase.
  4. Scalability: As projects grow, the ability to compose functions reduces complexity and encourages code reuse.

The (>>>) Operator: Composition Reversed

Haskell also provides the (>>>) operator for function composition, which is the reverse of (.). Instead of reading functions from right to left, (>>>) reads them from left to right.

(>>>) :: (a -> b) -> (b -> c) -> a -> c

Example:

import Control.Arrow (>>>)

combined = double >>> addOne
main = print (combined 3) -- Output: 7

This is useful when you want to express a series of transformations in the same order they’re applied.

Real-World Example: Processing a Pipeline of Transformations

Imagine you’re building a data pipeline to process user inputs. The inputs must:

  1. Be converted to lowercase.
  2. Have whitespace trimmed.
  3. Be validated for a specific pattern.

Instead of writing procedural code, you can compose the transformations:

import Data.Char (toLower)
import Data.List (isPrefixOf)

toLowerCase :: String -> String
toLowerCase = map toLower

trim :: String -> String
trim = unwords . words

isValid :: String -> Bool
isValid input = "valid:" `isPrefixOf` input

process :: String -> Bool
process = isValid . trim . toLowerCase

main :: IO ()
main = print (process "  VALID:example   ") -- Output: True

Here:

3. Functors: Mapping Over Contexts

Now that we understand function composition, let’s explore how we can combine functions when working with values in a context. This is where Functors come into play.

What is a Functor?

At its core, a Functor is a type class that defines how to apply a function to a value that is "wrapped" in a context. The context could be a list, a Maybe value, or any other data structure.

To be a Functor, a type must implement the fmap function:

class Functor f where
    fmap :: (a -> b) -> f a -> f b

Demystifying the Concept

Examples of Functors

Example 1: Lists as Functors

A list is a classic example of a Functor. Let’s see how fmap works with lists:

fmap (*2) [1, 2, 3] -- Output: [2, 4, 6]

Here:

This is equivalent to using the map function in Haskell:

map (*2) [1, 2, 3] -- Output: [2, 4, 6]

In fact, for lists, fmap and map are interchangeable because lists are an instance of the Functor type class.

NOTE: [] is the Functor in this example, because it contains our values. If we observe the definition of List on Hoogle, we can see that it is data List a where a is polymorphic. List has an instance of the Functor type class, and so it inherits the fmap capability.

Example 2: Maybe as a Functor

The Maybe type is another common Functor. Let’s see how fmap works with Maybe values:

fmap (+1) (Just 5) -- Output: Just 6
fmap (+1) Nothing  -- Output: Nothing

Here:

This ensures that we can work with optional values safely without manually checking for Nothing every time.

Example 3: Combining Functions with fmap

Since functions themselves are Functors, we can compose functions within a context using fmap.

fmap (*2) (+3) 5 -- Output: 16

Here’s what’s happening:

  1. (+3) is applied to 5, resulting in 8.
  2. (*2) is applied to 8, resulting in 16.

Why Functors Are Important

  1. Abstracting Contexts: Functors allow us to write generic code that works with any context. Whether it’s a list, a Maybe, or something more complex, we can apply functions without worrying about the details of the context.
  2. Composability: Functors enable function composition even when values are wrapped in a context. For example, you can chain operations on Maybe values without unwrapping and re-wrapping them.
  3. Safety: Functors ensure that functions are applied safely within their contexts. For example, when working with Maybe, you don’t need to check for Nothing explicitly—fmap handles it for you.

Real-World Example: Processing User Input

Imagine you’re working on a system that processes user input, which might be missing (Nothing). You want to:

  1. Add a prefix ("User: ").
  2. Convert the string to uppercase.

Here’s how you can do it with Functors:

import Data.Char (toUpper)

addPrefix :: String -> String
addPrefix input = "User: " ++ input

toUpperCase :: String -> String
toUpperCase = map toUpper

processInput :: Maybe String -> Maybe String
processInput = fmap toUpperCase . fmap addPrefix

main :: IO ()
main = do
    print $ processInput (Just "john")  -- Output: Just "USER: JOHN"
    print $ processInput Nothing       -- Output: Nothing

Key points:

Functor Laws

To qualify as a Functor, a type must satisfy two laws. These laws ensure that Functors behave consistently and predictably.

  1. Identity:
fmap id x == x

Applying the identity function id with fmap should not change the Functor.

Example with lists:

fmap id [1, 2, 3] == [1, 2, 3]
  1. Composition:
fmap (f . g) x == fmap f (fmap g x)

Mapping the composition of two functions is the same as mapping them one after the other.

Example with Maybe:

fmap ((*2) . (+3)) (Just 5) == fmap (*2) (fmap (+3) (Just 5))

4. Monoids: Combining Values Consistently

Now that we’ve explored Functors and how they enable function composition over contexts, let’s look at Monoids, which help us combine values in a consistent, predictable way. Monoids are another fundamental concept in functional programming and mathematics. They provide a framework for combining values using a binary operation (like addition or concatenation) while ensuring the operation behaves consistently across all values.

What is a Monoid?

A Monoid is a type class that defines:

  1. A binary operation (mappend or <> in Haskell) to combine two values.
  2. An identity element (mempty) that acts as a neutral value for the operation.

Here’s how Haskell defines the Monoid type class:

class Monoid m where
    mempty  :: m
    mappend :: m -> m -> m
    -- In modern Haskell, (<>) is preferred over mappend
    (<>)    :: m -> m -> m
    (<>) = mappend

Key Properties of Monoids

To qualify as a Monoid, a type must satisfy the following laws:

  1. Identity Law:
x <> mempty == x
mempty <> x == x

Combining any value with mempty must return the original value.

  1. Associativity Law:
(x <> y) <> z == x <> (y <> z)

The order in which you group operations doesn’t matter.

Examples of Monoids

Example 1: Lists

Lists are the most basic example of a Monoid. For lists:

mempty :: [a]
mempty = []

(<>) :: [a] -> [a] -> [a]
xs <> ys = xs ++ ys

Example:

[1, 2] <> [3, 4] -- Output: [1, 2, 3, 4]
mempty <> [1, 2] -- Output: [1, 2]
[1, 2] <> mempty -- Output: [1, 2]

The identity and associativity laws hold naturally:

Example 2: Numbers with Addition

Numbers can also form a Monoid under addition:

mempty :: Int
mempty = 0

(<>) :: Int -> Int -> Int
x <> y = x + y

Example:

5 <> 10 -- Output: 15
mempty <> 5 -- Output: 5

The laws hold:

Example 3: Numbers with Multiplication

Numbers can also form a Monoid under multiplication:

mempty :: Int
mempty = 1

(<>) :: Int -> Int -> Int
x <> y = x * y

Example:

5 <> 10 -- Output: 50
mempty <> 5 -- Output: 5

Example 4: Combining Boolean Values

Booleans can form Monoids under different operations, such as AND and OR.

Examples:

-- AND Monoid
True <> False -- Output: False
True <> True  -- Output: True

-- OR Monoid
False <> True  -- Output: True
False <> False -- Output: False

Why Monoids Matter

  1. Consistency in Combining Values: Monoids provide a consistent way to combine values, whether they’re lists, numbers, or custom data types.
  2. Generality: Monoids are abstract, meaning you can write code that works with any Monoid, regardless of the specific type.
  3. Composability: Monoids enable composition at a higher level. You can combine multiple values, or even collections of values, using the same interface (mempty and <>).
  4. Parallelism: The associativity law makes Monoids particularly useful in parallel processing, where operations can be grouped and computed independently.

Real-World Example: Combining Logs

Imagine you’re building a logging system where logs are represented as lists of strings. You want to combine multiple logs into a single log.

combineLogs :: [String] -> [String] -> [String]
combineLogs = (<>)

main :: IO ()
main = do
    let log1 = ["Started application"]
    let log2 = ["User logged in"]
    let log3 = ["Error: Database unavailable"]

    print $ log1 <> log2 <> log3
    -- Output: ["Started application", "User logged in", "Error: Database unavailable"]

Here:

Using Monoids with fold

Monoids are incredibly powerful when used with folds, allowing you to reduce collections of values in a consistent way.

Example: Summing a List

sumList :: [Int] -> Int
sumList = foldr (<>) mempty

This works because Int is a Monoid under addition.

Example: Concatenating Logs

concatLogs :: [[String]] -> [String]
concatLogs = foldr (<>) mempty

Example: Aggregating Financial Transactions

  1. Define a data type for a transaction.
data Transaction = Transaction
    { totalAmount :: Double
    , itemCount   :: Int
    , comments    :: [String]
    } deriving (Show)
  1. Define a custom instance of Monoid for Transaction
import Data.Monoid (Monoid(..))

instance Semigroup Transaction where
    (<>) (Transaction amt1 count1 comms1) (Transaction amt2 count2 comms2) =
        Transaction (amt1 + amt2) (count1 + count2) (comms1 <> comms2)

instance Monoid Transaction where
    mempty = Transaction 0 0 []
  1. Combine the transactions
transactions :: [Transaction]
transactions =
    [ Transaction 100.0 2 ["Purchase: Electronics"]
    , Transaction 200.0 3 ["Purchase: Furniture"]
    , Transaction 50.0 1 ["Purchase: Groceries"]
    ]

aggregateTransactions :: [Transaction] -> Transaction
aggregateTransactions = mconcat

main :: IO ()
main = print $ aggregateTransactions transactions

-- output:
-- Transaction
--     { totalAmount = 350.0
--     , itemCount = 6
--     , comments = ["Purchase: Electronics", "Purchase: Furniture", "Purchase: Groceries"]
--     }
  1. You can reduce the list using fold with (<>)
aggregateTransactions :: [Transaction] -> Transaction
aggregateTransactions txs = foldr (<>) mempty txs

5. Semigroups: Combining Values Without an Identity

Before diving into Monads, let’s explore Semigroups — a simpler abstraction that focuses on combining values. Semigroups are closely related to Monoids, but with a key difference: they don’t require an identity element. This makes them useful in scenarios where combining values is important, but an "empty" value doesn’t make sense or isn’t needed.

What is a Semigroup?

A Semigroup is a type class that defines a single operation, (<>):

class Semigroup a where
    (<>) :: a -> a -> a

This operation takes two values of the same type and combines them into one. To qualify as a Semigroup, the operation must satisfy the associativity law:

(x <> y) <> z == x <> (y <> z)

The order in which operations are grouped doesn’t matter.

How Semigroups Relate to Monoids

Examples of Semigroups

Example 1: Lists

Lists are the canonical example of a Semigroup. The (++) operator combines two lists, and it’s associative:

[1, 2] <> [3, 4] <> [5] == [1, 2] <> ([3, 4] <> [5])
-- Output: [1, 2, 3, 4, 5]

Since lists have an identity element ([]), they’re also Monoids, but (++) works perfectly as a Semigroup operation.

Example 2: Numbers with Maximum or Minimum

Numbers can form Semigroups under operations like maximum or minimum, which combine two numbers by selecting the largest or smallest value, respectively.

import Data.Semigroup (Max(..), Min(..))

main :: IO ()
main = do
    print $ Max 10 <> Max 20 -- Output: Max {getMax = 20}
    print $ Min 10 <> Min 20 -- Output: Min {getMin = 10}

Here:

Example 3: Combining Text with Precedence

Imagine combining strings, where we only keep the first non-empty string. This can be modeled as a Semigroup:

import Data.Semigroup (First(..))

main :: IO ()
main = do
    print $ First (Just "Hello") <> First (Just "World") -- Output: First {getFirst = Just "Hello"}
    print $ First Nothing <> First (Just "World")       -- Output: First {getFirst = Just "World"}

Here:

Why Semigroups Matter

  1. Simplicity: Semigroups provide a minimal abstraction for combining values, without the need for an identity element.
  2. Flexibility: Many types are naturally Semigroups but not Monoids (e.g., there’s no "empty" value for Max or Min).
  3. Building Blocks: Semigroups are often used as a foundation for more complex abstractions like Monoids and Applicatives.

Real-World Example: Combining Discounts

Imagine you’re building a shopping cart system that applies discounts. Discounts are represented as percentages, and when combining multiple discounts, you take the larger one (e.g., between 20% and 30%, you choose 30%).

import Data.Semigroup (Max(..))

data Discount = Discount { getDiscount :: Max Int }
    deriving (Show)

combineDiscounts :: Discount -> Discount -> Discount
combineDiscounts (Discount d1) (Discount d2) = Discount (d1 <> d2)

main :: IO ()
main = do
    let discount1 = Discount (Max 20)
    let discount2 = Discount (Max 30)
    print $ combineDiscounts discount1 discount2 -- Output: Discount {getDiscount = Max {getMax = 30}}

Here:

Using Semigroups with sconcat

The sconcat function is a handy tool for combining multiple Semigroup values:

sconcat :: NonEmpty a -> a

It takes a non-empty list of Semigroup values and combines them using (<>).

Example:

import Data.List.NonEmpty (NonEmpty(..))
import Data.Semigroup (sconcat, Max(..))

main :: IO ()
main = do
    let discounts = Max 10 :| [Max 20, Max 15]
    print $ sconcat discounts -- Output: Max {getMax = 20}

6. Applicatives: Combining Independent Effects

While Functors allow us to apply a single function to values within a context, Applicatives extend this capability by letting us apply functions with multiple arguments to values in multiple independent contexts. Applicatives are a natural progression from Functors, providing more power and flexibility while still adhering to Haskell’s principles of composability and safety.

What is an Applicative?

An Applicative is a type class that allows you to apply functions within a context (e.g., Maybe, lists, or Either) to values also within a context. Applicatives are defined by two core functions:

  1. pure: Embeds a value into a context.
  2. <*>: Applies a function wrapped in a context to a value wrapped in a context.

Here’s the type class definition:

class Functor f => Applicative f where
    pure :: a -> f a
    (<*>) :: f (a -> b) -> f a -> f b

Relationship to Functors

Applicatives are more powerful than Functors because they allow functions of multiple arguments to be applied within contexts. In fact, you can define fmap using Applicatives:

fmap f x = pure f <*> x

Why Are Applicatives Important?

  1. Combining Independent Effects: Applicatives allow you to combine computations that are independent of each other, meaning one doesn’t depend on the result of the other.
  2. Generality: Applicatives work across many contexts, including lists, Maybe, and Either.
  3. Safety: Like Functors, Applicatives ensure that you handle context appropriately and cannot accidentally "escape" it.

Examples of Applicatives

Example 1: Working with Maybe

Let’s say we have two Maybe values and want to add their contents. With Applicatives, we can do this without unwrapping and re-wrapping the values manually:

addMaybes :: Maybe Int -> Maybe Int -> Maybe Int
addMaybes x y = pure (+) <*> x <*> y

main :: IO ()
main = do
    print $ addMaybes (Just 5) (Just 3) -- Output: Just 8
    print $ addMaybes (Just 5) Nothing  -- Output: Nothing

Here’s what’s happening:

  1. pure (+) wraps the addition function in the Maybe context.
  2. <*> applies the wrapped function to the first Maybe value (Just 5).
  3. <*> then applies the result to the second Maybe value (Just 3).

If either value is Nothing, the result is Nothing.

Example 2: Combining Lists

With lists, Applicatives apply functions to all possible combinations of values from the contexts:

combineLists :: [Int] -> [Int] -> [Int]
combineLists xs ys = pure (+) <*> xs <*> ys

main :: IO ()
main = print $ combineLists [1, 2] [3, 4]
-- Output: [4, 5, 5, 6]

This works because the Applicative instance for lists applies the function to every combination of elements from both lists.

Example 3: Validating Inputs with Either

Imagine you’re validating form inputs and want to combine validation results. If any validation fails, the entire result should fail:

validateName :: String -> Either String String
validateName name
    | null name = Left "Name cannot be empty"
    | otherwise = Right name

validateAge :: Int -> Either String Int
validateAge age
    | age < 0 = Left "Age cannot be negative"
    | otherwise = Right age

validatePerson :: String -> Int -> Either String (String, Int)
validatePerson name age = pure (,) <*> validateName name <*> validateAge age

main :: IO ()
main = do
    print $ validatePerson "John" 25 -- Output: Right ("John", 25)
    print $ validatePerson "" 25     -- Output: Left "Name cannot be empty"
    print $ validatePerson "John" (-1) -- Output: Left "Age cannot be negative"

Here:

Applicative Laws

Like Functors, Applicatives must obey a set of laws to ensure consistent behavior:

  1. Identity:
pure id <*> v == v

Applying the identity function inside the context doesn’t change the value.

  1. Composition:
pure (.) <*> u <*> v <*> w == u <*> (v <*> w)

Composition of functions inside the context behaves like normal composition.

  1. Homomorphism:
pure f <*> pure x == pure (f x)

Wrapping a function and its argument in the context is the same as applying the function and then wrapping the result.

  1. Interchange:
u <*> pure y == pure ($ y) <*> u

Applying a wrapped function to a wrapped value is equivalent to applying the value to the function.

Real-World Example: Building a Configuration System

Suppose you’re building a system to parse configurations from the environment. Each configuration value may or may not exist (Maybe), and you need to combine them into a final configuration object.

data Config = Config
    { host :: String
    , port :: Int
    } deriving (Show)

getEnvVar :: String -> Maybe String
getEnvVar var
    | var == "HOST" = Just "localhost"
    | var == "PORT" = Just "8080"
    | otherwise = Nothing

readPort :: String -> Maybe Int
readPort str = case reads str of
    [(n, "")] -> Just n
    _         -> Nothing

getConfig :: Maybe Config
getConfig = pure Config <*> getEnvVar "HOST" <*> (getEnvVar "PORT" >>= readPort)

main :: IO ()
main = print getConfig
-- Output: Just (Config {host = "localhost", port = 8080})

Key points:

  1. pure Config wraps the constructor for Config in the Maybe context.
  2. <*> applies the constructor to each configuration value, ensuring that the entire process fails if any value is Nothing.

7. Monads: Chaining Dependent Effects

Monads are one of Haskell’s most powerful and versatile abstractions. They extend the functionality of Applicatives to allow chaining computations where each step may depend on the result of the previous step. Monads are essential for handling sequential effects, error propagation, and other context-sensitive operations in a safe and composable way.


What is a Monad?

A Monad is a type class that defines how to:

  1. Wrap a value in a context using return or pure (inherited from Applicative).
  2. Chain computations while managing the context using >>= (bind).

Here’s the type class definition:

class Applicative m => Monad m where
    (>>=) :: m a -> (a -> m b) -> m b
    (>>) :: m a -> m b -> m b
    return :: a -> m a
    return = pure

Breaking It Down:

Why Are Monads Important?

  1. Chaining Dependent Effects: Monads let you express sequential computations where the output of one step determines the input of the next.
  2. Error Handling: Monads like Maybe and Either propagate errors automatically, avoiding the need for manual checks.
  3. Side Effects: The IO Monad allows you to safely model side effects like reading from a file or printing to the console.
  4. Composability: Like Functors and Applicatives, Monads allow you to work with values in a context without "breaking out" of that context.

Examples of Monads

Example 1: Chaining with Maybe

Suppose we’re working with user input that may or may not exist. Monads allow us to chain operations on Maybe values without manually checking for Nothing at every step.

getUserInput :: Maybe String
getUserInput = Just "haskell"

validateLength :: String -> Maybe String
validateLength str
    | length str > 5 = Just str
    | otherwise      = Nothing

toUpperCase :: String -> Maybe String
toUpperCase str = Just (map toUpper str)

processInput :: Maybe String
processInput = getUserInput >>= validateLength >>= toUpperCase

main :: IO ()
main = print processInput
-- Output: Just "HASKELL"

Here:

  1. getUserInput provides the input wrapped in Maybe.
  2. validateLength and toUpperCase are chained using >>=.
  3. If any step returns Nothing, the entire chain short-circuits, propagating the failure.

Example 2: Error Propagation with Either

With Either, Monads allow us to propagate errors alongside computations. Let’s validate user data:

validateName :: String -> Either String String
validateName name
    | null name = Left "Name cannot be empty"
    | otherwise = Right name

validateAge :: Int -> Either String Int
validateAge age
    | age < 0 = Left "Age cannot be negative"
    | otherwise = Right age

createUser :: String -> Int -> Either String (String, Int)
createUser name age = do
    validName <- validateName name
    validAge  <- validateAge age
    return (validName, validAge)

main :: IO ()
main = do
    print $ createUser "John" 25 -- Output: Right ("John", 25)
    print $ createUser "" 25     -- Output: Left "Name cannot be empty"
    print $ createUser "John" (-1) -- Output: Left "Age cannot be negative"

Key points:

Example 3: Sequencing Effects with IO

The IO Monad allows you to sequence actions that interact with the real world (e.g., reading input, writing output).

main :: IO ()
main = do
    putStrLn "Enter your name:"
    name <- getLine
    putStrLn $ "Hello, " ++ name ++ "!"

Here:

The do Notation: Syntax Sugar for Monadic Chaining

The do notation in Haskell provides a more readable and imperative-style syntax for working with Monads. It’s a convenient abstraction over the >>= (bind) operator, allowing you to chain monadic operations in a clear and sequential manner.

Under the hood, the do notation is syntactic sugar for chaining operations with >>=. Each line in a do block represents a step in the computation, and the results of monadic operations can be extracted and assigned to variables.

General Structure

do
    value1 <- action1
    value2 <- action2
    action3 value1 value2

Here:

Equivalent in terms of >>=:

action1 >>= value1 ->
action2 >>= value2 ->
action3 value1 value2

Monad Laws

To qualify as a Monad, a type must satisfy three laws:

  1. Left Identity:
return a >>= f == f a

Wrapping a value with return and binding it to a function is the same as applying the function directly.

  1. Right Identity:
m >>= return == m

Binding a Monad to return doesn’t change the Monad.

  1. Associativity:
(m >>= f) >>= g == m >>= (x -> f x >>= g)

The grouping of chained operations doesn’t affect the result.

Real-World Example: Fetching Data from a Database

Imagine you’re building an application that retrieves user data from a database. Each step depends on the previous one:

  1. Fetch the user’s record by ID.
  2. Fetch the user’s orders based on their record.
  3. Validate the orders and calculate a summary.
fetchUser :: Int -> Maybe String
fetchUser 1 = Just "John"
fetchUser _ = Nothing

fetchOrders :: String -> Maybe [Int]
fetchOrders "John" = Just [1001, 1002, 1003]
fetchOrders _      = Nothing

validateOrders :: [Int] -> Maybe Int
validateOrders orders = Just (length orders)

processData :: Int -> Maybe Int
processData userId = do
    user   <- fetchUser userId
    orders <- fetchOrders user
    validateOrders orders

main :: IO ()
main = do
    print $ processData 1 -- Output: Just 3
    print $ processData 2 -- Output: Nothing

Here:

Why Monads Matter

  1. Chaining Dependent Effects: Monads enable sequential computations where each step depends on the previous one.
  2. Error Handling: Monads like Maybe and Either handle failure cases cleanly and propagate them automatically.
  3. Abstraction: Monads abstract the underlying logic, allowing you to focus on the computation rather than the context management.
  4. Composability: Monads, like Functors and Applicatives, allow you to build modular and reusable code.

8. Recap & Exercises

Recap

In this chapter, we explored how Haskell’s abstractions — Functors, Monoids, Applicatives, Semigroups, and Monads — enable powerful and composable function combinations. Here’s a summary of what we learned:

1. Function Composition

2. Functors

Example:

fmap (+1) (Just 5) -- Output: Just 6
fmap (*2) [1, 2, 3] -- Output: [2, 4, 6]

3. Monoids

Example:

mconcat [[1, 2], [3, 4]] -- Output: [1, 2, 3, 4]
mconcat [Sum 10, Sum 20, Sum 30] -- Output: Sum 60

4. Semigroups

Example:

sconcat (1 :| [2, 3, 4]) -- Output: 10

5. Applicatives

Example:

pure (+) <*> Just 5 <*> Just 3 -- Output: Just 8
pure (+) <*> [1, 2] <*> [3, 4] -- Output: [4, 5, 5, 6]

6. Monads

Example:

Just 5 >>= x -> Just (x * 2) -- Output: Just 10
processData = do
    user <- fetchUser userId
    orders <- fetchOrders user
    validateOrders orders

7. The do Notation

Example:

processInput = do
    x <- Just 5
    y <- Just 10
    return (x + y)

Exercises

Exercise 1: Functors

Write a function using fmap to double all numbers in a list and wrap them in the Maybe context. Ensure the result is Nothing if the list is empty.

Example:

doubleList :: [Int] -> Maybe [Int]
doubleList [] = Nothing
doubleList xs = -- Your implementation here

Exercise 2: Monoids

Combine a list of Sum values into a single Sum using mconcat.

Example:

combineSums :: [Sum Int] -> Sum Int
combineSums = -- Your implementation here

Exercise 3: Applicatives

Write a function that takes two Maybe values and applies a function to combine them. Use <*>.

Example:

combineMaybes :: Maybe Int -> Maybe Int -> Maybe Int
combineMaybes x y = -- Your implementation here

Exercise 4: Semigroups

Use the sconcat function to combine a non-empty list of Max values and find the maximum value.

Example:

findMax :: NonEmpty (Max Int) -> Max Int
findMax = -- Your implementation here

Exercise 5: Monads

Write a do block to validate and process user input using the Maybe Monad.

Example:

processMaybe :: Maybe Int -> Maybe Int
processMaybe input = do
    -- Your implementation here

Exercise 6: Simulating a Shopping Cart

Write a function that simulates a simple shopping cart system. The system:

  1. Validates input quantities (using Maybe to handle invalid inputs).
  2. Applies a discount if the cart total exceeds a certain amount (using Applicatives to combine independent calculations).
  3. Logs the results (using a Monad to sequence actions and include context).

Requirements:

cart = [Item "Apple" 1.5 4, Item "Banana" 0.8 10, Item "Orange" 2.0 3]

processCart :: Double -> Cart -> Maybe Double
processCart threshold cart = do
    -- Your implementation here

main :: IO ()
main = do
    print $ processCart 20 cart
    -- Output: Just 19.44 (after discount)

    print $ processCart 20 [Item "Apple" 1.5 (-4)]
    -- Output: Nothing (invalid quantity)

Previous Chapter | Next Chapter