Phantom types have been written and talked about enough, so I don’t personally have much to add to the conversation. Instead, I decided to try my luck with Phantom Types to see how well it fits in with my programming style and how ergonomic it feels in “real” code.

Imagine we are working with Cart and Item entities. A Cart contains Items and they have unique identifiers as integers, much to my chagrin. My preferred mode of using integers in API entities is generally only when math might be involved. I have too many horror stories involving integer fields and leading zeros to make your head spin. String is your friend, or, in this case, Phantom Types.

The Cart and Item entities might look like this, with other fields omitted for brevity.

struct Cart: Equatable {
    let id: Int
    let items: [Item]
}

struct Item: Equatable {
    let id: Int
    let name: String
}

While this looks benign, let’s add a function on Cart that will return a new Cart by omitting an Item. To remove the Item, we’ll pass in the item’s id to look it up by value.

struct Cart: Equatable {
    let id: Int
    let items: [Item]
    
    func removingItem(byId id: Int) -> Cart {
        guard let item = items.first(where: { $0.id == id }) else { return self }
        return Cart(id: id, items: items.filter { $0 != item })
    }
}

This code compiles, but we’ve introduced a nasty, subtle bug. When the new Cart is instantiated, it passes id which uses the parameter instead of the Cart’s id field. If instead we initialized the Cart like so Cart(id: self.id, ... then we’d be in good shape. I personally reserve the use of self when closures and capture semantics come into play so future me is more aware of potential strong reference cycles.

Fortunately, we have a working test suite, so surely we can find and fix the bug through a good old unit test. But if you’re not thorough, a single unit test isn’t guaranteed to find the bug.

func testRemovingCartItemReturnsModifiedCart() {
    let item1 = Item(id: 1, name: "Box")
    let item2 = Item(id: 2, name: "Envelope")

    let cart = Cart(id: 1, items: [item1, item2])
    
    let modifiedCart = cart.removingItem(byId: 1)
    XCTAssertEqual(modifiedCart, Cart(id: 1, items: [item2]))
}

We might write a single test with a single assertion, observe a green suite, and call it a day patting ourselves on the back for being good test citizens, or something.

The human element is still very much present when writing unit tests, and they can never provide as solid of a safety guarantee as compile time feedback. In my daily Swift, I have found myself writing much fewer unit tests than when I was using Ruby or Objective-C. Because as it turns out, Swift can provide much of that compile time safety and guarantee through type safety that unit tests cannot.

We can expand our unit test by using random id values, or adding more tests and assertions, but with the potential for this bug to creep in elsewhere in the code, is it worth it? Let’s see what happens when we swap out our Int type with our Phantom Type.

Here’s our thin Identifier Phantom Type, inspired by PointFree’s Tagged library:

public struct Identifier<T, RawValue>: RawRepresentable {
    public var rawValue: RawValue
    
    public init(rawValue: RawValue) {
        self.rawValue = rawValue
    }
}

extension Identifier: Equatable where RawValue: Equatable { }

extension Identifier: ExpressibleByIntegerLiteral where RawValue: ExpressibleByIntegerLiteral {
    public typealias IntegerLiteralType = RawValue.IntegerLiteralType

    public init(integerLiteral: IntegerLiteralType) {
        self.init(rawValue: RawValue(integerLiteral: integerLiteral))
    }
}

You can read more about Phantom Types from the linked articles above, but the trick here is the generic type T. There’s no stored value of type T and instead the generic parameter is used purely to differentiate identifier types. We’ll use Cart and Item as generic parameters to create distinct Identifier types. Now our Cart and Item can use our more expressive and type safe addition.

struct Cart: Equatable {
    let id: Identifier<Cart, Int>
    let items: [Item]
}

struct Item: Equatable {
    let id: Identifier<Item, Int>
    let name: String
}

Now that we have leveled up our type safety skills, we can revisit the extension from earlier and see what happens when we instantiate our new Cart. Since we’re no longer referring to plain old integer types for our identifiers, we pass in the new Identifier<Item, Int> type. I don’t know about you, but this feels more in tune with the spirit of Swift by passing in a specific, narrow type geared at an Item’s identifier, than blindly tossing around integers that could mean anything.

func removingItem(byId id: Identifier<Item, Int>) -> Cart {
    guard let item = items.first(where: { $0.id == id }) else { return self }
    return Cart(id: id, items: items.filter { $0 != item })
}

A funny thing happens when we try to compile this code. Before, when we were passing an Int in our parameter, the code compiled just fine. And only after adding some unit tests would we maybe encounter the bug.

Believe it or not, Swift actually provides a really good error message on our return line: Cannot convert value of type ‘Identifier<Item, Int>’ to expected argument type ‘Identifier<Cart, Int>’.

You better believe we cannot mix Cart identifiers with Item identifiers! And what’s great is that this bug was caught at compile time, without having to run our application and trip over the bug or write superfluous unit tests in order to spot the issue. Maybe there’s a lesson in unit testing to be had here, I’m not really sure—another time, perhaps.

I love stumbling upon these moments of enlightenment and basking in the satisfaction they bring. And I’m more than satisfied with this simple and freeing addition. It increases the feedback loop of finding bugs as well as setting up the code base for success for by preventing this mistake from happening again. And if you’re not already using them, you should use Phantom Types!