Vidal Vasconcelos
← Back to home

Monoids in the real world: merging accounts with fp-ts

In a previous post we met Monoids disguised as pressure cookers: surprisingly easy to operate, capable of making your life a lot simpler. Back then we used them to compose sorting rules, but that only scratched the surface. In this post we go deeper. We'll model a real-world requirement — merging customer accounts — and see how Monoids turn a tangled merge function into a set of small, declarative rules that compose themselves.

The problem

Picture an e-commerce platform. A customer signed up years ago, forgot about that account, and created a fresh one. Now support needs to merge the two. The merged account should keep the most recent name, combine every email address, join order histories, sum loyalty points, and merge notification preferences.

The imperative version of this usually looks like a wall of if/else blocks, one per field, glued together inside a single function. Adding a new field means touching that function again, hoping you don't break the rest. Let's see how Monoids offer a different path.

Diagram showing two accounts — old and new — with sample data merging field-by-field into a single merged account, annotated with the strategy used for each field

Modeling the domain

We start with a few interfaces that capture the shape of our data. Each domain concept — Email, Order — lives in its own module, and the Account type brings them together:

interface Order {
  readonly id: string
  readonly total: number
  readonly date: string
}

interface Account {
  readonly name: string
  readonly email: Email
  readonly orders: ReadonlyArray<Order>
  readonly loyaltyPoints: number
  readonly preferences: Readonly<Record<string, string>>
}

Notice that email is not a plain string. An email carries more information than its address — we'll see the full Email type in a moment, when we build its module.

A quick Monoid refresher

Before we dive in, let's look at what a Monoid actually is. In fp-ts the interface is remarkably small:

interface Monoid<A> {
  readonly concat: (first: A, second: A) => A
  readonly empty: A
}

Two members, that's it. concat takes two values of the same type and combines them into one. empty is the neutral element: when you concat it with any value, the result is that value unchanged. You already use Monoids without realizing it:

// Monoid for number addition
const MonoidSum: Monoid<number> = {
  concat: (first, second) => first + second,
  empty: 0, // 0 + n === n
}

// Monoid for arrays
const MonoidArray: Monoid<Array<string>> = {
  concat: (first, second) => [...first, ...second],
  empty: [], // [...[], ...items] === items
}

The beauty is that once you have a Monoid for each field of a structure, fp-ts can assemble them into a Monoid for the entire structure automatically. That is the key insight we'll exploit.

Diagram showing the Monoid contract: concat combines two values of the same type into one, and empty is the neutral element where concat(a, empty) equals a

Declaring what merge means

Instead of writing a function that manually copies and transforms each field, we take a different approach: we declare what merging means for each attribute. Each declaration lives inside the module that owns that piece of the domain, just like we organized predicates across modules in the contramap post. The result is that anyone on the team can open a module and immediately understand the merge rule for that concept — without reading the rest of the codebase.

name — keep the value from the newer account. This is a last-write-wins strategy. fp-ts gives us Semigroup helpers for this, and we can lift any Semigroup into a Monoid by providing an empty value:

// src/name.ts
import * as S from 'fp-ts/Semigroup'
import * as M from 'fp-ts/Monoid'

export const lastWins: M.Monoid<string> = {
  concat: S.last<string>().concat,
  empty: '',
}

email — keep the most recent one. When two accounts are merged the customer probably cares about the address they used last. In our application an email is not just a string. The Email module defines the interface, an Ord instance that compares emails by creation date, and the Monoid that always picks the newer one — all in one place:

// src/email.ts
import { pipe } from 'fp-ts/function'
import * as Ord from 'fp-ts/Ord'
import * as D from 'fp-ts/Date'
import * as S from 'fp-ts/Semigroup'
import * as M from 'fp-ts/Monoid'

export interface Email {
  readonly mail: string
  readonly verified: boolean
  readonly createdAt: Date
}

const byCreatedAt: Ord.Ord<Email> = pipe(
  D.Ord,
  Ord.contramap((e: Email) => e.createdAt)
)

// Epoch-dated email — any real email will be more recent
const empty: Email = { mail: '', verified: false, createdAt: new Date(0) }

export const mostRecent: M.Monoid<Email> = {
  concat: S.max(byCreatedAt).concat,
  empty,
}

The key piece is byCreatedAt. fp-ts ships an Ord for Date out of the box (D.Ord), and we adapt it to Email via contramap — the same technique we explored in the contramap post. S.max turns that ordering into a Semigroup that always picks the greater value, and we lift it into a Monoid with an empty dated at the epoch — any real email will be more recent, so the neutral element never wins.

orders — combine both histories. No deduplication needed because every order has a unique id. The array Monoid from fp-ts handles this directly:

// src/order.ts
import * as RA from 'fp-ts/ReadonlyArray'
import * as M from 'fp-ts/Monoid'

export const ordersMonoid: M.Monoid<ReadonlyArray<Order>> = RA.getMonoid<Order>()

loyaltyPoints — sum them up. We could write this Monoid by hand — after all, we know exactly what concat and empty should be:

// src/loyalty.ts
import * as M from 'fp-ts/Monoid'

export const sumPoints: M.Monoid<number> = {
  concat: (first, second) => first + second,
  empty: 0,
}

It works, but fp-ts already ships this exact Monoid ready to use:

// src/loyalty.ts
import * as N from 'fp-ts/number'
import * as M from 'fp-ts/Monoid'

export const sumPoints: M.Monoid<number> = N.MonoidSum

Same behavior, zero boilerplate. This is a good pattern to keep in mind: understand what the Monoid does by writing it once, then reach for the library version in production code.

preferences — merge the two records so that newer values override older ones on conflict. We can use getMonoid from the Record module together with a last-wins semigroup:

// src/preferences.ts
import * as S from 'fp-ts/Semigroup'
import * as M from 'fp-ts/Monoid'
import * as R from 'fp-ts/Record'

export const mergePreferences: M.Monoid<Readonly<Record<string, string>>> =
  R.getMonoid(S.last<string>())

Composing the Account Monoid

Each rule lives in its own module, owned by the team that understands that part of the domain. The Account module does not contain any merge logic itself. It simply imports the rules and wires them together using struct:

// src/account.ts
import * as M from 'fp-ts/Monoid'
import {lastWins} from './name'
import {mostRecent} from './email'
import {ordersMonoid} from './order'
import {sumPoints} from './loyalty'
import {mergePreferences} from './preferences'

export const accountMonoid: M.Monoid<Account> = M.struct({
  name: lastWins,
  email: mostRecent,
  orders: ordersMonoid,
  loyaltyPoints: sumPoints,
  preferences: mergePreferences,
})

This is the only place where the modules meet. The boundaries are explicit: each import is a conscious decision about how a field should be merged. No hidden coupling, no shared mutable state. Adding, removing, or changing a rule means touching one module and one line in this composition.

Merging two accounts is now a single call:

const merged: Account = accountMonoid.concat(oldAccount, newAccount)

Compare that with the imperative alternative. There are no if branches, no manual field copying, and no room for forgetting a property.

Scaling to N accounts

A few weeks later a new requirement arrives: support needs to merge three or more accounts at once. Because we have a full Monoid (not just a Semigroup), we get empty for free, which means concatAll works out of the box:

const mergeAll = M.concatAll(accountMonoid)

const result: Account = mergeAll([accountA, accountB, accountC])

If the list is empty, concatAll returns the empty Account — a perfectly valid default with an empty name, an epoch-dated email, no orders, zero points, and no preferences. No edge-case handling required.

Diagram showing N accounts being folded left-to-right through concat into a single merged result, with a second row showing the edge case where an empty list produces the empty Account

Adding a new field

Imagine the product team adds a tags field to Account — a list of strings that categorize the customer (e.g. "vip", "wholesale"). When merging accounts we want the union of both tag lists, not a simple concatenation that would leave us with duplicates. Tags are plain strings — simpler than emails — so the Monoid is a one-liner:

// src/tags.ts
import * as RA from 'fp-ts/ReadonlyArray'
import * as M from 'fp-ts/Monoid'
import * as Str from 'fp-ts/string'

export const unionTags: M.Monoid<ReadonlyArray<string>> =
  RA.getUnionMonoid<string>(Str.Eq)

getUnionMonoid takes an Eq to detect duplicates — for plain strings Str.Eq is all we need. Adding it to the composition is one new line:

const accountMonoid: M.Monoid<Account> = M.struct({
  name: lastWins,
  email: mostRecent,
  orders: ordersMonoid,
  loyaltyPoints: sumPoints,
  preferences: mergePreferences,
  tags: unionTags,
})

The compiler helps too: if you forget to add the entry, the types won't align and the code won't compile.

Conclusion

Here is the full picture. Each domain module defines its own merge rule on top of an fp-ts primitive, and the Account module composes them through M.struct — nothing more:

Diagram showing three layers: fp-ts primitives at the bottom, domain modules in the middle, and the Account Monoid composition at the top, connected by arrows

Monoids turn an ad-hoc merge function into a set of declarative, field-level rules that compose automatically. Each rule is small enough to test on its own, the combined result reads like a specification, and extending the model means adding one more line instead of weaving logic through a monolithic function. In the sorting post we used Monoids to rank products; in the contramap post we adapted predicates across types. Here we merged entire domain entities. The pattern is always the same: define small pieces, let composition do the heavy lifting.