Real World Example

Safe Money and IDs in a Payments Service

Payment companies (Razorpay, Stripe, PhonePe) never mix rupees with paise or a customer ID with a merchant ID. Branded types plus a single validating constructor and a Result type make those mistakes impossible to compile.

Example

declare const brand: unique symbol;
type Brand<T, B> = T & { readonly [brand]: B };
type Paise = Brand<number, "Paise">;
type MerchantId = Brand<string, "MerchantId">;
type Result<T> = { ok: true; value: T } | { ok: false; error: string };

const Paise = {
  fromRupees(r: number): Result<Paise> {
    if (!Number.isFinite(r) || r <= 0) return { ok: false, error: `invalid amount ${r}` };
    return { ok: true, value: Math.round(r * 100) as Paise };
  },
  add: (a: Paise, b: Paise) => (a + b) as Paise,
  percent: (a: Paise, pct: number) => Math.round((a * pct) / 100) as Paise,
  format: (a: Paise) => "Rs. " + (a / 100).toFixed(2),
};
const merchant = (id: string): Result<MerchantId> =>
  /^mer_[a-z0-9]{6}$/.test(id) ? { ok: true, value: id as MerchantId } : { ok: false, error: `bad merchant id ${id}` };

function settle(m: MerchantId, gross: Paise): string {
  const fee = Paise.percent(gross, 2);
  const net = (gross - fee) as Paise;
  return `${m}: gross ${Paise.format(gross)}, fee ${Paise.format(fee)}, net ${Paise.format(net)}`;
}

for (const [id, rupees] of [["mer_ab12cd", 1499.99], ["mer_ab12cd", -5], ["merchant-1", 100]] as const) {
  const m = merchant(id);
  const amt = Paise.fromRupees(rupees);
  if (!m.ok) console.log("Rejected:", m.error);
  else if (!amt.ok) console.log("Rejected:", amt.error);
  else console.log(settle(m.value, amt.value));
}

Output

mer_ab12cd: gross Rs. 1499.99, fee Rs. 30.00, net Rs. 1469.99
Rejected: invalid amount -5
Rejected: bad merchant id merchant-1