Why Reliability Demands Functional Programming: ADTs, Safety, and Critical Infrastructure
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
1) Reliability starts at the type system
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
null
in a path you forgot to guard - Conflicting booleans: an account is both
isActive = true
andisSuspended = true
- Incomplete lifecycles: a transaction is marked
Pending
and then jumps toReversed
without an associatedSettled
record
Functional programming helps by modeling the domain with types that make invalid states unrepresentable. Pure functions and immutability keep behavior predictable and testable.
2) ADTs in practice: sums and products
Product types combine fields, think "and". Sum types choose one of several cases, think "or". Together they model your domain rules.
Product type example
(* 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
}>;
Sum type example
(* 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.
3) Pattern matching and exhaustiveness
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.
4) Failure scenarios and their type driven fixes
4.1 Banking example: double settlement and reconciliation drift
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 } // new
Every switch
on TxnState
now requires a chargeback
branch. This is free guidance from the compiler.
4.2 Telecom example: ghost billing and incomplete sessions
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.
5) Options and Results as first class tools
Do not use null
to mean "maybe". Do not throw exceptions for expected errors.
Option or Maybe
(* 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
Result or Either
(* 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.
6) Immutability and why it simplifies reasoning
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.
7) Smart constructors and newtypes to protect units and invariants
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 error
You stop mixing milliseconds with seconds or dollars with cents by accident.
8) Pure core, effectful shell
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
}
}
9) Migration playbook
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
Millis
withSeconds
,Cents
withDollars
- 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
10) Performance and ergonomics
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.
Conclusion
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.
Further reading
- Functional programming
- Algebraic data type
- Tagged union or sum type
- Product type
- Pattern matching
- Immutable object
- Pure function
- High availability
Appendix: paste ready helpers
// 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