In F#, a discriminated union makes the alternatives explicit and pattern matching keeps handling closed and exhaustive:
Table of contents
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
- Use a function when one implementation is enough.
- Use a protocol when implementations vary by the first argument’s type.
- Use a multimethod when dispatch depends on values, several arguments, or a hierarchy.
Adding polymorphism before a second implementation exists only hides a straightforward function, so wait until the variation is real.