Vidal Vasconcelos
← Back to home

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

There is an account on the books that signed up years ago, and the customer who opened it forgot it the way one forgets old receipts. Then, one Tuesday in March, the same person signed up again, with a new address and a fresh password, and never quite realised the old account was still there. Months pass, and the day they need to return a faulty toaster, support opens the system and finds the same person twice, two profiles side by side, and the engineers are asked, kindly, to make them one again.

What should that one merged account look like? The most recent name, the most recent email, the combined order history with not a single purchase forgotten, the loyalty points added together, the notification preferences from the newer profile when the two disagree. Five fields, five small rules, one merged result.

Written by hand the merge works. It usually settles into a single function with a small block per field, faithful to its job for years, and there is nothing wrong with that shape until the sixth field is added and the question of where the new rule lives has more than one defensible answer. The Monoids we met in the sorting post, disguised as pressure cookers, take that tangle apart and put each rule back in the module it belongs to.

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

The shop has a handful of small entities and one bigger one. Each small entity, an Order, an Email, lives in its module, and the Account is the place where they meet:

// src/order.ts

/**
 * a single purchase the customer ever made
 */
export interface Order {
  readonly id: string
  readonly total: number
  readonly date: string
}
// src/account.ts
import { Email } from './email'
import { Order } from './order'

/**
 * a customer's account, with all the bits the shop ever knew about them
 */
export 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, and we will see the full Email type in a moment, when we build its rule.

What a Monoid is

In fp-ts the interface is as small as it ever gets:

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

Two members. concat takes two values of the same type and folds them into one. empty is the value that does nothing: combine it with anything, and the anything comes back unchanged. You have written Monoids a thousand times without ever naming them:

/**
 * the Monoid of numbers under addition: zero is the neutral
 */
const MonoidSum: Monoid<number> = {
  concat: (first, second) => first + second,
  empty: 0,
}

/**
 * the Monoid of arrays under concatenation: the empty array is the neutral
 */
const MonoidArray: Monoid<Array<string>> = {
  concat: (first, second) => [...first, ...second],
  empty: [],
}

The quiet thing fp-ts knows, and the thing we are about to lean on, is this: once you have a Monoid for each field of a structure, you have a Monoid for the entire structure for free. The merge function we used to write by hand has been waiting for us to assemble it from its parts.

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

A rule for each field

Five fields, then; five rules. Each rule belongs in the module that owns its field, the same way we kept predicates in their own modules in the contramap post. Anyone on the team can open email.ts and read what it means to merge two emails without having to read the rest of the codebase first.

The simplest rule is on the name. When two accounts disagree, the newer one is kept and the older quietly retires. fp-ts ships Semigroup helpers for exactly this little family of choices, and lifting a Semigroup to a Monoid asks only that we name the neutral value:

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

/**
 * keep the newer name when two accounts are merged
 */
export const lastWins: M.Monoid<string> = {
  concat: S.last<string>().concat,
  empty: '',
}

The email rule is a touch richer. An email in our domain is not just a string; it carries a creation date alongside its address, and a flag for whether the customer ever verified it. The merged account should keep the email created most recently. So the rule begins with an ordering on emails by their creation date and lifts that ordering into a Monoid that always picks the greater of the two:

// src/email.ts
import { pipe } from 'fp-ts/function'
import * as O 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'

/**
 * an email address the customer ever used, with the day it was added
 */
export interface Email {
  readonly mail: string
  readonly verified: boolean
  readonly createdAt: Date
}

/**
 * emails are ordered by the day they were created
 */
const byCreatedAt: O.Ord<Email> = pipe(
  D.Ord,
  O.contramap((email: Email) => email.createdAt),
)

/**
 * an epoch-dated email, so that any real email beats it
 */
const empty: Email = { mail: '', verified: false, createdAt: new Date(0) }

/**
 * keep the email created most recently
 */
export const mostRecent: M.Monoid<Email> = {
  concat: S.max(byCreatedAt).concat,
  empty,
}

byCreatedAt is the very same contramap step we packed in the contramap post: a Date knows how to compare itself, and an email contains a date inside it, and so an email is ordered by what its date is. S.max turns that ordering into a Semigroup that always returns the greater of two emails, and the empty we hand it is dated at the epoch so that any real email beats it. The neutral element earns its keep by losing.

The orders combine into a single history, and because every order has a unique id there is nothing to deduplicate. The plain array Monoid that fp-ts ships handles it directly:

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

/**
 * combine two order histories, keeping every purchase
 */
export const allOrders: M.Monoid<ReadonlyArray<Order>> = RA.getMonoid<Order>()

The loyalty points are a sum. The Monoid is short enough to write by hand, just to see what it looks like:

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

/**
 * the loyalty points of two accounts add together, with zero as the neutral
 */
export const sumPoints: M.Monoid<number> = {
  concat: (first, second) => first + second,
  empty: 0,
}

And then to reach for the one fp-ts already ships, which is the same Monoid without the boilerplate:

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

/**
 * the loyalty points add together: the library Monoid is the one we just wrote
 */
export const sumPoints: M.Monoid<number> = N.MonoidSum

A useful habit grows from this: write the Monoid once by hand to understand what it is, then borrow the library version in the code that ships.

The preferences are a record of strings, and when both accounts have something to say about the same key, the newer value wins. Record.getMonoid takes a Semigroup that resolves conflicts on each value, and a last-wins Semigroup is exactly the rule the merge asks for:

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

/**
 * merge two preference maps, with newer values overriding older ones on conflict
 */
export const mergePreferences: M.Monoid<Readonly<Record<string, string>>> =
  R.getMonoid(S.last<string>())

Composing the merge

Five small rules, each in the module that owns its field. The Account module imports them and asks M.struct to assemble a Monoid for the whole structure, one field at a time:

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

/**
 * merge two accounts field by field, using the rule that lives next to each field
 */
export const Monoid: M.Monoid<Account> = M.struct({
  name: lastWins,
  email: mostRecent,
  orders: allOrders,
  loyaltyPoints: sumPoints,
  preferences: mergePreferences,
})

This is the only place the modules meet. Each import is a deliberate decision about how its field is merged, and there is no hidden coupling and no shared mutable state. Merging two accounts is now a single call that says exactly what it does:

import * as Account from './account'

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

And the safety net is quietly the compiler. Leave a field out of the composition and the types disagree; the program will not even build until every field of the Account has a rule beside its name.

Merging more than two

A few weeks later, support comes back. The audit has surfaced a third long-lost profile belonging to the same customer; three accounts now, not two. Because the rule we built is a full Monoid, with an empty, and not merely a Semigroup, concatAll is already waiting for us:

import * as M from 'fp-ts/Monoid'
import * as Account from './account'

const mergeAll = M.concatAll(Account.Monoid)

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

And the awkward corner case of an empty list dissolves quietly. concatAll hands back the empty Account, a perfectly valid value with no name, an epoch-dated email, no orders, zero points, and no preferences. Nothing to special-case, nothing to crash.

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

When a new field arrives

Some weeks on, the product team adds a tags field to the Account: a small list of labels ("vip", "wholesale", and the like) that the marketing team uses for segmentation. When merging accounts the rule wants the union of the two tag lists, not a plain concatenation that would leave duplicates. Tags are plain strings, simpler than emails, so the Monoid is one line:

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

/**
 * combine two tag lists, dropping duplicates by string equality
 */
export const unionTags: M.Monoid<ReadonlyArray<string>> =
  RA.getUnionMonoid<string>(Str.Eq)

Adding it to the composition is one more line in M.struct, and that is the whole change to ship the new field:

// src/account.ts

/**
 * the same merge, with tags slotted in beside the rules already there
 */
export const Monoid: M.Monoid<Account> = M.struct({
  name: lastWins,
  email: mostRecent,
  orders: allOrders,
  loyaltyPoints: sumPoints,
  preferences: mergePreferences,
  tags: unionTags,
})

And the compiler is quietly on our side again: if the new field is added to the Account interface but its rule is forgotten, the types stop matching and the build refuses. It is the kind of safety net you forget is there until it catches you.

Conclusion

What composition has given us here is the same shape we have been finding all the way down the series. Each domain module owns one rule about one field, written on top of an fp-ts primitive small enough to test on its own, and the Account module is just the list that brings the rules together:

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

In the sorting post we ranked products with Monoids; in the contramap post we adapted predicates across types; here we merged whole entities the same way we built the rest, one declarative line at a time. The pressure cooker, all this while, has done what pressure cookers do: it has taken a list of small pieces and patiently folded them into one. Define the small pieces with care, and let composition do the heavy lifting.