Lessons Learned: Avoiding Primitives in Elm

Josh Clayton

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 Ints represent? What operations could apply that take two Ints 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.