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.
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.