https://blog.rastrian.dev/post/why-reliability-demands-functional-programming-adts-safety-and-critical-infrastructure ~/rastrian/blog [?] Light Home Posts Tags RSS Feed Why Reliability Demands Functional Programming: ADTs, Safety, and Critical Infrastructure By Rastrian on 2025-09-16 at 16:29 UTC View on GitHub 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 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 = { _tag: "Ok"; value: T } type Err = { _tag: "Err"; error: E } type Result = Ok | Err const Ok = (value: T): Result => ({ _tag: "Ok", value }) const Err = (error: E): Result => ({ _tag: "Err", error }) function settle(t: Txn, ledgerId: string): Result { 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 => /^-?\d+$/.test(s) ? Ok(Number(s)) : Err("invalid-int") function loadTtl(): Option { 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 = { _tag: "Some"; value: T } type Option = None | Some const None: None = { _tag: "None" } const Some = (value: T): Option => ({ _tag: "Some", value }) const map = (o: Option, f: (a: A) => B): Option => 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 = { _tag: "Ok"; value: T } type Err = { _tag: "Err"; error: E } type Result = Ok | Err const Ok = (value: T): Result => ({ _tag: "Ok", value }) const Err = (error: E): Result => ({ _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 }> const setEmail = (u: User, email: Option): 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 & { __brand: T } type Cents = Brand const Cents = (n: number): Cents => { if (!Number.isInteger(n) || n < 0) throw new Error("invalid cents") return n as Cents } type Millis = Brand 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 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 * 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 = { _tag: "Some"; value: T } export type Option = None | Some export const None: None = { _tag: "None" } export const Some = (value: T): Option => ({ _tag: "Some", value }) export type Ok = { _tag: "Ok"; value: T } export type Err = { _tag: "Err"; error: E } export type Result = Ok | Err export const Ok = (value: T): Result => ({ _tag: "Ok", value }) export const Err = (error: E): Result => ({ _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 functional-programmingocamltypescriptsecurityADTs (c) 2025 Luis Vaz (Rastrian). Crafted with [?] and lots of [?] Open Source on GitHub