In banking, telecom, and payments, reliability is not a nice to have. It is table stakes. The most reliable systems I have worked on reduce entire classes of bugs before the code even runs. Functional programming and Algebraic Data Types (ADTs) let you push correctness into the type system, so illegal states cannot be constructed in the first place.
What you will learn
- How invalid states show up in real systems and why they cause costly incidents
- How ADTs encode business rules so the compiler enforces them
- How pattern matching and exhaustiveness checks turn refactors into safe edits
- Practical modeling patterns for banking and telecom domains in TypeScript and OCaml
- A migration playbook that juniors and mid-levels can apply today
References
Functional programming
Algebraic data type
Tagged union or sum type
Product type
Pattern matching
Immutable object
Pure function
High availability
Most production incidents are not due to complex algorithms. They are due to the code entering a state that should never have been possible. If you have been on call, you have seen variants of these:
- Magic strings:
"paypal"sneaks into a system that only supportsCash,Card,Pix - Nulls: a function expects an email and receives
nullin a path you forgot to guard - Conflicting booleans: an account is both
isActive = trueandisSuspended = true - Incomplete lifecycles: a transaction is marked
Pendingand then jumps toReversedwithout an associatedSettledrecord
Functional programming helps by modeling the domain with types that make invalid states unrepresentable. Pure functions and immutability keep behavior predictable and testable.
Product types combine fields, think "and". Sum types choose one of several cases, think "or". Together they model your domain rules.
(* OCaml *)
type user = {
id : int;
name : string;
email : string option; (* Some "[email protected]" or None *)
}// TypeScript
type User = Readonly<{
id: number;
name: string;
email?: string; // we will improve this with Option next
}>;(* OCaml *)
type payment =
| Cash
| Card of string (* last 4 digits *)
| Pix of string// TypeScript discriminated union
type Payment =
| { kind: "cash" }
| { kind: "card"; last4: string }
| { kind: "pix"; key: string };With this shape, "paypal" cannot exist as a Payment. The compiler refuses the value.
When you pattern match on a sum type, the compiler can force you to handle every variant. If you later add a new case, every non exhaustive match becomes a compilation error or warning. This is how refactors become safe by default.
(* OCaml *)
let describe_payment = function
| Cash -> "Paid in cash"
| Card last4 -> "Card ••••" ^ last4
| Pix key -> "Pix " ^ key// TypeScript
const assertNever = (x: never): never => { throw new Error(`Unhandled variant: ${JSON.stringify(x)}`) }
function describePayment(p: Payment): string {
switch (p.kind) {
case "cash": return "Paid in cash"
case "card": return `Card ••••${p.last4}`
case "pix": return `Pix ${p.key}`
default: return assertNever(p)
}
}Add a new Crypto method and both code bases will point out every place you must update.
Incident story
A payout worker retries on network timeouts and calls settle() twice. The table allows pending = false and settled = true twice with the same ledger id. Reconciliation finds duplicates and accounting needs a manual fix.
Why it happened
State is spread across booleans and strings. The database does not express the lifecycle. The application code does, but only by convention and tests.
Fix with ADTs
(* OCaml *)
type failure_reason =
| InsufficientFunds
| ComplianceHold
| NetworkError of string
type txn_state =
| Pending
| Settled of string (* ledger_id *)
| Failed of failure_reason
| Reversed of string (* original_ledger_id *)
type txn = {
id : string;
amount_cents : int;
state : txn_state;
}// TypeScript
type FailureReason =
| { kind: "insufficientFunds" }
| { kind: "complianceHold" }
| { kind: "networkError"; message: string }
type TxnState =
| { kind: "pending" }
| { kind: "settled"; ledgerId: string }
| { kind: "failed"; reason: FailureReason }
| { kind: "reversed"; originalLedgerId: string }
type Txn = Readonly<{
id: string
amountCents: number
state: TxnState
}>Transitions become total functions. You can return a Result when a transition is not allowed.
(* OCaml *)
type 'a result = Ok of 'a | Error of string
let settle (t: txn) (ledger_id: string) : txn result =
match t.state with
| Pending -> Ok { t with state = Settled ledger_id }
| Settled _ -> Error "already settled"
| Failed _ -> Error "cannot settle a failed transaction"
| Reversed _ -> Error "cannot settle a reversed transaction"// TypeScript
type Ok<T> = { _tag: "Ok"; value: T }
type Err<E> = { _tag: "Err"; error: E }
type Result<T, E> = Ok<T> | Err<E>
const Ok = <T>(value: T): Result<T, never> => ({ _tag: "Ok", value })
const Err = <E>(error: E): Result<never, E> => ({ _tag: "Err", error })
function settle(t: Txn, ledgerId: string): Result<Txn, string> {
switch (t.state.kind) {
case "pending": return Ok({ ...t, state: { kind: "settled", ledgerId } })
case "settled": return Err("already settled")
case "failed": return Err("cannot settle a failed transaction")
case "reversed": return Err("cannot settle a reversed transaction")
}
}Now the illegal transitions are blocked by construction. Test coverage still matters, but the shape of the model prevents a class of bugs.
Refactor safety
When product adds Chargeback, the compiler highlights every match that ignores it. You cannot ship with a half handled lifecycle.
type TxnState =
| { kind: "pending" }
| { kind: "settled"; ledgerId: string }
| { kind: "failed"; reason: FailureReason }
| { kind: "reversed"; originalLedgerId: string }
| { kind: "chargeback"; networkRef: string } // newEvery switch on TxnState now requires a chargeback branch. This is free guidance from the compiler.
Incident story
The call detail record pipeline generates billing events whenever it sees a Connected event. Under jitter and retries, some sessions never receive Completed. The billing system charges based on the wrong boundary and customers complain.
Why it happened
The call lifecycle is implicit across many services. A connected session with no end was still billable because there was no type that separated non billable states from billable ones.
Fix with ADTs
(* OCaml *)
type drop_reason = Network | Busy | Timeout
type call =
| Dialing of int (* at_ms *)
| Connected of int (* started_ms *)
| Dropped of drop_reason * int (* reason, at_ms *)
| Completed of int * int (* started_ms, ended_ms *)
let billable_seconds = function
| Completed (start_ms, end_ms) -> max 0 (end_ms - start_ms) / 1000
| _ -> 0// TypeScript
type DropReason = "network" | "busy" | "timeout"
type Call =
| { kind: "dialing"; atMs: number }
| { kind: "connected"; startedMs: number }
| { kind: "dropped"; reason: DropReason; atMs: number }
| { kind: "completed"; startedMs: number; endedMs: number }
const billableSeconds = (c: Call): number => {
switch (c.kind) {
case "completed": return Math.max(0, (c.endedMs - c.startedMs) / 1000)
default: return 0
}
}Now a connected but never completed call cannot produce a billable duration. The shape forbids the bug.
4.3 Config parsing example: hidden NaN and partial failures
Incident story
A cache TTL is stored in an environment variable. Someone sets CACHE_TTL_SECS=30s. In JavaScript, Number("30s") yields NaN and your code treats it as zero, disabling caching in production.
Fix with Result types
(* OCaml *)
type 'a result = Ok of 'a | Error of string
let parse_int (s: string) : int result =
try Ok (int_of_string s) with _ -> Error "invalid int"
let load_ttl () : int option =
match Sys.getenv_opt "CACHE_TTL_SECS" with
| None -> None
| Some s ->
match parse_int s with
| Ok n -> Some n
| Error _ -> None (* or propagate the error *)// TypeScript
const parseIntR = (s: string): Result<number, "invalid-int"> =>
/^-?\d+$/.test(s) ? Ok(Number(s)) : Err("invalid-int")
function loadTtl(): Option<number> {
const raw = process.env.CACHE_TTL_SECS
if (!raw) return None
const parsed = parseIntR(raw)
return parsed._tag === "Ok" ? Some(parsed.value) : None
}The ambiguity disappears. The code must handle absence and parse errors explicitly.
Do not use null to mean "maybe". Do not throw exceptions for expected errors.
(* OCaml *)
type user = { id: int; email: string option }
let send_mail (u: user) =
match u.email with
| Some e -> (* send e *) ()
| None -> ()// TypeScript
type None = { _tag: "None" }
type Some<T> = { _tag: "Some"; value: T }
type Option<T> = None | Some<T>
const None: None = { _tag: "None" }
const Some = <T>(value: T): Option<T> => ({ _tag: "Some", value })
const map = <A, B>(o: Option<A>, f: (a: A) => B): Option<B> =>
o._tag === "Some" ? Some(f(o.value)) : None(* OCaml *)
let result_map f = function
| Ok x -> Ok (f x)
| Error e -> Error e// TypeScript
type Ok<T> = { _tag: "Ok"; value: T }
type Err<E> = { _tag: "Err"; error: E }
type Result<T, E> = Ok<T> | Err<E>
const Ok = <T>(value: T): Result<T, never> => ({ _tag: "Ok", value })
const Err = <E>(error: E): Result<never, E> => ({ _tag: "Err", error })These types make the happy path and the error path equally explicit.
Mutable shared state is a common source of heisenbugs under concurrency. Prefer immutable data and pure functions. When you need to update, create a new value.
(* OCaml *)
let set_email (u: user) (e: string option) : user =
{ u with email = e }// TypeScript
type User = Readonly<{ id: number; name: string; email: Option<string> }>
const setEmail = (u: User, email: Option<string>): User =>
({ ...u, email })Your tests become simple. Given the same inputs, the function returns the same output.
Numbers are not self describing. Create types that carry meaning.
(* OCaml *)
module Cents : sig
type t
val make : int -> t (* reject negatives *)
val add : t -> t -> t
end = struct
type t = Cents of int
let make x = if x < 0 then invalid_arg "negative cents" else Cents x
let add (Cents a) (Cents b) = Cents (a + b)
end// TypeScript
type Brand<K, T> = K & { __brand: T }
type Cents = Brand<number, "Cents">
const Cents = (n: number): Cents => {
if (!Number.isInteger(n) || n < 0) throw new Error("invalid cents")
return n as Cents
}
type Millis = Brand<number, "Millis">
const Millis = (n: number): Millis => n as Millis
const price: Cents = Cents(500)
// const wrong: Cents = Millis(500) // type errorYou stop mixing milliseconds with seconds or dollars with cents by accident.
Keep the domain logic pure and push IO to the edges. This makes unit tests cheap and fast.
(* OCaml, pure core *)
let authorize_payment (amount: Cents.t) (balance: Cents.t) : bool =
amount <= balance (* assume a comparison helper inside the module *)
(* impure shell *)
let run () =
let amount = Cents.make 500 in
let balance = Cents.make 1200 in
if authorize_payment amount balance then
(* call database and payment gateway here *) ()// TypeScript, pure core
const authorizePayment = (amount: Cents, balance: Cents): boolean => {
return (amount as unknown as number) <= (balance as unknown as number)
}
// effectful shell
async function run() {
const amount = Cents(500)
const balance = Cents(1200)
if (authorizePayment(amount, balance)) {
// perform DB writes or API calls here
}
}Start small and make continuous progress. Here is a practical order for a team new to these ideas.
-
Replace pairs of booleans with a sum type
- Before:
{ isActive: boolean; isSuspended: boolean } - After:
type AccountState = { kind: "active" } | { kind: "suspended" } | { kind: "closed" }
- Before:
-
Replace string enums with discriminated unions
- Avoid free form strings like
"pending" | "settled" | "failed" | string
- Avoid free form strings like
-
Replace nullable fields with Option
- OCaml
string option - TypeScript
Option<string>or a stricter domain specific union
- OCaml
-
Replace thrown control flow with Result
- Reserve exceptions for truly unexpected situations
-
Introduce newtypes or branded types for units and ids
- Prevent mixing
MilliswithSeconds,CentswithDollars
- Prevent mixing
-
Enforce exhaustiveness
- OCaml already warns
- TypeScript: discriminated unions,
assertNever,strictNullChecks,noImplicitReturns
-
Add guard rails in CI
- Treat TypeScript non exhaustive switches and OCaml warnings as errors
Smells to look for
- Multiple booleans that can be true at the same time
- Strings that travel far before being validated
- Functions that sometimes return a value and sometimes throw
- Types that carry numbers without units
Pattern matching compiles to simple branches. Discriminated unions in TypeScript are just plain objects. The main cost you will feel is validation at the boundaries in smart constructors. This is a trade worth making. The compiler then protects the interior of the system.
Reliability is designed. With Algebraic Data Types, pattern matching, Option and Result, immutability, and smart constructors, you encode domain rules directly in your types. Illegal states cannot compile. This is why industries that cannot afford failure, such as banking and telecom, gravitate to functional ideas.
If you work on code that touches money, minutes, or public availability, adopt these patterns now.
- Model workflows with sum types, not conflicting flags
- Use Option and Result instead of nullable and throwable APIs
- Keep a pure core with effects at the edges
- Brand units and identifiers
- Enforce exhaustiveness during code review and in CI
Your on call shifts will be quieter, and your users will notice the difference.
- Functional programming
- Algebraic data type
- Tagged union or sum type
- Product type
- Pattern matching
- Immutable object
- Pure function
- High availability
// TypeScript Option and Result
export type None = { _tag: "None" }
export type Some<T> = { _tag: "Some"; value: T }
export type Option<T> = None | Some<T>
export const None: None = { _tag: "None" }
export const Some = <T>(value: T): Option<T> => ({ _tag: "Some", value })
export type Ok<T> = { _tag: "Ok"; value: T }
export type Err<E> = { _tag: "Err"; error: E }
export type Result<T, E> = Ok<T> | Err<E>
export const Ok = <T>(value: T): Result<T, never> => ({ _tag: "Ok", value })
export const Err = <E>(error: E): Result<never, E> => ({ _tag: "Err", error })
export const assertNever = (x: never): never => { throw new Error(`Unhandled variant: ${JSON.stringify(x)}`) }(* OCaml Option and Result helpers *)
let option_map f = function
| None -> None
| Some x -> Some (f x)
let option_value ~default = function
| None -> default
| Some x -> x
let result_map f = function
| Ok x -> Ok (f x)
| Error e -> Error e