Phantom types in Swift
Basics article available: Type inferenceAmbiguous 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.
This week, let’s take a look at a technique that can let us 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 — by using phantom types.
Well formed, yet still ambiguous
As an example, let’s say that we’re working on a text editor, and while it originally only supported plain text files — over time we’ve also added support for editing HTML documents, as well as for previewing PDFs.
To be able to reuse as much of our original document handling code as possible, we’ve kept using the same Document
model as we started out with — only now it has gained a Format
property that tells us what kind of document that we’re dealing with:
struct Document {
enum Format {
case text
case html
case pdf
}
var format: Format
var data: Data
var modificationDate: Date
var author: Author
}
While being able to avoid code duplication is certainly a good thing, and enums are a great way of modeling state in general — when we’re dealing with distinct formats or variants of a model, then the above kind of setup can actually end up causing quite a lot of ambiguity.
For example, we might have certain APIs that only makes sense to call with a document of a given format — such as this function for opening a text editor, which assumes that any Document
passed into it will be a text document:
func openTextEditor(for document: Document) {
let text = String(decoding: document.data, as: UTF8.self)
let editor = TextEditor(text: text)
...
}
While it wouldn’t be the end of the world if we accidentally passed an HTML document to the above function (HTML is, after all, just text), trying to open a PDF that way would most likely result in something completely incomprehensible being rendered, none of our text editing features would work, and our app could even end up crashing.
We’ll keep encountering that same problem with any other format-specific code that we’ll write, for example if we wanted to improve the user experience of editing HTML documents by implementing a parser and a dedicated editor for that format:
func openHTMLEditor(for document: Document) {
// Just like our above function for text editing, this function
// assumes that it'll always be passed HTML documents.
let parser = HTMLParser()
let html = parser.parse(document.data)
let editor = HTMLEditor(html: html)
...
}
An initial idea on how to fix the above problem might be to write a wrapping function that switches on the passed document’s format
, and then opens the correct editor for each case. However, while that would work great for text and HTML documents, since PDF documents aren’t editable within our app — we’d be forced to either throw an error, trigger an assert, or fail in some other way when encountering a PDF:
func openEditor(for document: Document) {
switch document.format {
case .text:
openTextEditor(for: document)
case .html:
openHTMLEditor(for: document)
case .pdf:
assertionFailure("Cannot edit PDF documents")
}
}
The above situation isn’t great, since it requires us as developers to always keep track of what type of document that we’re working on within any given code path, and any mistakes that we might make will only be caught during run time — there’s simply not enough information available to the compiler to perform those kind of checks at compile time.
So even though our Document
model might look very elegant and well formed at first glance, it turns out that it’s not quite the right solution for the situation at hand.
Sounds like we need a protocol!
One way of solving the above problem would be to turn Document
into a protocol, rather than being a concrete type, with all of its properties (except format
) as requirements:
protocol Document {
var data: Data { get }
var modificationDate: Date { get }
var author: Author { get }
}
With the above change in place we can now implement dedicated types for each of our three document formats, and have each of those types conform to our new Document
protocol — like this:
struct TextDocument: Document {
var data: Data
var modificationDate: Date
var author: Author
}
The beauty of the above approach is that it enables us to both implement generic functionality that can operate on any Document
, as well as specific APIs that only accept a certain concrete type:
// This function can save any document, so it accepts anything
// conforming to our new Document protocol:
func save(_ document: Document) {
...
}
// We can now only pass text documents to our function that
// opens a text editor:
func openTextEditor(for document: TextDocument) {
...
}
What we’ve essentially done above is to move the checks that were previously performed during run time to instead be verified during compile time — since the compiler is now able to check that we’re always passing a correctly formatted document to each of our APIs, which is a big win.
However, by performing the above change we’ve also lost what was so great about our initial implementation — code reuse. Since we’re now using a protocol to represent all document formats, we’ll need to write completely duplicate model implementations for each of our three document types, as well as for any other formats we might add support for in the future.
Enter phantom types
Wouldn’t it be great if we could find a way to both be able to reuse the same Document
model for all formats, while still being able to verify our format-specific code at compile time? It turns out that one of our previous lines of code can actually give us a hint towards one way of achieving that:
let text = String(decoding: document.data, as: UTF8.self)
When converting Data
into a String
, like we do above, we pass the encoding that we want the string to be decoded using — in this case UTF8
— by passing a reference to that type itself. That’s really interesting. If we dive a bit deeper, we can then see that the Swift standard library defines the UTF8
type that we refer to above as a case-less enum within yet another namespace-like enum called Unicode
:
enum Unicode {
enum UTF8 {}
...
}
typealias UTF8 = Unicode.UTF8
Note that if you take a look at the actual implementation of the UTF8
type, it does contain one private case that’s only there for backward compatibility with Swift 3.
What we’re looking at here is a technique known as phantom types — when types are used as markers, rather than being instantiated to represent values or objects. In fact, since neither of the above enums have any public cases, they can’t even be instantiated!
Let’s see if we can use that very same technique to solve our Document
dilemma. We’ll start by reverting Document
back to being a struct, only this time we’ll remove its format
property (and the associated enum), and instead turn it into a generic over any Format
type — like this:
struct Document<Format> {
var data: Data
var modificationDate: Date
var author: Author
}
Inspired by the standard library’s Unicode
enum and its various encodings, we’ll then define a similar enum — DocumentFormat
— that’ll act as a namespace for three case-less enums, one for each of our formats:
enum DocumentFormat {
enum Text {}
enum HTML {}
enum PDF {}
}
Note that there are no protocols involved — any type can be used as a format since, just like String
and its various encodings, we’ll only use a document’s Format
type as a compile-time marker. That’ll now enable us write our format-specific APIs like this:
func openTextEditor(for document: Document<DocumentFormat.Text>) {
...
}
func openHTMLEditor(for document: Document<DocumentFormat.HTML>) {
...
}
func openPreview(for document: Document<DocumentFormat.PDF>) {
...
}
We can of course still write generic code that doesn’t require any specific format. For example, here’s how we could turn our save
API from before into a completely generic function:
func save<F>(_ document: Document<F>) {
...
}
However, having to always type Document<DocumentFormat.Text>
to refer to a text document can be quite tedious, so let’s also define shorthands for each format using type aliases. That’ll give us nice, semantic names, without requiring any code duplication:
typealias TextDocument = Document<DocumentFormat.Text>
typealias HTMLDocument = Document<DocumentFormat.HTML>
typealias PDFDocument = Document<DocumentFormat.PDF>
Phantom types also really shine when it comes to format-specific extensions, which can now be implemented directly using Swift’s powerful generics system and same-type constraints. For example, here’s how we might extend all text documents with a method for generating an NSAttributedString
:
extension Document where Format == DocumentFormat.Text {
func makeAttributedString(withFont font: UIFont) -> NSAttributedString {
let string = String(decoding: data, as: UTF8.self)
return NSAttributedString(string: string, attributes: [
.font: font
])
}
}
Since our phantom types are, at the end of the day, just normal types — we could also have them conform to protocols, and use those protocols as generic constraints. For example, we could have some of our DocumentFormat
types conform to a Printable
protocol, which we could then use as a constraint within our printing code. There’s a ton of possibilities here.
A standard pattern
At first, phantom types may look a bit ”out of place” in Swift. However, while Swift doesn’t offer quite the first-class support for phantom types that more pure functional languages (such as Haskell) do, it’s a pattern that can be found in many different places across both the standard library and Apple’s platform SDKs.
For example, Foundation’s Measurement
API uses phantom types to ensure type safety when it comes to passing around various kinds of measurements — such as degrees, lengths, and weights:
let meters = Measurement<UnitLength>(value: 5, unit: .meters)
let degrees = Measurement<UnitAngle>(value: 90, unit: .degrees)
Through the use of phantom types, the above two measurement values can’t be mixed, since what kind of unit that each value is for is encoded into that value’s type. That prevents us from accidentally passing a length to a function that accepts an angle, and vice versa — just like how we prevented document formats from being mixed up before.
Conclusion
Using phantom types is an incredibly powerful technique that can let us leverage the type system in order to validate different variants of a given value. While using phantom types usually makes an API more verbose, and does come with the added complexity of generics — when dealing with different formats and variants, it can let us reduce our reliance on run-time checks, and let the compiler perform those checks instead.
Just like with generics in general, I think it’s important to first carefully assess the current situation — before deploying phantom types. Just like how our initial Document
model wasn’t the right choice for the task at hand, despite being well formed, phantom types could make a simple setup much more complicated — if deployed in the wrong kind of situation. Like always, it comes down to picking the right tool for the job.
What do you think? Have you ever used phantom types, or is it a technique that you have a good use case for? Let me know — along with your questions, comments, or feedback — via either email, or Twitter.
Thanks for reading! 🚀