Skip to content

Search content

From F# to Clojure > 6: From Discriminated Unions to Open Polymorphism

From F# to Clojure Part 6
A closed F# union opens into protocol and multimethod branches.

In F#, a discriminated union makes the alternatives explicit and pattern matching keeps handling closed and exhaustive:

Table of contents

On this page

Protocols Define Operations

A Clojure protocol is closest to an F# interface, while defrecord supplies both fields and an implementation:

type ChargeResult =
    { Status: string
      Method: string
      Reference: string
      Amount: decimal }

type IChargeable =
    abstract member Charge: decimal -> ChargeResult

type Card(lastFour: string) =
    member _.LastFour = lastFour

    interface IChargeable with
        member _.Charge amount =
            { Status = "charged"
              Method = "card"
              Reference = lastFour
              Amount = amount }

type AccountCredit(accountId: int) =
    interface IChargeable with
        member _.Charge amount =
            { Status = "charged"
              Method = "credit"
              Reference = string accountId
              Amount = amount }

let chargeWith (payment: IChargeable) amount =
    payment.Charge amountF#
(defprotocol Chargeable
  (charge [payment amount]))

(defrecord Card [last-four]
  Chargeable
  (charge [_ amount]
    {:status :charged
     :method :card
     :last-four last-four
     :amount amount}))

(defrecord AccountCredit [account-id]
  Chargeable
  (charge [_ amount]
    {:status :charged
     :method :credit
     :account-id account-id
     :amount amount}))Clojure

The caller uses the same function for either representation:

chargeWith (Card("4242") :> IChargeable) 25m
// { Status = "charged"; Method = "card"; Reference = "4242"; Amount = 25M }

chargeWith (AccountCredit(7) :> IChargeable) 10m
// { Status = "charged"; Method = "credit"; Reference = "7"; Amount = 10M }F#
(charge (->Card "4242") 25)
;; => {:status :charged, :method :card, :last-four "4242", :amount 25}

(charge (->AccountCredit 7) 10)
;; => {:status :charged, :method :credit, :account-id 7, :amount 10}Clojure

F# must declare optional data in the type up front. Clojure records still behave like maps, so normal collection operations can attach it later:

type CardDetails =
    { LastFour: string
      Currency: string option }

let card = { LastFour = "4242"; Currency = None }
let euroCard = { card with Currency = Some "EUR" }F#
(def card (->Card "4242"))

(:last-four card)
;; => "4242"

(assoc card :currency :EUR)
;; => #user.Card{:last-four "4242", :currency :EUR}Clojure

Protocol dispatch uses the runtime type of the first argument. It resembles an F# interface at the call site, but implementations can also be extended to existing types after those types were defined.

Multimethods Dispatch on Data

A closed F# union handles shipping with pattern matching. A Clojure multimethod instead uses any function to calculate an open dispatch value; here a keyword reads :method from a map:

type Shipping =
    | Pickup
    | Postal of weight: int

let shippingCost shipping =
    match shipping with
    | Pickup -> 0
    | Postal weight -> 2 * weight

shippingCost (Postal 3)
// 6F#
(defmulti shipping-cost :method)

(defmethod shipping-cost :pickup [_]
  0)

(defmethod shipping-cost :postal [{:keys [weight]}]
  (* 2 weight))

(defmethod shipping-cost :default [{:keys [method]}]
  (throw (ex-info "Unsupported shipping method" {:method method})))

(shipping-cost {:method :pickup})
;; => 0

(shipping-cost {:method :postal :weight 3})
;; => 6Clojure

The dispatch function can inspect several arguments, return a composite value, or participate in a hierarchy. Unlike pattern matching over a discriminated union, new dispatch values and methods can arrive from another namespace without editing the original definition.

Choosing the Smallest Tool

Adding polymorphism before a second implementation exists only hides a straightforward function, so wait until the variation is real.


Share this post

Previous in series
From F# to Clojure > 5: Namespaces and REPL-Driven Development
Next in series
From F# to Clojure > 7: State and Concurrency