Series overview
Part 12 of 2060% complete
2026-04-17•20 min read

Semigroups and monoids

Strip map and flatMap away and something even simpler remains: combining two values of the same type into one. A semigroup is a type with an associative combine — (a ⊕ b) ⊕ c = a ⊕ (b ⊕ c). A monoid is a semigroup with an identity element empty such that empty ⊕ a = a = a ⊕ empty. That is the entire definition — and it is the piece Part 8’s Validated.map2 needed to merge error lists without knowing anything about lists.

The interfaces

algebra/Semigroup.java
@FunctionalInterface
public interface Semigroup<T> {
T combine(T left, T right);
}
algebra/Monoid.java
public interface Monoid<T> extends Semigroup<T> {
T empty();
static <T> Monoid<T> of(T empty, Semigroup<T> semigroup) {
return new Monoid<>() {
public T empty() { return empty; }
public T combine(T left, T right) { return semigroup.combine(left, right); }
};
}
static Monoid<String> string() { return of("", (l, r) -> l + r); }
static Monoid<Integer> integerSum() { return of(0, Integer::sum); }
static Monoid<Boolean> booleanAnd() { return of(true, Boolean::logicalAnd); }
static <T> Monoid<List<T>> list() { return of(List.of(), Semigroup.listConcat()); }
// integerProduct, longSum, booleanOr, set() — same shape
}

Each factory pairs an identity with an associative combine: 0 for addition, "" for concatenation, List.of() for list concat, Set.of() for union, true for and, false for or.

Where the series already used it

Validated.map2 takes a Semigroup<E> to merge two failures — and NonEmptyList supplies one:

data/NonEmptyList.java
public static <T> Semigroup<NonEmptyList<T>> semigroup() {
return NonEmptyList::append;
}

Notice what is not there: NonEmptyList has no Monoid instance, because an empty non-empty list does not exist. The type system is telling you the truth about the algebra — a Semigroup is the strongest claim you can lawfully make, so that is the one the library makes.

Scheme example: combining evaluation metrics

Suppose a district-level report folds thousands of eligibility decisions into counters:

Metrics.java
Monoid<Integer> count = Monoid.integerSum();
Integer total = Foldable.combineAll(List.of(1, 1, 0, 1, 1), count); // 4
Monoid<List<String>> reasons = Monoid.list();
List<String> all = reasons.combine(List.of("income"), List.of("residence"));

The monoid’s value is not that a + b is clever — it is that combineAll (built in Part 13) works for any monoid, and the identity element means the empty list case is free: combineAll(List.of(), Monoid.integerSum()) is 0, no special-casing. Associativity additionally licenses parallelism — you may split the input, fold the halves, and combine, which is exactly what Stream.reduce relies on when you give it a combiner.

The laws, tested

laws/MonoidLawsTest.java
private static <T> void assertMonoidLaws(Monoid<T> monoid, T a, T b, T c) {
assertThat(monoid.combine(monoid.empty(), a)).isEqualTo(a);
assertThat(monoid.combine(a, monoid.empty())).isEqualTo(a);
assertThat(monoid.combine(monoid.combine(a, b), c))
.isEqualTo(monoid.combine(a, monoid.combine(b, c)));
}

Same three-value shape as the monad-law tests: pin the contract on representatives, and promote to property tests if you publish. One subtlety for floating-point types — Double addition is not quite associative ((0.1 + 0.2) + 0.3 ≠ 0.1 + (0.2 + 0.3) at full precision), so “monoid of doubles” is an approximation that matters for financial accumulation. Prefer long paise/cents or BigDecimal, which are exact.

JavaFunctional Programming

Type to search the site.

↑↓ navigate⏎ openPowered by Pagefind