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


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 supports Cash, 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 and isSuspended = true
  • Incomplete lifecycles: a transaction is marked Pending and then jumps to Reversed without an associated Settled record

Functional programming helps by modeling the domain with types that make invalid states unrepresentable. Pure functions and immutability keep behavior predictable and testable.

Examples of invalid state patterns and their type-safe replacements

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.

Product types combine fields; sum types choose one of several variants

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.

Allowed transitions for a transaction from Pending to Settled, Failed, or Reversed.

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.

Call session lifecycle; only Completed produces a billable duration

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.

Control flow using Option and Result instead of nulls or thrown 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.

Branded types prevent mixing incompatible numeric units
(* 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.

Layered architecture diagram showing Shell IO nodes DB, HTTP, Queue flowing into Ports Adapters impure, then into Core Pure Domain with authorize_payment a b to bool and txn_state to Result, arrows left to right.
(* 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.

  1. Replace pairs of booleans with a sum type

    • Before: { isActive: boolean; isSuspended: boolean }
    • After: type AccountState = { kind: "active" } | { kind: "suspended" } | { kind: "closed" }
  2. Replace string enums with discriminated unions

    • Avoid free form strings like "pending" | "settled" | "failed" | string
  3. Replace nullable fields with Option

    • OCaml string option
    • TypeScript Option<string> or a stricter domain specific union
  4. Replace thrown control flow with Result

    • Reserve exceptions for truly unexpected situations
  5. Introduce newtypes or branded types for units and ids

    • Prevent mixing Millis with Seconds, Cents with Dollars
  6. Enforce exhaustiveness

    • OCaml already warns
    • TypeScript: discriminated unions, assertNever, strictNullChecks, noImplicitReturns
  7. 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
Numbered flowchart checklist for migration from strings and nulls to ADTs Option Result with six steps from booleans to union through enforcing exhaustiveness

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


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