The Big Oops in Type Systems: This Problem Extends to FP as Well Building on Casey Muratori's critique (youtube) of "compile time hierarchies that match the domain model," this problem extends beyond OOP to encompass a broader pattern in static type systems, particularly functional programming approaches that attempt to "make illegal states unrepresentable." Type systems are often ranked in a "correctness hierarchy", with Idris/Haskell at the top, Java/C# in the middle, Python/JavaScript at the bottom. Static type proponents argue that better type systems can capture domain models more accurately, reducing the need for tests by proving certain software properties impossible. The experiment of encoding complex business domains directly into mathematical type constructs has shown mixed results, succeeding in some areas while struggling with the inherent messiness of business logic. While functional programming concepts have spread to other languages, they've largely abandoned the rigid philosophy that defines languages like Haskell. Technical Models vs. Domain Reality There's a critical distinction between technical understanding (how data is processed) and domain presentation (how data is conceptualized within the problem domain). Data processing works best when mapped onto "simpler" reasoning models (à la Rich Hickey's "simple not easy") and then transformed. But domain presentation must accommodate legacy and entrenched domain understanding that evolves with business requirements. The trap is that both OOP hierarchies and FP "make illegal states unrepresentable" create premature crystallization of domain understanding into rigid technical models. When domains evolve (and they always do), this coupling demands expensive refactoring. It's fascinating that an industry that learned the pitfalls of premature optimization and tight coupling has convinced itself that encoding domains into type systems is beneficial—despite creating these very same problems. The Categorical Mismatch Here's the thing nobody talks about: domain experts think in workflows, exceptions, and contextual rules. They'll tell you things like "normally we validate the credit card first, but if it's a returning customer and the amount is under $50, we can process it immediately" or "a shipment is 'pending' until it leaves the warehouse, except for digital goods which are 'delivered' instantly, unless there's a compliance hold." Traditional algebraic type systems encourage exhaustive, mutually exclusive categories, though modern approaches like row types and effect systems offer more flexibility: data PaymentStatus = Pending | Validated | Failed | Completed data ShipmentStatus = Created | InTransit | Delivered | Returned But business domains are messy, contextual, and full of overlapping states that depend on multiple factors simultaneously. Consider healthcare: patient status isn't just 'active' or 'discharged'—it depends on insurance authorization, bed availability, treatment protocols, and family preferences. In finance, a 'trade' might be pending, executed, settled, or failed, but also partially filled, awaiting counter-party confirmation, under regulatory review, or in dispute resolution. The "ubiquitous language" from Domain-Driven Design tries to bridge this gap, but encoding domain language directly into types creates brittle mappings that satisfy nobody. Domain experts can't recognize their mental models in rigid algebraic structures, and developers get trapped maintaining complex type hierarchies that resist change. When the business inevitably adds "VIP customers can modify orders after payment if the change is under $20," watch what happens to your beautiful type system. In raw Haskell, you might start with clean sum types: data PaymentStatus = Pending | Completed | Failed data Order = Order { status :: PaymentStatus , customer :: Customer } The new requirement forces you to encode every state combination. You end up with something like: data PaymentStatus = Pending | Completed CompletedState | Failed FailureReason data CompletedState = CompletedState { canModify :: Bool , maxModificationAmount :: Maybe Decimal , customerType :: CustomerType , itemType :: ItemType } data CustomerType = Standard | VIP | Enterprise data ItemType = Physical | Digital | Service But wait—digital goods have instant delivery, enterprise customers need approval workflows, and seasonal promotions affect modification rules. Soon you're maintaining a baroque hierarchy: data PaymentStatus = Pending PendingState | Completed CompletedState | Failed FailureReason | PartiallyCompleted PartialState CompletedState | PendingApproval ApprovalWorkflow CompletedState data CompletedState = CompletedState { canModify :: Bool , maxModificationAmount :: Maybe Decimal , customerType :: CustomerType , itemType :: ItemType , deliveryStatus :: DeliveryStatus , seasonalRules :: [ SeasonalRule ] , approvalRequired :: Bool } The domain expert's simple mental model ("VIP customers get special flexibility") has been shredded across multiple type definitions that resist every new requirement. This complexity explosion isn't unique to Haskell—it's symptomatic of a deeper mismatch between how businesses think and how type systems work. At some point a good Haskell program might need a complete rewrite of the architecture from scratch: data CustomerId = CustomerId Text data OrderId = OrderId Text data Money = Money Decimal data OrderEvent = OrderCreated CustomerId [ Item ] UTCTime | PaymentAttempted Money PaymentMethod UTCTime | PaymentCompleted PaymentReference UTCTime | PaymentFailed FailureReason UTCTime | OrderModified [ Item ] Money UTCTime | OrderShipped ShippingDetails UTCTime data Order = Order { orderId :: OrderId , events :: [ OrderEvent ] , metadata :: Map Text Value } canModifyOrder :: Order -> Bool canModifyOrder order = let currentState = deriveCurrentState order customer = getCustomer (customerId currentState) in case (customerTier customer, paymentStatus currentState, orderTotal currentState) of ( VIP , Paid , total) | total < Money 20 -> True (_, Pending , _) -> True _ -> False deriveCurrentState :: Order -> OrderState deriveCurrentState = foldl applyEvent emptyOrderState . events type BusinessRule = Order -> ValidationResult validateOrderModification :: [ BusinessRule ] -> Order -> [ Item ] -> ValidationResult validateOrderModification rules order newItems = mconcat $ map (\rule -> rule order) rules vipModificationRule :: BusinessRule vipModificationRule order = if canModifyOrder order then Valid else Invalid "Order cannot be modified" seasonalPromotionRule :: BusinessRule seasonalPromotionRule order = validateSeasonalRules order The Clojure Alternative Clojure's approach of modeling software around data rather than mathematical constructs or type hierarchies has successfully brought functional programming concepts to traditionally OO languages. This approach trades compile-time guarantees for runtime flexibility. You lose some categories of errors that static typing catches, but gain the ability to handle domain complexity that resists categorical thinking. Instead of encoding business rules in type structures, you represent domain concepts as descriptive data that mirrors how domain experts actually think: { :customer-type :premium :order-total 150.00 :shipping-rules #{ :free-shipping :expedited-available }} Business logic becomes explicit transformation functions rather than implicit type system constraints: ( defn apply-shipping-rules [order] ( cond-> order ( and ( = :premium ( :customer-type order)) ( > ( :order-total order) 100 )) ( assoc :shipping-cost 0 ))) Domain experts can read and verify this logic directly. When rules change, you modify functions, not type hierarchies. A full clojure production example demonstrates this flexibility without requiring fundamental architecture updates: ( require '[clojure.spec.alpha :as s]) ( s/def ::customer-id uuid?) ( s/def ::customer-type #{ :standard :premium :vip :enterprise }) ( s/def ::order-total ( s/and number? pos?)) ( s/def ::order-status #{ :pending :paid :shipped :delivered :cancelled }) ( s/def ::order ( s/keys :req [ ::customer-id ::customer-type ::order-total ::order-status ] :opt [ ::items ::payment-method ::shipping-address ])) ( defmulti can-modify-order? ( fn [order] [( :customer-type order) ( :order-status order)])) ( defmethod can-modify-order? [ :vip :paid ] [order] ( < ( :order-total order) 20.00 )) ( defmethod can-modify-order? [ :enterprise :paid ] [order] ( requires-approval? order)) ( defmethod can-modify-order? [_ :pending ] [_order] true ) ( defmethod can-modify-order? :default [_order] false ) ( def shipping-rules [{ :id :free-shipping-vip :condition ( fn [order] ( and ( = :vip ( :customer-type order)) ( > ( :order-total order) 100 ))) :action ( fn [order] ( assoc order :shipping-cost 0 )) :description "VIP customers get free shipping over $100" } { :id :expedited-available :condition ( fn [order] ( # { :vip :enterprise } ( :customer-type order))) :action ( fn [order] ( assoc-in order [ :shipping-options :expedited ] true )) :description "Premium customers can choose expedited shipping" }]) ( defn apply-shipping-rules [order] ( reduce ( fn [order rule] ( if (( :condition rule) order) (( :action rule) order) order)) order shipping-rules)) ( defn validate-order [order rules-config] ( let [validation-results ( for [rule rules-config :let [result (( :validator rule) order)] :when ( not ( :valid? result))] result)] ( if ( empty? validation-results) { :valid? true :order order} { :valid? false :errors ( map :error validation-results)}))) ( defn apply-event [order event] ( case ( :type event) :payment-completed ( assoc order :order-status :paid :payment-ref ( :payment-ref event)) :order-modified ( -> order ( assoc :items ( :new-items event)) ( assoc :order-total ( :new-total event)) ( update :events conj event)) :seasonal-rule-added ( update-in order [ :active-rules ] conj ( :rule event)) order)) ( defn process-order-modification [order new-items] ( -> order ( s/assert ::order ) ( apply-business-rules ) ( apply-seasonal-rules ) ( calculate-new-total new-items) ( validate-modification-limits ) ( update :events conj { :type :order-modified :new-items new-items :timestamp ( java.time.Instant/now )}))) Treating data as immutable facts and designing transformation pipelines sidesteps the minigame of creating type structures that are simultaneously flexible enough for change and rigid enough to prevent errors. Finding the Right Abstraction Level The real insight here isn't that type systems are bad or that dynamic languages are superior—it's that we've been applying type system rigor at the wrong abstraction level. Think about it: we happily use strong typing for low-level concerns. Nobody argues that pointers should be dynamically typed, or that we shouldn't distinguish between integers and floats at the machine level. The type system earns its keep by preventing segfaults and arithmetic errors. But as we move up the abstraction stack toward business logic, the cost-benefit equation flips. Business rules change frequently, involve contextual exceptions, and resist categorical thinking. Trying to encode "VIP customers can modify small orders" as a compile-time guarantee is like using a precision instrument for rough carpentry. The sweet spot appears to be recognizing that different abstraction levels have different stability characteristics. Low-level concerns like data integrity and API contracts benefit from compile-time guarantees, while business logic benefits from runtime flexibility. The mistake isn't choosing the wrong paradigm—it's applying the same approach everywhere. This isn't about abandoning type systems—it's about recognizing that different problems require different tools. Use compile-time guarantees where stability matters and runtime flexibility where change is constant. The industry's mistake was treating this as an either/or choice. The mature approach recognizes that a payment processing system needs both the reliability of strong typing (for financial calculations) and the flexibility of data manipulation (for business rules). You don't have to pick a side in the type system wars. You can use the right tool at the right level of abstraction. Conclusion The path forward isn't to abandon static typing, but to recognize its proper scope. Use it where stability and correctness matter most—data integrity, API contracts, and core algorithms. Embrace runtime flexibility where business logic lives. The goal isn't ideological purity but practical effectiveness. This is how we escape the trap elucidated in "The Big OOPs", regardless of paradigm. The translation layer between domain and technical models becomes a feature, not a bug. Each model optimizes for its purpose without artificial coupling through shared type hierarchies. We can have both reliability where it matters and flexibility where it's needed—we just need to stop trying to solve business domain complexity with type system complexity. July 31, 2025 12:59 PM