Introduction to Phantom Types in Swift
Sure, Swift is a strongly typed language and most of the time, the compiler will tell you if you assign a String to an Int for example. But there is still a risk where your code will receive unexpected data. There is a practice known in several language to avoid that kind of situation, it's called phantom types.
Phantom Types? Huh?
Ambiguous data is arguably one of the most common sources of bugs and problems within apps in general. While Swift helps us avoid many sources of ambiguity through its strong type system and thorough compiler — whenever we’re unable to create a compile-time guarantee that a given piece of data will always match our requirements, there’s always a risk that we’ll end up in an ambiguous or unpredictable state.
Phantom types is a way to leverage Swift’s type system to perform even more kinds of data validation at compile time — removing more potential sources of ambiguity, and helping us preserve type safety throughout our code base. — John Sundell
1) let text = String(decoding: document.data, as: UTF8.self)
UTF8.self is actually a phantom type. Let's check the implementation of Unicode :
1) enum Unicode { 2) enum UTF8 {} 3) ... 4) } 5) 6) typealias UTF8 = Unicode.UTF8
You can see nested caseless enums, this is a way of using types as markers instead of instantiating them to represent values or objects.
In practice
Let's try to build a banking system to transfer money through accounts. Obviously not everyone has the same currency and for the example, based on a class custom type, we will show how we can handle generic bank transfers to local account (USD to USD) and specific transfers (USD to EUR) while adding type-checks at compile time.
1) class BankAccount<CurrencyType> { 2) let converter: Converter = Converter() 3) let holder: String 4) var balance: Double 5) 6) init(_ name: String, withBalance value: Double) { 7) holder = name 8) balance = value 9) } 10) 11) func transfer(_ value: Double, to receiver: BankAccount) { 12) if value > balance { 13) print("Account \(holder) - Insufficient balance ...") 14) return 15) } 16) 17) balance -= value 18) receiver.balance += value 19) print("Transfered \(value) from \(holder) (curr.balance $\(balance)) to \(receiver.holder) (curr.balance $\(receiver.balance))") 20) } 21) }
Our main class is created using a CurrencyType and for that we will be using an enum containing enums but without cases (see below). This is what we call a phantom type: instead of creating a value that would differentiate your objects, you use a phantom type that will be used as a marker rather than instantiated. This way you can create many BankAccount that will conform to different APIs based on their custom types.
1) enum Currency { 2) enum Dollar {} 3) enum Euro {} 4) }
Note: This code has for only purpose of being an example. You could easily make the whole thing more generic by checking the type of your object during a transfer, for example if self is BankAccount<Currency.Dollar>.
Alright. Time to write some scenarios to test our code.
1) let accountUSD1 = BankAccount<Currency.Dollar>("USD1", withBalance: 1000.0) 2) let accountUSD2 = BankAccount<Currency.Dollar>("USD2", withBalance: 1000.0) 3) let accountEUR1 = BankAccount<Currency.Euro>("EUR1", withBalance: 1000.0) 4) 5) accountUSD1.transfer(200, to: accountUSD2) 6) accountUSD1.transfer(200, to: accountEUR1) 7) // ^- This line won't compile since the `transfer(_, _)` 8) // method only works with accounts with the same `CurrencyType`
And... voilà! We just added a compile-time check to our code to avoid any unexpected data during runtime. The compiler will not compile the last line because we are trying to directly transfer funds from a USD to a EUR account.
Going further
We might want to allow the user to convert and transfer money to another account that uses a different currency. Well, here is the good news: phantom types help us do so. You can create extensions of your class and write type-specific functions.
1) extension BankAccount where CurrencyType == Currency.Dollar { 2) func transferUSDEUR(_ value: Double, to receiver: BankAccount<Currency.Euro>) { 3) if value > balance { 4) print("Account \(holder) - Insufficient balance ...") 5) return 6) } 7) 8) balance -= value 9) 10) let exchangedValue = converter.getConversionRate(value, with: .USD_EUR) 11) receiver.balance += exchangedValue 12) 13) // Do additionnal specific management to the currency / country 14) print("Transfered $\(value) (\(exchangedValue)€) from \(holder) (curr.balance $\(balance)) to \(receiver.holder) (curr.balance \(receiver.balance)€)") 15) } 16}
And now, users can convert and send money from an USD account to an EUR account. In addition, you can not call this function from accountEUR1 since this a type-constrained function for BankAccount where CurrencyType == Currency.Dollar.
Conclusion
In addition to helping the compiler avoid ambiguous states, phantom types help us make our code more reusable and readable when integrated in the right place. There are a lot of possible use cases, and one I love and did not mention in this article is called a state machine (Building a state machine section), it just shows how powerful it can be.
To write this article, I took inspiration from amazing articles (see References section below) that dive deeper. I highly recommend to check them if you are interested.
You can check the full code on my GitHub.
Hope you guys learn something while reading this short article. If you have any question, comments or feedback, you can contact me on Twitter or email.
Thanks for reading!
References
Phantom types in Swift | Swift by Sundell
