Modeling Currency in Elm using Phantom Types

What is the sum of 3 EUR + 5 USD?

The question seems simple on the surface but there are several complications. We can’t just add 3 + 5 because they are two different currencies. We’ll first need to convert one to the other. For that we’ll need an exchange rate.

There are a lot of ways we could go wrong with this currency logic. The compiler can give us a hand, but we need to communicate our constraints to it. Communication with the compiler is done via types.

Custom types

The common solution to this kind of problem is to introduce a custom type for each unit of measure. While this does allow the compiler to enforce our constraints, it forces the developer to write a ton of conversion functions between the types such as toUsd : Eur -> Usd. Adding just a few extra currencies causes combinatorial explosion of these conversion functions.

Adding more currencies increases the number of conversions combinatorially

This won’t do. We’re going to have to bring in a powerful new type technique: phantom types.

Phantom types

Phantom types are an advanced type technique that allows you to tell the compiler two values are of different types, while still sharing all the same functions. Sounds like exactly what we are looking for!

The trick is defining a type that has a type variable that is unused by any of the type’s constructors.

-- The `a` is not used by any of the type's data
-- constructors so `Currency` is called a "phantom" type.

type Currency a
  = Currency Int

Note that values like Currency 500 don’t tell you what kind of currency we’re dealing with. The only way to know is by adding a type signature.

price : Currency Usd
price =
  Currency 500

Constraint 1 - Addition

OK, so phantom types are a weird party trick. How do they help us enforce our constraints without writing multiple functions? Let’s look at the rule for adding currencies:

Two currencies can be added only if they are both of the same type.

We can write an add function that looks like

-- Can only add two currencies of the same kind

add : Currency a -> Currency a -> Currency a
add (Currency c1) (Currency c2) =
  Currency (c1 + c2)

That signature effectively translates our constraint from English into something the compiler can understand. Now the compiler knows what we want to enforce! And not just for a single currency. This one holds true for any currency.

In the add function above, note that all that work is being done by the signature only. The function body doesn’t know what types the two currencies are, just that they are two integers.

Let’s try to add some currencies. Notice that without the type signature, there’s nothing in the function bodies to differentiate whether Currency 500 is in dollars or euros.

-- Nothing in the body of the function tells us that
-- this currency value is in dollars.

fiveDollars : Currency Dollar
fiveDollars =
  Currency 500

threeEuros : Currency Euro
threeEuros =
  Currency 300

If we try to add these two values, the compiler will give us a correct error saying we can’t add two currencies of different types. Constraint enforced!

38|   add fiveDollars threeEuros
                      ^^^^^^^^^^
This `threeEuros` value is a:

    Currency Euro

But `add` needs the 2nd argument to be:

    Currency Dollar

Constraint 2: Conversions

On to our second constraint:

A currency can be converted to another as long as you know the exchange rate between the two currencies.

We can encode the requirements above into our type signature like: Exchange a b -> Currency a -> Currency b.

Exchange a b is also a phantom type, but it has two unused variables. Double the spookiness for the same low price! 👻👻

type Exchange from to
  = Exchange Float

Once we have the signature, the function body for convert is just arithmetic. Like earlier, the implementation doesn’t know the to and from currencies are for the given rate. It just multiplies the two numbers and lets the type system do all the work of enforcing constraints.

-- We don't allow fractions of a cent so we need to round.
-- Note that some money may be lost or gained by this

convert : Exchange from to -> Currency from -> Currency to
convert (Exchange rate) (Currency c) =
  Currency <| round (rate * toFloat c)

Combo time

It’s now possible to convert our euros into dollars and then add them together. We can go a step further and combine the two steps together.

Adding currencies of different types can be done if you have an exchange rate between them.

addDifferent :
  Exchange a b
  -> Currency a
  -> Currency b
  -> Currency b
addDifferent rate c1 c2 =
  add c2 (convert rate c1)

Finally, let’s try to add those two values again:

-- Nothing in the function body tells us what kind of 
-- exchange rate this is. Only the signature tells us
-- (and the compiler) that this is an exchange rate
-- from Euros to US Dollars.

eurToUsd : Exchange Euro Dollar
eurToUsd =
  Exchange 1.7

addDifferent eurToUsd threeEuros fiveDollars
-- Currency 1010 : Currency Dollar
--
-- that's 10.10 USD

$10.10 USD is the correct answer!

A number by any other name

I talked about phantom types and other ways to communicate what numbers mean to the compiler at Elm in the Spring 2019. You can watch my talk below:

In the wild

Phantom types are useful in a variety of settings where you need to differentiate between types while sharing implementations of functions. For example, they are used a lot in ianmackenzie/elm-units. If you dig into the source, you’ll see that everything is a Quantity a under the hood. This allows the library to share functions like Quantity.add and Quantity.square across all unit types while also enforcing constraints around which operations require same unit values.

You’ll also sometimes see it used for identifiers of various entities. Each entity can have a unique ID type while still sharing common functions, like decoders, with other IDs.

They are also heavily used in dillonkearns/elm-graphql to model queries. The phantom parameter is used to describe what part of the graph you’re querying. This allows the library to enforce constraints around how you’re allowed to compose and nest queries so that they match your API’s schema.