Swift: Money with Phantom Types 👻
When I wrote about enums with no cases yesterday, both @mbrandonw and @jl_hfl pointed me to Phantom Types 👻!
@mbrandonw @NatashaTheRobot @cocoaphony @jesse_squires Yes. https://t.co/bOcxqVhtKi I got no case enums from @johannesweiss
— Joseph Lord (@jl_hfl) March 29, 2016
I’ve seen Phantom Types before, including in this objc.io article about Phantom Types and in conference talks, but while I like the idea of Phantom Types and think they’re cool and interesting, I haven’t actually used them ever in my own code. Mostly, because it’s not yet natural for me to see a problem and think “Ah, Phantom Types would be the perfect solution!”.
However, I would like to have Phantom Types more accessible for myself in the future and I really enjoyed the example that @johannesweiss gave in his talk The Type System is Your Friend, so I’m going to write his example out here to be more searchable / discoverable for myself and hopefully others!
The problem that @johannesweiss with Phantom Types is currency conversion 💸🤑💸!
While in the video, @johannesweiss goes straight into the Phantom Type solution, I wanted to first try a solution without the Phantom Types to see the before and after!
Without Phantom Types 😱
I would probably have a Money struct that includes both the amount and the currency of the amount:
1 2 3 4 5 6 7 8 |
struct Money { enum Currency { case GBP, EUR, USD } let amount: NSDecimalNumber let currency: Currency } |
But let’s say I want to convert the money to a different currency – let’s say from GBP to EUR:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
enum MoneyError: ErrorType { case WrongCurrency } func convertGBPtoEUR(gbp: Money) throws -> Money { if gbp.currency == .GBP { let forex = NSDecimalNumber(mantissa: 133263, exponent: -5, isNegative: false) let convertedAmount = gbp.amount.decimalNumberByMultiplyingBy(forex) return Money(amount: convertedAmount, currency: .EUR) } else { throw MoneyError.WrongCurrency } } // Usage let fivePounds = Money(amount: 5, currency: .GBP) let threeEuros = Money(amount: 3, currency: .EUR) do { let conversionResult = try convertGBPtoEUR(fivePounds) } catch { print("Error!!! Wrong Currency Conversion!!!") } do { // passing in the wrong currency is allowed here let badConversionResult = try convertGBPtoEUR(threeEuros) } catch { print("Error!!! Wrong Currency Conversion!!!") } |
We can immediately see the problem. I have to check to make sure that the money passed into my function is of the correct currency. And if it’s not, I have to trhow an Error. Which cascades down and now the function that calls this function has to handle an error case!
With Phantom Types 👻
Phantom Types are Types that don’t have any functionality. According to @johannesweiss:
A phantom type is a parameterized type whose type parameters do not all appear in its definition. I have what you could call a useless type parameter C. This is the Currency, and it is not even used in the actual properties of the struct. To fill Money, I created an empty protocol, Currency, so that I may mark any type as a currency.
Like this:
1 2 3 4 5 6 7 8 9 |
protocol Currency { } enum GBP: Currency { } enum EUR: Currency { } enum USD: Currency { } struct Money<C: Currency> { let amount: NSDecimalNumber } |
Now the GBP -> EUR conversion function looks like this!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
// the method signature is super nice now! // inputing Money in GBP, return Money in EUR! func convertGBPtoEUR(gbp: Money<GBP>) -> Money<EUR> { let forex = NSDecimalNumber(mantissa: 133263, exponent: -5, isNegative: false) let convertedAmount = gbp.amount.decimalNumberByMultiplyingBy(forex) return Money(amount: convertedAmount) } // Usage let fivePounds = Money<GBP>(amount: 5) let threeEuros = Money<EUR>(amount: 3) let convertedMoney = convertGBPtoEUR(fivePounds) // success! let badMoney = convertGBPtoEUR(threeEuros) // compiler error! |
Notice that we no longer need to manually check for correct currency type or return any errors. The compiler does the work for us!
Update
The point of this blog post is to illustrate the basics of using Phantom Types. However, as @oisdk points out, you can go further and use Phantom Types to automatically pick the correct conversion:
@NatashaTheRobot @johannesweiss @jl_hfl You can also use phantom types to automatically pick the correct conversion https://t.co/2JfZquS4E9
— oisdk (@oisdk) March 30, 2016
@davedelong further optimized the solution to this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 |
import Foundation protocol Currency { static var code: String { get } static var factor: NSDecimalNumber { get } } enum GBP: Currency { static let code = "GBP" static let factor: NSDecimalNumber = 1.44 } enum EUR: Currency { static let code = "EUR" static let factor: NSDecimalNumber = 1.13 } enum USD: Currency { static let code = "USD" static let factor: NSDecimalNumber = 1.0 } struct Money<Cur: Currency>: CustomStringConvertible, FloatLiteralConvertible, IntegerLiteralConvertible { let amount: NSDecimalNumber var description: String { let f = NSNumberFormatter() f.numberStyle = .CurrencyStyle f.currencyCode = Cur.code return f.stringFromNumber(self.amount)! } init(floatLiteral value: Double) { self.amount = NSDecimalNumber(double: value) } init(integerLiteral value: Int) { self.amount = NSDecimalNumber(integer: value) } init(_ amount: NSDecimalNumber) { self.amount = amount } func convertTo<C: Currency>() -> Money<C> { let baseAmount = amount.decimalNumberByMultiplyingBy(Cur.factor) let convertedAmount = baseAmount.decimalNumberByDividingBy(C.factor) return Money<C>(convertedAmount) } } extension Money { var gbp: Money<GBP> { return convertTo() } var eur: Money<EUR> { return convertTo() } var usd: Money<USD> { return convertTo() } } // Usage let fivePound: Money<GBP> = 5 // £5 let threeEuro: Money<EUR> = 3 // €3 print(fivePound.eur) // €6.35 print(threeEuro.gbp) // £2.37 |