Magma, Semigroup, Monoid: The Algebra of Combining Things (Part 8 of the Functional Programming Series)
Part 8 of the Functional Programming in Practice series. Part 7 gave us the category-theory map: objects, arrows, and lawful composition. This part cashes it in on the most ordinary thing in programming, combining two things into one.
Hey there, Coding Chefs! π¨βπ»
Count how many times you have written code that combines a list of things into a single thing. Sum these numbers. Concatenate these strings. Merge these arrays. Fold these config objects into one. Multiply these factors. Each time you probably reached for a slightly different reduce, with a slightly different starting value, and moved on.
What if I told you all of those are the same operation, and that recognizing it gives you a single function that combines anything combinable, written once, working forever? That recognition has three names, layered like nesting dolls: Magma, Semigroup, and Monoid. They sound like PokΓ©mon, but each is just "a set plus a way to combine its values, with progressively nicer guarantees." By the end you will see the shared skeleton under half your reduce calls, and you will understand why one of these structures is the mathematical permission slip for running work in parallel. Let's get cracking.
Magma: A Set With a Way to Combine
Start with the simplest possible structure. A Magma is a set of values together with one operation, call it concat, that takes two values from the set and produces another value in the same set.
That "same set" part is the only requirement, and it has a name: the operation is closed over the set. Two things go in, one thing of the same type comes out. Nothing escapes.
interface Magma<A> {
concat: (x: A, y: A) => A; // two A's in, one A out, always
}
Examples are everywhere once you look:
- Integers with addition. Add two integers, get an integer. Closed.
- Integers with multiplication. Closed.
- Strings with string concatenation. Two strings give a string. Closed.
- Arrays with array concatenation. Closed.
Magma is deliberately barebones, which is why it makes a good base to build on. We pile on requirements to get the more useful structures.
Semigroup: A Magma That Is Associative
A Semigroup is a Magma with one extra rule: the concat operation must be associative.
You met associativity in the category-theory part. It means the grouping does not matter. Combining X then Y then Z gives the same answer whether you combine X and Y first, or Y and Z first. You can drop the parentheses entirely.
// associativity: concat(concat(a, b), c) === concat(a, concat(b, c))
interface Semigroup<A> {
concat: (x: A, y: A) => A; // and this MUST be associative
}
A quick honesty note: TypeScript cannot enforce associativity in the type system. The interface for a Semigroup looks identical to a Magma. The associativity is a law we promise to uphold and, ideally, verify with tests (more on that in a later part). The compiler trusts us here.
Which of our Magmas are also Semigroups? Check associativity:
- Integers with addition.
(3 + 4) + 5equals3 + (4 + 5). Associative. Semigroup. - Integers with multiplication. Associative. Semigroup.
- Strings with concatenation.
("a" + "b") + "c"equals"a" + ("b" + "c"). Semigroup. - Arrays with concatenation. Semigroup.
And which are not? Integers with subtraction look like a Magma (subtract two integers, get an integer), but they are not a Semigroup. Watch: (3 - 4) - 5 is -6, but 3 - (4 - 5) is 4. Different answers, so subtraction is not associative. One counterexample is enough to disqualify it. Division of real numbers fails the same way.
Why Associativity Is Secretly a Superpower
Associativity sounds like a dry technicality. It is actually the thing that lets you run work in parallel, and that is a big deal.
Here is the reasoning. Say you are combining a long sequence of values: a + b + c + d + e + f + g + h. Because the operation is associative, the order you group the combinations does not affect the result. So you do not have to go strictly left to right. You can combine a + b and c + d and e + f and g + h all at the same time, on four different cores or four different machines, then combine the four results, then combine those. The final answer is guaranteed identical to the sequential version.
That is the whole basis of map-reduce and distributed aggregation. Associativity is the mathematical license that says "you may split this work across workers and reassemble it safely." Without it, you would be locked into a single sequential order. With it, the work fans out. This is one of the quiet reasons the industry keeps gravitating toward functional structures as systems scale.
So a Semigroup is not abstract nonsense, it is the property that tells you whether an aggregation is safely parallelizable.
The Edge Case That Breaks Us
Let's try to build the payoff: a generic function that combines a whole list using any Semigroup. Since concat exists on every Semigroup, the logic does not care whether it is combining integers, strings, or arrays. We write it once:
const concatAll =
<A>(sg: Semigroup<A>) =>
(items: A[]): A => {
// combine them all using sg.concat
return items.reduce((acc, x) => sg.concat(acc, x));
};
This works when the list has values. If the list has two items, combine them. If it has one item, return it. But what happens when the list is empty?
Take a second with that. There is nothing to return. With a reduce and no initial value, an empty array throws. We genuinely do not know what to hand back, because a Semigroup gives us no notion of "the value that means nothing yet." This gap is exactly the reason for the next, final structure.
Monoid: A Semigroup With an Identity Element
A Monoid is a Semigroup with one more piece of information: a special empty element (also called the identity or neutral element).
The empty element has a precise property. Combining it with any value, from either side, gives back that same value unchanged. It is the do-nothing value for the operation. You met this exact idea in the category-theory part as the identity arrow of a category, and in arithmetic as 0 for addition.
interface Monoid<A> extends Semigroup<A> {
empty: A; // concat(empty, x) === x AND concat(x, empty) === x
}
Every combinable thing has its natural empty element:
- Integers with addition: empty is
0. Adding 0 changes nothing. - Integers with multiplication: empty is
1. Multiplying by 1 changes nothing. - Strings with concatenation: empty is
"", the empty string. - Arrays with concatenation: empty is
[], the empty array. - Linked lists: empty is the nil end-marker from the recursion part.
And that empty element is precisely what solves our edge case. For an empty list, concatAll returns the monoid's empty. The empty sum is 0. The empty product is 1. The empty string-concat is "". Each is exactly right, and each falls out of the same general rule.
const numberSumMonoid: Monoid<number> = { concat: (x, y) => x + y, empty: 0 };
const stringMonoid: Monoid<string> = { concat: (x, y) => x + y, empty: "" };
const arrayMonoid = <A>(): Monoid<A[]> => ({ concat: (x, y) => [...x, ...y], empty: [] });
const concatAll =
<A>(m: Monoid<A>) =>
(items: A[]): A =>
items.reduce((acc, x) => m.concat(acc, x), m.empty); // empty seeds it, empty list safe
concatAll(numberSumMonoid)([1, 2, 3, 4]); // 10
concatAll(numberSumMonoid)([]); // 0 <- the edge case, handled
concatAll(stringMonoid)(["a", "b", "c"]); // "abc"
concatAll(arrayMonoid<number>())([]); // []
One function. It combines numbers, strings, arrays, anything that is a Monoid, and it handles the empty list correctly for every one of them, because the Monoid carries its own notion of empty. That is the payoff I promised. The thing you kept rewriting as bespoke reduce calls is a single generic function over Monoids.
This Is the Same Shape as Your reduce Calls
Step back and look at what reduce actually takes: a combining function and an initial value. A combining function and an initial value are precisely a Monoid's concat and empty. Every time you wrote arr.reduce((a, b) => a + b, 0) you were using the number-sum Monoid. Every arr.reduce((a, b) => a.concat(b), []) was the array Monoid. You have been programming with Monoids for years without the name. Now that you have the name, you can spot the pattern and reach for the shared machinery instead of rewriting the reduce each time.
But Everything Has a Cost
The honest notes.
The laws are on your honor. As mentioned, TypeScript cannot enforce associativity or the identity law. A Monoid whose operation is not actually associative, or whose empty is not truly neutral, will compile fine and then give wrong answers, especially under parallelism. These structures are a promise, and the way to keep yourself honest is property-based testing, which a later part covers. Verify your instances.
Do not abstract prematurely. If you sum numbers in exactly one place, arr.reduce((a, b) => a + b, 0) is perfectly clear and reaching for a Monoid<number> is ceremony. The abstraction pays off when you have many combinable types flowing through shared, generic code, or when you genuinely need the parallel-aggregation guarantee. Match the tool to the need.
Real wins live beyond arithmetic. The textbook examples are numbers and strings, which feel trivial. The genuinely useful Monoids are things like merging partial configuration objects, accumulating a list of validation errors instead of stopping at the first, or combining metrics from many sources. fp-ts ships Monoid and Semigroup modules with these batteries included, plus a foldMap that maps each item into a Monoid and combines them in one pass.
The Honest Conclusion
Combining things is so ordinary that we never thought to name it. Naming it reveals a ladder. A Magma is any set with a closed combine operation. A Semigroup adds associativity, which is quietly the permission to parallelize. A Monoid adds an empty element, which is exactly what you need to combine an empty list without crashing. Each step adds one guarantee and unlocks more.
The reward is concrete: a single concatAll over any Monoid replaces a pile of bespoke reduce calls, and the moment you recognize that reduce is "concat plus empty," you start seeing Monoids everywhere in code you already wrote.
The moment you see Monoid, half your reduce calls turn out to be the same function wearing different clothes. Name the pattern, and you only have to write it once.
Next up: We tackle the most famous functional concept of them all, and the one hiding in plain sight. Next we meet the Functor, and you will discover that .map(), which you have called on arrays a thousand times, was never really about arrays at all.