Ben, Joël and I recently built an Elm application over two investment days,
with a focus on trying out a new (to us!) paradigm in Elm. In this application,
we avoided type aliases and primitive types (String
, Int
, etc.) in favor of
product types.
One of our types was for currency. Let’s break down the three ways we could declare this:
-- primitive type (Int)
type alias Player =
{ dollarsOnHand : Int
, dollarsInBank : Int
}
-- type alias
type alias Dollar = Int
type alias Player =
{ dollarsOnHand : Dollar
, dollarsInBank : Dollar
}
-- product type
type Dollar = Dollar Int
type alias Player =
{ dollarsOnHand : Dollar
, dollarsInBank : Dollar
}
Avoid Primitive Obsession
Primitive obsession outlines the use of primitive types (often String
,
Int
, etc.) instead of defining domain-specific types. In our case, the first
Player
uses the type Int
to describe currency. In Elm, however, this is
almost counter-intuitive, and relies on function names alone to convey intent.
Imagine the type signature Int -> Int -> Int
; what do those Int
s represent?
What operations could apply that take two Int
s and return an Int
? The
possibilities are endless, and within an application, can be quite confusing
without looking at the function name.
This both reduces readability and increases the opportunity for bugs to sneak in.
Next, imagine Dollar -> Dollar -> Dollar
or even Dollar -> Int -> Dollar
.
These tell a more compelling story. Even without the function name, my mind
immediately fills in the first as likely describing addition or subtraction,
and the second multiplication or division.
Finally, imagine Dollar -> Quantity -> Dollar
. This might be a function to
calculate the total cost given a specific number are purchased.
Type Aliases Improve Readability, but Still No Help from the Compiler
With what seems like a clear win, let’s move on to the next option: type aliases.
Type aliases allow developers to declare functions with more specific types as we did in the examples above; however, the compiler will still allow any appropriate type in! This means we have an improvement on the readability front (since the type signature can more accurately convey what’s happening), but the compiler still can’t protect us from applying values in the wrong order.
Let’s loop back to the Dollar -> Quantity -> Dollar
example; with a type
alias, any appropriately-typed value (an Int
) would be allowed, meaning the
compiler reduces the signature to Int -> Int -> Int
still.
I’ve been bitten by order-of-operations bugs that can be hidden because of mis-ordered arguments.
Imagine this buggy code (fee
and quantity
are flipped in the list of
arguments in calculateTotalPrice
):
type alias Dollar = Int
type alias Quantity = Int
type alias AdditionalFee = Dollar
calculateTotalPrice : Dollar -> Quantity -> AdditionalFee -> Dollar
calculateTotalPrice dollar fee quantity = -- quantity and fee are flipped!
dollar * quantity + fee
calculateTotalPrice 10 10 5
-- the code compiles, but the type signature doesn't help ensure correctness
This returns 60 : Int
(10 * 5 + 10) instead of 105 : Int
(10 * 10 + 5).
The order of arguments being flipped (fee
and quantity
) isn’t obvious to
spot, but the compiler can help us here.
Product Types
Let’s add product types for Dollar
and Quantity
, as well as the
mathematical operations we’ll be using:
type Dollar = Dollar Int
type Quantity = Quantity Int
type alias AdditionalFee = Dollar
multiply : Dollar -> Int -> Dollar
multiply (Dollar value) quantity =
Dollar <| value * quantity
plus : Dollar -> Dollar -> Dollar
plus (Dollar a) (Dollar b) =
Dollar <| a + b
calculateTotalPrice : Dollar -> Quantity -> AdditionalFee -> Dollar
calculateTotalPrice dollar fee quantity =
(multiply dollar quantity)
|> plus fee
calculateTotalPrice (Dollar 10) (Quantity 10) (Dollar 5) -- compilation error!
This results in a compilation error:
Detected errors in 1 module.
-- TYPE MISMATCH ---------------------------------------------------------------
The argument to function `plus` is causing a mismatch.
19| plus fee
^^^
Function `plus` is expecting the argument to be:
Dollar
But it is:
Quantity
... truncated
Excellent! The compiler is telling us we can’t add a Dollar
and a Quantity
together, pointing out our error. Let’s make the fix:
calculateTotalPrice : Dollar -> Quantity -> AdditionalFee -> Dollar
calculateTotalPrice dollar (Quantity quantity) fee =
(multiply dollar quantity)
|> plus fee
calculateTotalPrice (Dollar 10) (Quantity 10) (Dollar 5)
-- Dollar 105 : Dollar
This compiles successfully, returns the correct value, and is arguably easier to understand:
-- what do these numbers mean!?
calculateTotalPrice 10 10 5
-- more obvious what the numbers mean
calculateTotalPrice (Dollar 10) (Quantity 10) (Dollar 5)
Product types allow for expressive type signatures that correctly convey intent, and the compiler protects us from doing silly things. All is right in the world… or is it?
Costs to Product Types
Introducing product types isn’t without a cost, however. When we started down
the path of using product types in our application, we were unwrapping the
value, applying the value to a function, and re-wrapping the result. This
became incredibly tedious. After thinking about the problem, though, we
recognized that we were basically describing Elm’s version of map
(check out
List.map
and Maybe.map
for polymorphic versions).
map : (Int -> Int) -> Dollar -> Dollar
map f (Dollar i) =
Dollar <| f i
map (* 5) (Dollar 5) -- Dollar 25 : Dollar
With map
identified, we noticed another pattern: adding and subtracting
Dollar
values. (+)
and (-)
both have the signature number -> number -> number
,
so map2
feels very similar:
map2 : (Int -> Int -> Int) -> Dollar -> Dollar -> Dollar
map2 f (Dollar a) (Dollar b) =
Dollar <| f a b
map2 (+) (Dollar 17) (Dollar 8) -- Dollar 25 : Dollar
This allowed for further sugar by defining add
and subtract
, leveraging map2
.
Within the investment time project, here’s what we ended up with:
module Currency exposing (..)
type Currency
= Currency Int
zero : Currency
zero =
Currency 0
add : Currency -> Currency -> Currency
add =
map2 (+)
subtract : Currency -> Currency -> Currency
subtract =
map2 (-)
divideBy : Int -> Currency -> Currency
divideBy divisor =
map (flip (//) divisor)
fromInt : Int -> Currency
fromInt =
Currency
toInt : Currency -> Int
toInt (Currency int) =
int
greaterThan : Currency -> Currency -> Bool
greaterThan (Currency a) (Currency b) =
a > b
map : (Int -> Int) -> Currency -> Currency
map f (Currency a) =
Currency <| f a
map2 : (Int -> Int -> Int) -> Currency -> Currency -> Currency
map2 f (Currency a) (Currency b) =
Currency <| f a b
As you can see, Currency.map
and Currency.map2
ended up being cornerstones
to a number of the other functions, and that makes a lot of sense! Because of
the underlying Int
that we’re wrapping, the functions passed are always
either Int -> Int
or Int -> Int -> Int
.
Ties to Haskell
This path came from my liberal use of newtype
in Haskell; I enjoy the safety
newtype
brings and wanted to see how well it translated to Elm.
If you’re familiar with Haskell, you might notice similarities between the
signatures of Currency.map
/Currency.map2
and mono-traversable
’s
omap
and ozipWith
.
The Haskell equivalent with mono-traversable
could be written as:
{-# LANGUAGE TypeFamilies #-}
module Currency where
import Data.Containers (MonoZip, ozipWith)
import Data.MonoTraversable (MonoFunctor, Element, omap)
newtype Currency = Currency Int deriving (Eq, Ord, Show)
type instance Element Currency = Int
instance MonoFunctor Currency where
omap f (Currency i) = Currency $ f i
instance MonoZip Currency where
ozipWith f (Currency i) (Currency i') = Currency $ f i i'
add :: Currency -> Currency -> Currency
add = ozipWith (+)
subtract :: Currency -> Currency -> Currency
subtract = ozipWith (-)
divideBy :: Int -> Currency -> Currency
divideBy divisor = omap (`quotInt` divisor)
where
quotInt i i' = fromIntegral i `quot` fromIntegral i'
zero :: Currency
zero = Currency 0
fromInt :: Int -> Currency
fromInt = Currency
toInt :: Currency -> Int
toInt (Currency i) = i
Note that greaterThan :: Currency -> Currency -> Bool
isn’t necessary because
Currency
derives the Ord
type class, allowing us to use the >
operator
directly.
One could also derive Num
(with GeneralizedNewtypeDeriving
) to leverage
(+)
, (-)
, (*)
, and the like:
2 * Currency 5 -- Currency 10
Currency 2 - Currency 1 -- Currency 1
However, it allows for odd interactions:
Currency 2 * Currency 2 -- Currency 4
Currency 5 - 2 -- Currency 3
This feels sloppy, and as such, I favor the explicit functions for the
operations I expect to perform on Currency
.
Final Thoughts
Compilers are wonderful things, and type safety (especially with concepts as
important as currency) is an asset I’ve found invaluable in these cases. Even
though there’s a perception of verbosity and extra work mirroring a handful of
functions that are available to Int
, it feels like a worthwhile trade-off.