Careeroza
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