Published on

Making Sense of Monoids

Authors
  • avatar
    Name
    Mamun Rashid

GitHub Logo

I still remember the first time I stumbled across a monoid. Probably in Allan or Yuxi’s code. Back then, it was pure magic… and complete confusion. Later, someone told me, “Monoid = Semigroup + Identity”… and my brain short-circuited 😅. But over time, it clicked.

Now, monoids feel like a superpower in my toolkit. The concept helps me tame complexity, reason about state, and build intricate state machines without losing my mind. Every time I merge states, accumulate updates, or compose functions, I see its elegancy. In every project I dive into, I try to implement monoids as a part of “state refactoring”.

I feel like it’s time to make these ideas easier to grasp for everyone, so here I go. I’ve pulled together some examples and explanations with a bit of help from GPT, so there might be mistakes along the way. I’d be happy if anyone points out any errors or offers corrections!

The Problem: Why Combining Things Gets Messy

Imagine we're building a shopping cart application. Users can:

  • Add items from multiple sources (product pages, recommendations, wishlist)
  • Apply discounts from different promotions
  • Receive updates from real-time inventory systems
  • Sync cart state across multiple devices

How do we combine all these updates reliably? What happens when updates arrive out of order? How do you ensure the final state is always correct?

This is where monoids come in: a deceptively simple pattern that solves complex composition problems.

What is a Monoid? (The Simple Version)

A monoid is just three things:

  1. A type of value (numbers, strings, shopping carts, anything)
  2. A way to combine two values (add, concat, merge, etc.)
  3. A "neutral" or empty value that doesn't change anything (0 for addition, "" for strings, {} for objects)

Real-World Monoid: Number Addition

// Type: numbers
// Combine: addition (+)
// Neutral: 0

const total = 5 + 0 + 3 + 0 + 2; // = 10
// Notice: adding 0 anywhere doesn't change the result
// Notice: grouping doesn't matter: (5 + 3) + 2 = 5 + (3 + 2)

Real-World Monoid: String Concatenation

// Type: strings
// Combine: concatenation
// Neutral: ""

const message = '' + 'Hello' + '' + ' ' + 'World'; // = "Hello World"
// Empty string "" doesn't change anything
// Grouping doesn't matter: ("Hello" + " ") + "World" = "Hello" + (" " + "World")

Implementing a Monoid: The Complete Pattern

Here's how we'd implement a monoid in code. The pattern is surprisingly simple:

// Monoid = Semigroup + Identity
// What is semigroup? A type with a combine operation

export const Sum = (x: number) => ({
    x,
    concat: (other: { x: number }) => Sum(x + other.x),
    empty: () => Sum(0),
});

describe('Monoid', () => {
    test('general test', () => {
        expect(Sum(2).concat(Sum(4)).x).toBe(6);
    });

    test('associativity', () => {
        const a = Sum(2);
        const b = Sum(3);
        const c = Sum(4);

        expect(a.concat(b).concat(c).x).toBe(a.concat(b.concat(c)).x);
    });

    test('identity', () => {
        const value = Sum(5);
        const empty = Sum(0);

        expect(value.concat(empty).x).toBe(value.x);
        expect(empty.concat(value).x).toBe(value.x);
    });
});

Key parts:

  • concat - The combine operation
  • empty - Returns the identity element (0 for addition)
  • The structure wraps a value and provides monoid operations

This pattern works for any monoid. We just change the type, combine logic, and identity value!

The Formal Definition: Semigroup + Identity

Now that we've seen the pattern in action, here's the formal type definition:

interface Semigroup<A> {
    concat: (x: A, y: A) => A;
}

interface Monoid<A> extends Semigroup<A> {
    readonly empty: A;
}

Breaking it down:

  • Semigroup: Any type with a concat operation (the associative binary operation)
  • Monoid: A semigroup that also has an empty value (the identity element)

So when we said "Monoid = Semigroup + Identity" earlier, this is what we meant!

Advanced Pattern: Endofunctions as Monoids

Endofunctions are functions from some type to itself (e.g., ShoppingCart => ShoppingCart or AppState => AppState). They're incredibly useful for describing immutable state transformations—a pattern we use constantly in React, Redux, and modern web development.

interface Endo<A> {
    (x: A): A;
}

interface ShoppingCart {
    items: Array<{ id: string; quantity: number }>;
    discount: number;
    total: number;
}

// An endofunction that applies a 10% discount
const apply10PercentDiscount: Endo<ShoppingCart> = (cart) => ({
    ...cart,
    discount: cart.discount + 0.1,
    total: cart.total * 0.9,
});

const discountedCart = () => {
    const cart = {
        items: [{ id: 'laptop', quantity: 1 }],
        discount: 0,
        total: 1000,
    };
    const cart_ = apply10PercentDiscount(cart);
    return cart_; /* { ..., discount: 0.1, total: 900 } */
};

Notice the power here:

  1. apply10PercentDiscount is a value we can store, pass to functions, and compose
  2. We "mutate" cart immutably by creating a new value with changes applied
  3. State transformations become first-class values we can manipulate!

The Semigroup of Endofunctions

We can combine multiple transformations into a single transformation:

const apply10PercentDiscount: Endo<ShoppingCart> = (cart) => ({
    ...cart,
    discount: cart.discount + 0.1,
    total: cart.total * 0.9,
});

const addShippingFee: Endo<ShoppingCart> = (cart) => ({
    ...cart,
    total: cart.total + 15,
});

// Combine two changes into one
const endoMonoid = <A>() => ({
    concat:
        (f: Endo<A>, g: Endo<A>): Endo<A> =>
        (x: A) =>
            g(f(x)), // Compose: apply f, then g
    empty: (x: A) => x, // Identity: do nothing
});

const applyDiscountAndShipping = () => {
    const cart = {
        items: [{ id: 'laptop', quantity: 1 }],
        discount: 0,
        total: 1000,
    };
    const change = endoMonoid<ShoppingCart>().concat(apply10PercentDiscount, addShippingFee);
    const cart_ = change(cart);
    return cart_; /* { ..., discount: 0.1, total: 915 } (900 + 15) */
};

The Monoid of Endofunctions

The identity element lets us conditionally apply changes:

const buildCartTransformations = (
    hasPromoCode: boolean,
    isPremiumMember: boolean,
    needsShipping: boolean,
) => {
    let change = endoMonoid<ShoppingCart>().empty;

    // Conditionally add transformations
    change = endoMonoid<ShoppingCart>().concat(
        change,
        hasPromoCode ? apply10PercentDiscount : endoMonoid<ShoppingCart>().empty,
    );

    change = endoMonoid<ShoppingCart>().concat(
        change,
        isPremiumMember ? applyFreeShipping : endoMonoid<ShoppingCart>().empty,
    );

    change = endoMonoid<ShoppingCart>().concat(
        change,
        needsShipping && !isPremiumMember ? addShippingFee : endoMonoid<ShoppingCart>().empty,
    );

    return change;
};

// Apply all accumulated changes at once
const finalCart = buildCartTransformations(true, false, true)(originalCart);
// Result: 10% discount applied + $15 shipping = $915

This pattern shines when:

  • Building up state transformations based on runtime conditions (feature flags, user permissions)
  • Composing multiple Redux-style reducers into a single operation
  • Creating reusable, composable data transformations for React state
  • Implementing undo/redo systems (transformations are values you can store!)
  • Accumulating UI state changes before applying them all at once

The Magic: Three Simple Rules

These three rules give us superpowers:

  1. Associativity: We can process updates in any order or group them however we want
  2. Identity: We always have a safe "starting point" or "empty" value
  3. Composability: Small pieces naturally combine into bigger pieces

Why Monoids for State Management?

State management in modern applications needs to:

  • Combine updates from multiple sources (user actions, API responses, websockets)
  • Handle concurrent changes without race conditions
  • Accumulate changes over time predictably
  • Support undo/redo by tracking and reversing operations
  • Enable time-travel debugging by replaying state changes
  • Parallelize computation by processing updates independently

Monoids naturally provide all of these capabilities through their mathematical properties!

Monoids We Use Every Day (Just Didn't Know It)

1. Redux Reducers

// Redux reducer is a monoid!
// Type: State objects
// Combine: Apply action to state
// Neutral: initialState

const cartReducer = (state = { items: [] }, action) => {
    switch (action.type) {
        case 'ADD_ITEM':
            return { ...state, items: [...state.items, action.payload] };
        case 'CLEAR':
            return { items: [] }; // Reset to identity!
        default:
            return state; // Identity: no change
    }
};

// Combining multiple actions:
const finalState = actions.reduce(cartReducer, initialState);
// Order matters for individual items, but the pattern is monoid-like

2. Array Methods (Reduce, Concat)

// Array concatenation is a perfect monoid
const userTags = ['developer'];
const systemTags = ['premium', 'verified'];
const adminTags = ['admin'];

const allTags = [].concat(userTags).concat(systemTags).concat(adminTags);
// = ['developer', 'premium', 'verified', 'admin']

// Using reduce (monoid fold)
const combined = [userTags, systemTags, adminTags].reduce(
    (acc, tags) => acc.concat(tags),
    [], // Identity: empty array
);

3. Object Merging (React setState, spread operator)

// Object merge is a monoid (with last-value-wins)
const defaults = { theme: 'light', language: 'en' };
const userPrefs = { theme: 'dark' };
const sessionPrefs = { showTutorial: false };

const finalPrefs = { ...defaults, ...userPrefs, ...sessionPrefs };
// = { theme: 'dark', language: 'en', showTutorial: false }

// Identity: {}
const unchanged = { ...{ theme: 'dark' }, ...{} }; // theme: 'dark'

4. Promise.all (Async Composition)

// Promise.all combines promises monoidal-like
const userPromise = fetchUser(id);
const postsPromise = fetchPosts(id);
const commentsPromise = fetchComments(id);

const all = await Promise.all([userPromise, postsPromise, commentsPromise]);

// Can be grouped: Promise.all([Promise.all([a, b]), c])
// Identity-like: Promise.resolve()

5. Form Validation

// Validation results form a monoid
type ValidationResult = { valid: true } | { valid: false; errors: string[] };

const combineValidations = (a: ValidationResult, b: ValidationResult): ValidationResult => {
    if (a.valid && b.valid) return { valid: true };

    const errors = [...(!a.valid ? a.errors : []), ...(!b.valid ? b.errors : [])];

    return { valid: false, errors };
};

// Identity
const identity: ValidationResult = { valid: true };

// Combine all validations
const result = validations.reduce(combineValidations, identity);

Advanced: Monoid Patterns in Real Applications

Pattern 1: Event Sourcing

// Events are a perfect monoid
type Event =
    | { type: 'USER_REGISTERED'; data: User }
    | { type: 'CART_UPDATED'; data: CartUpdate }
    | { type: 'ORDER_PLACED'; data: Order };

// Combine: Apply event to state
const applyEvent = (state: AppState, event: Event): AppState => {
    switch (event.type) {
        case 'USER_REGISTERED':
            return { ...state, user: event.data };
        case 'CART_UPDATED':
            return { ...state, cart: { ...state.cart, ...event.data } };
        case 'ORDER_PLACED':
            return { ...state, orders: [...state.orders, event.data] };
    }
};

// Identity: empty state
const initialState: AppState = {
    user: null,
    cart: { items: [] },
    orders: [],
};

// Reconstruct state from events
const currentState = events.reduce(applyEvent, initialState);

// Time-travel: replay up to any point
const stateAtTime = (timestamp: number) =>
    events.filter((e) => e.timestamp <= timestamp).reduce(applyEvent, initialState);

Pattern 2: Analytics Aggregation

// Analytics metrics form a monoid
interface Metrics {
    pageViews: number;
    uniqueUsers: Set<string>;
    totalTime: number;
    errors: string[];
}

const combineMetrics = (a: Metrics, b: Metrics): Metrics => ({
    pageViews: a.pageViews + b.pageViews, // Number addition monoid
    uniqueUsers: new Set([...a.uniqueUsers, ...b.uniqueUsers]), // Set union monoid
    totalTime: a.totalTime + b.totalTime, // Number addition monoid
    errors: [...a.errors, ...b.errors], // Array concat monoid
});

const identity: Metrics = {
    pageViews: 0,
    uniqueUsers: new Set(),
    totalTime: 0,
    errors: [],
};

// Combine metrics from multiple servers
const totalMetrics = await Promise.all([
    fetchMetrics('server1'),
    fetchMetrics('server2'),
    fetchMetrics('server3'),
]).then((metrics) => metrics.reduce(combineMetrics, identity));

Pattern 3: Configuration Cascading

// Configuration merging with precedence
interface Config {
    apiUrl?: string;
    timeout?: number;
    retries?: number;
    headers?: Record<string, string>;
}

const mergeConfig = (base: Config, override: Config): Config => ({
    ...base,
    ...override,
    headers: { ...base.headers, ...override.headers }, // Nested merge
});

const identity: Config = {};

// Cascade: defaults → env → user → runtime
const finalConfig = [defaultConfig, envConfig, userConfig, runtimeConfig].reduce(
    mergeConfig,
    identity,
);

Practical Benefits We Get For Free

When we use monoid patterns, we automatically get:

1. Easy Testing

// Test the combine function once
test('combine is associative', () => {
    expect(combine(combine(a, b), c)).toEqual(combine(a, combine(b, c)));
});

// All other tests are simpler
test('handles user update', () => {
    const result = combine(identity, userUpdate);
    expect(result.user).toEqual(expectedUser);
});

2. Natural Parallelization

// Split work across workers
const processLargeDataset = async (data: Item[]) => {
    const chunkSize = Math.ceil(data.length / 4);
    const chunks = [
        data.slice(0, chunkSize),
        data.slice(chunkSize, chunkSize * 2),
        data.slice(chunkSize * 2, chunkSize * 3),
        data.slice(chunkSize * 3),
    ];

    // Process in parallel (workers/threads)
    const results = await Promise.all(chunks.map((chunk) => processInWorker(chunk)));

    // Combine results (associativity allows parallel processing!)
    return results.reduce(combineResults, emptyResult);
};

// Because of associativity, these operations are equivalent:
const serial = combine(combine(a, b), combine(c, d));
const parallel = await Promise.all([
    Promise.resolve(combine(a, b)),
    Promise.resolve(combine(c, d)),
]).then(([ab, cd]) => combine(ab, cd));

3. Multi-Source State Composition

// Real-world scenario: E-commerce app
interface AppState {
    user: User | null;
    cart: Cart;
    products: Product[];
    notifications: Notification[];
}

// Updates can come from anywhere
const updates = await Promise.all([
    fetchUserSession(), // User state
    syncCartFromServer(), // Cart state
    loadProductCatalog(), // Product state
    getNotifications(), // Notification state
]);

// Combine all updates - order doesn't matter (associativity)!
const newState = updates.reduce(
    (state, update) => ({ ...state, ...update }),
    initialState, // Identity
);

4. Incremental Updates

// Build up state gradually
let state = identity;
state = combine(state, userUpdate);
state = combine(state, cartUpdate);
state = combine(state, preferencesUpdate);
// Same as: combine(identity, combine(userUpdate, combine(cartUpdate, preferencesUpdate)))

5. Batch Operations

// Instead of many small updates
updates.forEach((update) => {
    state = applyUpdate(state, update);
});

// Batch them
const batched = updates.reduce(combineUpdates, emptyUpdate);
state = applyUpdate(state, batched);

Common Monoids Cheat Sheet

Monoid Type Combine Operation Identity
Addition number a + b 0
Multiplication number a * b 1
String Concat string a + b ""
Array Concat T[] [...a, ...b] []
Object Merge object {...a, ...b} {}
Boolean AND boolean a && b true
Boolean OR boolean a || b false
Set Union Set<T> new Set([...a, ...b]) new Set()
Map Merge Map<K,V> new Map([...a, ...b]) new Map()
Min number Math.min(a, b) Infinity
Max number Math.max(a, b) -Infinity

Testing Monoids: Property-Based Testing

// Instead of testing specific cases, test the laws!
describe('Monoid Laws', () => {
    test('Associativity', () => {
        const a = { count: 1 };
        const b = { count: 2 };
        const c = { count: 3 };

        const left = merge(merge(a, b), c);
        const right = merge(a, merge(b, c));

        expect(left).toEqual(right);
    });

    test('Left Identity', () => {
        const value = { count: 5 };
        const identity = {};

        expect(merge(identity, value)).toEqual(value);
    });

    test('Right Identity', () => {
        const value = { count: 5 };
        const identity = {};

        expect(merge(value, identity)).toEqual(value);
    });
});

Why This Matters For State Machines

When building complex applications with state machines, monoids provide:

// State machine transitions form a monoid
type Transition<S> = (state: S) => S;

// Combine: Function composition
const compose =
    <S>(f: Transition<S>, g: Transition<S>): Transition<S> =>
    (state) =>
        g(f(state));

// Identity: No-op function
const identity = <S>(state: S): S => state;

// Example: Authentication flow
const checkSession: Transition<AppState> = (state) => ({
    ...state,
    sessionChecked: true,
});

const loadUserData: Transition<AppState> = (state) => ({
    ...state,
    user: fetchedUser,
    loading: false,
});

const initializeApp = compose(checkSession, loadUserData);
// Can also group as: compose(compose(checkSession, loadUserData), otherTransition)

Real Benefits:

  1. Predictable Composition: Transitions compose without surprises
  2. Testability: Test individual transitions, compose for integration tests
  3. Parallelization: Independent transitions can run concurrently
  4. Time-Travel: Replay transitions to debug or reconstruct state
  5. Undo/Redo: Reverse transitions by tracking monoid operations

When NOT to Use Monoids

Monoids are powerful but not always the right choice:

Order-Dependent Operations: When operation order fundamentally changes results (like dividing numbers)

// Division is NOT a monoid (not associative)
(12 / 4) / 2 = 1.5
12 / (4 / 2) = 6  // Different!

Operations with Side Effects: When each operation must execute individually

// Database writes with unique constraints
// Can't batch: each write might fail independently
await Promise.all([
    db.insert(user1), // Might fail
    db.insert(user2), // Might succeed
    db.insert(user3), // Might fail
]);

Complex Conflicts: When merging requires domain-specific conflict resolution

// Git merge conflicts
const branch1 = { file: 'version A content' };
const branch2 = { file: 'version B content' };
// Can't automatically merge - need human decision

Use Monoids When: Operations are accumulative, order-independent (within groups), and naturally composable.

Advanced: Building Your Own Monoids

Example: Last-Write-Wins Register (CRDT)

interface LWWRegister<T> {
    value: T;
    timestamp: number;
}

const combineLWW = <T>(a: LWWRegister<T>, b: LWWRegister<T>): LWWRegister<T> =>
    a.timestamp >= b.timestamp ? a : b;

const identity: LWWRegister<any> = {
    value: undefined,
    timestamp: -Infinity, // Any timestamp beats this
};

// Usage: Distributed system with eventual consistency
const server1State = { value: 'hello', timestamp: 100 };
const server2State = { value: 'world', timestamp: 200 };
const server3State = { value: '!', timestamp: 150 };

const convergedState = [server1State, server2State, server3State].reduce(combineLWW, identity);
// Result: { value: "world", timestamp: 200 }

Example: Shopping Cart with Quantities

type Cart = Map<ProductId, Quantity>;

const combineCart = (a: Cart, b: Cart): Cart => {
    const result = new Map(a);

    for (const [product, quantity] of b) {
        const existing = result.get(product) || 0;
        result.set(product, existing + quantity); // Addition monoid!
    }

    return result;
};

const identity: Cart = new Map();

// Merge carts from multiple sessions/devices
const finalCart = [mobileCart, desktopCart, tabletCart].reduce(combineCart, identity);

Conclusion

Of course, there’s the classic math and category theory way to think about monoids. Here, I took a more developer-oriented approach. Next time, once I understand it a bit better, I’ll dive even deeper.

Further Reading

Enjoyed this post?

Subscribe to get notified about new posts and updates. No spam, unsubscribe anytime.

By subscribing, you agree to our Privacy Policy. You can unsubscribe at any time.

Discussion (0)

This website is still under development. If you encounter any issues, please contact me