Skip to main content
  1. Posts/

Combination before Identity

·1599 words·8 mins

Semigroups and Monoids #

In our journey to understand functional abstractions, we’ve climbed the ladder from Functors to Applicatives and finally to Monads. We learned that choosing the right abstraction isn’t about complexity, but about the specific power and guarantees you need.

Today, we take a step sideways to explore two foundational concepts that underpin much of this compositional power: Semigroups and Monoids

Combination #

At the heart of both Semigroups and Monoids is a simple, intuitive idea: combination. We constantly combine things in programming:

  • adding numbers (1 + 2)
  • concatenating strings ("hello" ++ "world")
  • appending lists ([1, 2] ++ [3, 4])
  • merging validation errors (["too short"] <> ["missing @"])

These seemingly simple operations hide a powerful mathematical structure that functional programming leverages for robust composition.

The Semigroup #

A Semigroup is the most basic algebraic structure for combining things. It captures the essence of “I have a way to put two things together to get a new thing.”

To be a Semigroup, a type a needs just one thing:

  1. An associative binary operation This is a function that takes two values of type a and returns a new value of type a. It is often written as (<>) in Haskell (in math notation \(\diamond\) is also used). \[ (\diamond) :: a \to a \to a \] The associative part is crucial \((x \diamond y) \diamond z \equiv x \diamond (y \diamond z)\). This means that the order in which you group combinations doesn’t matter for the final result. (a + b) + c is the same as a + (b + c).

Examples #

You’ve already used tons of Semigroups, even outside of programming.

  • Numbers with addition (Int, +) form a Semigroup.
  • Strings with concatenation (String, ++)
  • Lists with concatenation ([a], ++)

You can check the associativity law of each of these yourselve.

A less obvious, but powerful example:

  • Max and Min wrappers, these are types that wrap numbers and provide a semigroup instance for finding the maximum or minimum
newtype Max a = Max a deriving (Show, Eq)
instance Ord a => Semigroup (Max a) where
    (Max x) <> (Max y) = Max (max x y)

Max 5 <> Max 10 <> Max 3    -- Max 10

Why associativity matters #

Associativity guarantees that you can combine elements in any order (e.g. left-to-right, right-to-left or even in parallel) without changing the outcome. This is vital for optimizations and reliable parallel processing.

Not all operations are associative, think of some trivial examples like:

  • Subtraction \((5 - 3) - 1 = 1 \ne 5 - (3 - 1) = 3\) so (Int, -) is not a Semigroup
  • Division

The Semigroup typeclass provides a contract: “I promise to combine things associatively.”

Why go through the trouble? Because the operation is associative, the compiler doesn’t care if you calculate \((1 + 2) + (3 + 4)\) or \(1 + (2 + (3 + 4))\). This means you can split a massive list across CPU cores, combine the results at the end, and be mathematically certain the answer is correct.

Monoid #

A Monoid builds upon a Semigroup by adding one crucial element: an identity element.

To be a Monoid, a type a needs two things:

  1. A Semigroup instance: it must already have an associative binary operation (<>)
  2. An identity element (mempty): this is a special value that, when combined with any other value x using (<>) leaves x unchanged. \[ mempty :: a \]

mempty has two important laws, if they aren’t met it’s not an identity element.

  1. Left identity: \(mempty \diamond x \equiv x\)
  2. Right identity: \(x \diamond mempty \equiv x\)

The identity element allows you to define a “starting point” or “neutral” value. This is incredibly useful for operations that need to start with nothing and build up a result like foldMap (which we will cover in the next post).

Without an identity, you can only combine existing values. With an identity, you can define a default result even when there are no values to combine.

Examples #

Most of our Semigroup examples also have a natural identity element:

  • Numbers with addition (Int, +, 0) with 0 the identity
  • Strings with concatenation (String, ++, "") with the empty string as identity
  • Lists with concatenation ([a], ++, []) with the empty list as identity
  • Max and Min wrappers with a tweak, for Max a mempty would be negative infinity, while for Min a it would be positive infinity. These can be defined using Bounded contexts. This already hints at a problem: not every domain has a meaningful identity.
instance (Ord a, Bounded a) => Monoid (Max a) where
    mempty = Max minBound

Although we can define identities for Max and Min using Bounded, this only works when the type already has meaningful bounds. Many domains don’t, and in those cases the abstraction quietly breaks down. Algebraic structure should reflect the domain, if there is no natural “do-nothing” value, forcing one is conceptually wrong.

Semigroups and Monoids in practice #

Let’s look at how these fundamental structures underpin powerful patterns in functional programming.

Applicative error accumulation #

Remember our Validation Applicative from the previous post?

data Validation e a = Success a | Failure [e] deriving (Show)

-- Example Applicative instance for Validation
-- (Simplified - note the list of errors [e] forms a Monoid)
instance Applicative (Validation [e]) where
    pure = Success
    (Success f) <*> (Success x) = Success (f x)
    (Failure e1) <*> (Failure e2) = Failure (e1 ++ e2) -- Collect all errors
    (Failure e) <*> _ = Failure e
    _ <*> (Failure e) = Failure e

The crucial part here is (Failure e1) <*> (Failure e2) = Failure (e1 ++ e2). Lists form a Semigroup under (++), and a Monoid once we include [] as the identity.

More generally, if your e type (the error type) itself is a Semigroup we can write:

instance Semigroup e => Applicative (Validation e) where
    -- ...
    (Failure e1) <*> (Failure e2) = Failure (e1 <> e2) -- Combine errors with <>
    -- ...

This means that our Applicative validation works because our error type knows how to combine itself associatively. If e cannot form a Semigroup, our Applicative can’t accumulate errors reliably. Forcing e to be a Semigroup instead of a [e] we’ve made our Validation type more generic.

We don’t reach for a Monoid here because an empty, or neutral error makes little sense.

foldMap needs a Monoid #

One of the most powerful functions in Haskell for summarizing data is foldMap. It’s part of the Foldable typeclass (which we will discuss in the next post). \[ \text{foldMap} :: \text{Monoid } m \implies (a \to m) \to f \space a \to m \] foldMap takes two arguments:

  1. a function (a -> m) which transforms each element a of your data structure into a Monoid value m.
  2. a data structure f a, something like List a or Tree a

It transforms each a into an m, and combines all the m’s using the Monoid’s (<>) operation, starting from mempty.

Here’s an example where we sum or multiply a list of integers together. Since integers have multiple Semigroups (for example (Int, +) and (Int, *)), we have to define seperate Monoids for the specific operation we want to use.

import Data.Monoid (Sum(..)) -- Sum is a Monoid wrapper for numbers

-- Let's define our own Product wrapper
data Product a = Product a deriving (Show)
-- Make it a Semigroup (Int, *)
instance Num a => Semigroup (Product a) where
    (Product x) <> (Product y) = Product (x * y)
-- Make it a Monoid (Int, *, 1)
instance Num a => Monoid (Product a) where
    mempty = Product 1

-- use Sum to a sum a list
foldMap Sum [1, 2, 3, 4]        -- GetSum 10

-- use Product to multiply a list
foldMap Product [1, 2, 3, 4]    -- Product 24

-- what if the lists are empty?
foldMap Sum []                  -- GetSum 0
foldMap Product []              -- Product 1

The mempty from the Monoid is crucial here. If the list is empty, foldMap returns mempty as the default result. A Semigroup alone couldn’t do this, as it lacks a “default nothing” value.

This ability to transform elements into a Monoid and then combine them starting from mempty makes foldMap incredibly versatile for tasks like summing, counting, concatenating, finding min/max or even building complex reports from any traversable data structure.

A Semigroup lets you combine values; a Monoid lets you combine values even when you have none.

Conclusion #

The real question isn’t “can I define a mempty?” but “does an identity actually exist in my domain?”

Semigroups give us a reliable way to combine things, while Monoids extend that idea with a neutral starting point - when such a value genuinely exists.

Just like we learned not to reach for a Monad when a Functor or Applicative suffices, the same principle applies here:

  • don’t start with Monoid, start with a Semigroup
  • add identity (mempty) only when it truly exists and makes sense for your domain

The choice between Semigroup and Monoid is about acknowledging whether a “neutral” or “empty” state is a meaningful concept for your combination.

If you are always guaranteed to have at least one element to combine, a Semigroup is sufficient and more precise. It removes the possibility of dealing with an mempty that might not make sense in all contexts (e.g. max of an empty set of numbers is problematic without minBound).

Just like Monads empower complex control flow by removing restrictions, Monoids empower foldMap and other “aggregate from nothing” operations by removing the restriction of non-emptiness, but only when an identity actually makes sense.