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.
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 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 `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
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.
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
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
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:
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)
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!
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:
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
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.