Tips · 7 Aug 2026

TypeScript tips for code that handles money: 20 patterns that prevent bugs.

Branded types, exhaustive checks, BigInt arithmetic, boundary validation, and Result types — using the type system to make payment mistakes hard to write.

Topics
TypeScriptTipsPaymentsCode qualityStablePay
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.

Illustrative — exact integer arithmeticTypeScript
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 surprise

2. 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.

Illustrative — branded typesTypeScript
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 amounts

3. 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.

Illustrative — data that exists only in the right stateTypeScript
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.

Illustrative — exhaustive switchTypeScript
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.

Illustrative — validate a webhook payloadTypeScript
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 bigint

12. 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.

Illustrative — a small Result typeTypeScript
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 bigint integers 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

  1. 1
    TypeScript Handbook — Microsoft
  2. 2
    Zod — Zod documentation
  3. 3
    BigInt — MDN Web Docs
  4. 4
    Money — Martin Fowler, Patterns of Enterprise Application Architecture
  5. 5
Found this useful? Share it

Get the next essay in your inbox

Practical writing on payments infrastructure, operations software, and shipping real systems. No spam, no sales sequence.

We only use your email to send the studio's writing. See the privacy policy.

About the authors

Product behaviour described here reflects what is implemented and tested; anything else is marked as planned. Code samples are illustrative.

All writing

Have a system like this to run?

Explore the catalog, or write down the problem and the constraints. We respond when the fit is real.

Free apps from the studio. Enter your email, get a private download link. Free for personal use.

Get them free

Have a product to sell? We review, list, and sell it for you — you keep 90% of every sale.

Apply to sell with us