On this page
Key takeaways
- Use the type system to make illegal states and unit mix-ups impossible to represent.
- Validate all external data at the boundary with a schema, then trust the types inside.
- Use `BigInt` and integer units for money; never let a float near an amount.
- Make exhaustiveness a compile-time guarantee, so adding a state forces you to handle it.
TypeScript cannot make your payment logic correct, but it can make a great many incorrect programs fail to compile. In code that moves value, that is worth real effort. The patterns below are the ones we reach for repeatedly. They follow the same principle: encode what you know in types, so the compiler enforces it on every change [1].
Represent money precisely
The most expensive bugs are unit and precision mistakes.
1. Use `bigint` for amounts
JavaScript numbers are floating point and lose precision for decimal amounts and for large integers. BigInt [3] is exact for integer minor units.
const price = 1_250_000n; // 1.25 in 6-decimal token units
const fee = (price * 25n) / 10_000n; // 0.25% using integer division
const net = price - fee;
// 0.1 + 0.2 === 0.30000000000000004 in floats; bigint has no such surprise2. Brand your amount types
A plain bigint can be a token amount, a fiat amount, or a block number. A branded type stops you passing one where another is expected.
type Brand<T, B extends string> = T & { readonly __brand: B };
type UsdcUnits = Brand<bigint, "UsdcUnits">;
type BlockNumber = Brand<bigint, "BlockNumber">;
const usdc = (n: bigint) => n as UsdcUnits;
function charge(amount: UsdcUnits) { /* ... */ }
charge(usdc(1_000_000n)); // ok
// charge(123n as BlockNumber); // compile error: block numbers are not amounts3. Keep the unit next to the number
Carry { units, decimals, tokenId } together. An amount without its unit is a bug waiting for a caller who guesses wrong [4].
4. Define rounding once, in one function
Scatter Math.round across code and you will round twice somewhere. Put every conversion in a single audited module.
5. Format only at the edge
Store and compute in integers. Convert to a display string only when rendering to a person.
Make illegal states unrepresentable
Model the domain so wrong combinations do not type-check.
6. Use discriminated unions for states
Each state carries exactly the fields that exist in that state. You cannot read a confirmation time from a payment that was never confirmed.
type Payment =
| { state: "created"; id: string }
| { state: "confirming"; id: string; depth: number }
| { state: "confirmed"; id: string; confirmedAt: Date }
| { state: "settled"; id: string; confirmedAt: Date; settledAt: Date };
function confirmedTime(p: Payment) {
if (p.state === "created" || p.state === "confirming") return null;
return p.confirmedAt; // narrowed: only exists here
}7. Enforce exhaustiveness with `never`
When a new state is added, every switch that forgets it should fail to compile.
function assertNever(x: never): never { throw new Error(`unhandled: ${JSON.stringify(x)}`); }
function label(p: Payment): string {
switch (p.state) {
case "created": return "Waiting";
case "confirming": return `Confirming (${p.depth})`;
case "confirmed": return "Confirmed";
case "settled": return "Settled";
default: return assertNever(p); // adding a state breaks the build here
}
}8. Prefer literal unions to booleans and strings
status: "paid" | "unpaid" beats isPaid: boolean and beats a free string. It documents the possibilities and allows exhaustive checks.
9. Use `readonly` and `as const` for tables of rules
Confirmation tiers and transition tables should not be mutable at runtime.
10. Make impossible fields optional-free
Avoid types where half the fields are optional and only some combinations are valid. Split into a union.
Validate at the boundary
Types exist at compile time. External data does not obey them.
11. Parse, do not cast, external input
A JSON.parse result cast to a type is a lie. Validate with a schema library [2] and use the validated value.
import { z } from "zod";
const PaymentConfirmed = z.object({
id: z.string(),
type: z.literal("payment.confirmed"),
data: z.object({
reference: z.string().min(1),
amountUnits: z.string().regex(/^\d+$/).transform((s) => BigInt(s)),
decimals: z.number().int().min(0).max(36),
}),
});
const event = PaymentConfirmed.parse(JSON.parse(raw)); // throws on bad input
// event.data.amountUnits is now a bigint12. Serialise `bigint` explicitly
JSON.stringify throws on bigint. Send amounts as decimal strings and parse them back, rather than as numbers that lose precision.
13. Validate environment variables at startup
Parse config with a schema once and export a typed object. Missing or malformed settings fail fast.
14. Keep validation at the edges
Inside the core, trust the types. Repeated defensive checks everywhere are noise; one careful boundary is signal.
Handle failure explicitly
Errors in payment code deserve visibility in the types.
15. Use a Result type for expected failures
An underpayment or an expired link is not an exception; it is a normal outcome. Returning a typed result forces callers to handle it.
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
type CreateError = "duplicate_reference" | "invalid_amount" | "network_unsupported";
function createIntent(input: Input): Result<Intent, CreateError> {
if (input.amount <= 0n) return { ok: false, error: "invalid_amount" };
// ...
return { ok: true, value: intent };
}16. Reserve exceptions for the unexpected
Bugs and infrastructure faults throw; business outcomes return. Mixing them makes error handling unreliable.
17. Never swallow errors in money paths
An empty catch in payment code is how a failed payout becomes a mystery. Log with the payment id and rethrow or surface.
18. Return typed error codes, not strings
A union of error codes can be matched exhaustively and mapped to user messages in one place.
Test what the types cannot check
Types catch shape errors; tests catch logic errors.
19. Property-test arithmetic and transitions
Random inputs find boundary bugs example tests miss [5]. Assert invariants like "fees never exceed the amount" and "states never move backwards".
20. Test the boundaries of tiers and limits
Amounts exactly on a threshold are where off-by-one errors live.
Payment code review, in types
- Amounts are
bigintintegers with their unit alongside. - Distinct quantities have distinct branded types.
- States are discriminated unions; switches are exhaustive.
- External data is parsed with a schema at the boundary.
- Expected failures are typed results; exceptions are for the unexpected.
- Rounding lives in one audited function.
The best bug is the one that cannot compile. Spend the effort in the types once and every future change is checked for free.
References & further reading
- 1TypeScript Handbook — Microsoft
- 2Zod — Zod documentation
- 3BigInt — MDN Web Docs
- 4Money — Martin Fowler, Patterns of Enterprise Application Architecture
- 5
The product behind this post
StablePay
Self-hosted stablecoin payment infrastructure.
From $4,800 one-time license
About the authors
Mohamed Hasan
Design & Frontend Lead
Leads interface design and frontend engineering across the catalog, with a focus on dense, calm operator UIs.
El Sayed Abd Almohaymen
Mobile Developer
Builds mobile experiences that put approvals, queues, and payment status in the hands of people who are not at a desk.
Product behaviour described here reflects what is implemented and tested; anything else is marked as planned. Code samples are illustrative.
All writing