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.
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:
f
of type (b -> c)
and a function g
of type (a -> b)
.
(a -> c)
.
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:
double
is applied first to give 6
(3 * 2
).
7
.
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.
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.
(>>>)
Operator: Composition ReversedHaskell 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.
Imagine you’re building a data pipeline to process user inputs. The inputs must:
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:
process
function composes them into a pipeline that’s easy to read and test.
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.
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
(a -> b)
is a function that transforms a value of type a
into type b
.
f a
is the context (e.g., a list, Maybe
, etc.) containing the value.
f b
is the result of applying the function (a -> b)
to the value inside the context.
fmap
as a way to "map" a function over a value inside a container.
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:
(*2)
is applied to each element of the list.
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 theFunctor
in this example, because it contains our values. If we observe the definition ofList
on Hoogle, we can see that it isdata List a
wherea
is polymorphic.List
has an instance of theFunctor
type class, and so it inherits thefmap
capability.
Maybe
as a FunctorThe 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:
(+1)
is applied to the value 5
inside the Just
context, producing Just 6
.
Nothing
, the function is not applied, and the result is still Nothing
.
This ensures that we can work with optional values safely without manually checking for Nothing
every time.
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:
(+3)
is applied to 5
, resulting in 8
.
(*2)
is applied to 8
, resulting in 16
.
Maybe
, or something more complex, we can apply functions without worrying about the details of the context.
Maybe
values without unwrapping and re-wrapping them.
Maybe
, you don’t need to check for Nothing
explicitly—fmap
handles it for you.
Imagine you’re working on a system that processes user input, which might be missing (Nothing
). You want to:
"User: "
).
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:
addPrefix
and toUpperCase
) is composed using fmap
.
Maybe
) ensures that no transformations are applied if the input is Nothing
.
To qualify as a Functor, a type must satisfy two laws. These laws ensure that Functors behave consistently and predictably.
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]
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))
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.
A Monoid is a type class that defines:
mappend
or <>
in Haskell) to combine two values.
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
To qualify as a Monoid, a type must satisfy the following laws:
x <> mempty == x
mempty <> x == x
Combining any value with mempty must return the original value.
(x <> y) <> z == x <> (y <> z)
The order in which you group operations doesn’t matter.
Lists are the most basic example of a Monoid. For lists:
mempty
is the empty list []
.
<>
is list concatenation (++
).
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:
[]
leaves it unchanged.
Numbers can also form a Monoid under addition:
mempty
is 0
.
<>
is (+)
.
mempty :: Int
mempty = 0
(<>) :: Int -> Int -> Int
x <> y = x + y
Example:
5 <> 10 -- Output: 15
mempty <> 5 -- Output: 5
The laws hold:
0
to any number doesn’t change it.
Numbers can also form a Monoid under multiplication:
mempty
is 1
.
<>
is (*)
.
mempty :: Int
mempty = 1
(<>) :: Int -> Int -> Int
x <> y = x * y
Example:
5 <> 10 -- Output: 50
mempty <> 5 -- Output: 5
Booleans can form Monoids under different operations, such as AND and OR.
mempty
is True
.
<>
is (&&)
.
mempty
is False
.
<>
is (||)
.
Examples:
-- AND Monoid
True <> False -- Output: False
True <> True -- Output: True
-- OR Monoid
False <> True -- Output: True
False <> False -- Output: False
mempty
and <>
).
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:
<>
operator combines logs seamlessly.
mempty
) doesn’t affect the result.
fold
Monoids are incredibly powerful when used with folds, allowing you to reduce collections of values in a consistent way.
sumList :: [Int] -> Int
sumList = foldr (<>) mempty
This works because Int
is a Monoid under addition.
concatLogs :: [[String]] -> [String]
concatLogs = foldr (<>) mempty
data Transaction = Transaction
{ totalAmount :: Double
, itemCount :: Int
, comments :: [String]
} deriving (Show)
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 []
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"]
-- }
fold
with (<>)
aggregateTransactions :: [Transaction] -> Transaction
aggregateTransactions txs = foldr (<>) mempty txs
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.
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.
mempty
).
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.
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:
Max
and Min
are wrappers provided by the Data.Semigroup
module.
(<>)
combines the wrapped values by applying max
or min
.
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:
First
is a Semigroup that chooses the first non-Nothing
value when combining.
Max
or Min
).
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:
Max Int
to ensure that the larger discount is always chosen.
Max
handles the combining logic seamlessly.
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}
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.
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:
pure
: Embeds a value into a context.
<*>
: 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
pure
takes a value and wraps it in the Applicative context.
<*>
(called "apply") takes a wrapped function (f (a -> b)
) and a wrapped value (f a
) and produces a wrapped result (f b
).
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
Maybe
, and Either
.
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:
pure (+)
wraps the addition function in the Maybe
context.
<*>
applies the wrapped function to the first Maybe
value (Just 5
).
<*>
then applies the result to the second Maybe
value (Just 3
).
If either value is Nothing
, the result is Nothing
.
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.
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:
pure (,)
creates a wrapped tuple constructor.
<*>
applies the constructor to the validated name and age.
Right
) or an error message (Left
).
Like Functors, Applicatives must obey a set of laws to ensure consistent behavior:
pure id <*> v == v
Applying the identity function inside the context doesn’t change the value.
pure (.) <*> u <*> v <*> w == u <*> (v <*> w)
Composition of functions inside the context behaves like normal composition.
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.
u <*> pure y == pure ($ y) <*> u
Applying a wrapped function to a wrapped value is equivalent to applying the value to the function.
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:
pure Config
wraps the constructor for Config
in the Maybe
context.
<*>
applies the constructor to each configuration value, ensuring that the entire process fails if any value is Nothing
.
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.
A Monad is a type class that defines how to:
return
or pure
(inherited from Applicative).
>>=
(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
(>>=)
(bind) : Takes a value wrapped in a context (m a
) and a function that produces a new value in the same context (a -> m b
). It chains the two, passing the inner value to the function.
return
: Wraps a value in the Monad context. In modern Haskell, return
is synonymous with pure
.
Maybe
and Either
propagate errors automatically, avoiding the need for manual checks.
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:
getUserInput
provides the input wrapped in Maybe
.
validateLength
and toUpperCase
are chained using >>=
.
Nothing
, the entire chain short-circuits, propagating the failure.
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:
do
notation provides a more readable syntax for chaining operations with Monads.
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:
putStrLn
and getLine
are IO
actions.
do
block chains them, passing the result of getLine
into the next action.
do
Notation: Syntax Sugar for Monadic ChainingThe 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.
do
value1 <- action1
value2 <- action2
action3 value1 value2
Here:
action1
and action2
are monadic actions.
<-
extracts the value from the context of the Monad (e.g., Maybe
, IO
, etc.).
do
block.
Equivalent in terms of >>=
:
action1 >>= value1 ->
action2 >>= value2 ->
action3 value1 value2
To qualify as a Monad, a type must satisfy three laws:
return a >>= f == f a
Wrapping a value with return
and binding it to a function is the same as applying the function directly.
m >>= return == m
Binding a Monad to return doesn’t change the Monad.
(m >>= f) >>= g == m >>= (x -> f x >>= g)
The grouping of chained operations doesn’t affect the result.
Imagine you’re building an application that retrieves user data from a database. Each step depends on the previous one:
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:
Maybe
Monad ensures that failures propagate automatically.
Maybe
and Either
handle failure cases cleanly and propagate them automatically.
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:
(.)
, which allows you to chain functions succinctly.
Maybe
, lists).
fmap
or <$>
.
Example:
fmap (+1) (Just 5) -- Output: Just 6
fmap (*2) [1, 2, 3] -- Output: [2, 4, 6]
<>
and mempty
.
Example:
mconcat [[1, 2], [3, 4]] -- Output: [1, 2, 3, 4]
mconcat [Sum 10, Sum 20, Sum 30] -- Output: Sum 60
<>
.
Example:
sconcat (1 :| [2, 3, 4]) -- Output: 10
pure
and <*>
.
Example:
pure (+) <*> Just 5 <*> Just 3 -- Output: Just 8
pure (+) <*> [1, 2] <*> [3, 4] -- Output: [4, 5, 5, 6]
Example:
Just 5 >>= x -> Just (x * 2) -- Output: Just 10
processData = do
user <- fetchUser userId
orders <- fetchOrders user
validateOrders orders
Example:
processInput = do
x <- Just 5
y <- Just 10
return (x + y)
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
Combine a list of Sum values into a single Sum using mconcat.
Example:
combineSums :: [Sum Int] -> Sum Int
combineSums = -- Your implementation here
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
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
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
Write a function that simulates a simple shopping cart system. The system:
Maybe
to handle invalid inputs).
Requirements:
fmap
to adjust item prices based on a fixed multiplier (e.g., tax or currency conversion).
<*>
to calculate the total cost of items and apply a discount if necessary.
>>=
or do
notation to handle potential errors in item quantities and return the final cart result.
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)