Ahh Swift protocols and generics. When I first started learning about them, my organizational impulses went wild. All the possibilities for meticulously crafted hierarchies ... it was an OCD dreamworld.

That is, until I ran into this:

Protocol can only be used as a generic constraint because it has Self or associated type requirements

With one error, my dreams came crashing to a halt. I could not have collections of protocols with associated types!

Initially in disbelief, I went down every possible rabbit hole (sometimes more than once) to work around this problem, often with none of the solutions out there really meeting my needs.

Here's what I found, and what I eventually decided to do instead.

The core problem

Once upon a time I was experimenting with a personal finance app, and I had a protocol that modeled a debt:

/// A debt owed to a lender
protocol Debt {

        /// The amount borrowed
        var principal: Decimal { get }
        ...
}

The idea was to model debts abstractly, then make different types of debts that conform to the Debt protocol, for example:

/// A debt owed on a credit card
protocol CreditCardDebt: Debt {
        ...
}

Then I could store all my debts as an array on my User object:

struct User {

        /// The user's debts
        var debts: [Debt]
}

Super. Works great so far.

The problem arose when I tried to add interest. You see, different kinds of debts calculate interest differently, so I thought it would be cute to encapsulate this logic into it's own protocol and set of concrete implementations:

protocol Interest {
        ...
}

struct APR: Interest {
        ...
}

But in order to do that properly, I need associated types:

protocol Debt {

        associatedtype ConcreteInterestType: Interest

        var interest: ConcreteInterestType
        ...
}

Because doing so allows me to be type specific in my specific debt implementation:

protocol CreditCardDebt: Debt {
        var principal: Decimal
        var interest: APR // Rather than "var interest: Interest"
}

But that also means I can no longer declare var debts: [Debt] because Debt now has associatedtype requirements. And since I can't have a collection of debts, I can do basic stuff like iterating over a bunch of debts either.

And boy was I indignant. I thought for sure this couldn't be the case. There MUST be a way!

Basic type-erasure

After pulling my hair out with my own failed workarounds, I turned to the Google-sphere and discovered what appeared to be a miracle panacea: a magical pattern called "type-erasure."

Type-erasure simply means "erasing" a specific type to a more abstract type in order to do something with the abstract type (like having an array of that abstract type). And this happens in Swift all the time, pretty much whenever you see the word "Any."

For example, you can have an array of strings and numbers if you erase to AnyHashable:

var hashables = [AnyHashable]()
hashables.append("ABC")
hashables.append(123)

Or you could get even more abstract and erase to Any:

var things = [Any]()
things.append("ABC")
things.append(123)

And you can even iterate over the collection and access the original type:

for thing in things {
    if let string = thing as? String {
        print(string)
    }
    if let integer = thing as? Int {
        print(integer)
    }
}

Magic! All we need to do is create an array of AnyDebt to solve our initial problem and we're good to go!

So I followed the first type-erasure tutorial I could find, and ended up with this:

/// A type-erased debt
struct AnyDebt: Debt {
    
        /// The amount
    let amount: Decimal
    
        /// The interest
    let interest: AnyInterest
    
        /// Initialize with a debt of type D
    init<D: Debt>(_ debt: D) {
        self.amount = debt.amount
        self.interest = AnyInterest(debt.interest)
    }
}

Since Debt has associated type requirements, we need a generic initializer to be able to instantiate an AnyDebt with any type conforming to Debt. We also need AnyInterest to avoid the generic AnyDebt<I: Interest>, which cannot be stored in an array with mixed types of Interest.

This essentially gives us a type in which the details of the original, specific type is “erased” to the lowest common denominator of any implementation of Debt.

And this is all well and good. We can now have an array of AnyDebt and iterate to our heart's content:

var debts: [AnyDebt]

for debt in debts {
        print(debt)
}

Hurray! We did it...

Elaborate type-erasure

Not so fast.

While our simple basic type-erasure solution makes it possible to have an array of debts, we're super limited as to what we can do with it. Unlike our mixed array of strings and integers, we can't do something like this:

for debt in debts {
    if let creditCardDebt = debt as? CreditCardDebt {
        print(creditCardDebt)
    }
}

If we try, we get this warning:

Cast from 'AnyDebt' to unrelated type 'CreditCardDebt' always fails

That's because our AnyDebt implementation offers no way to translate from AnyDebt back to CreditCardDebt. When we erase the type with the above approach, we're actually erasing it for good!

On top of that, remember how we could create an array of AnyHashable and add stuff to it on the fly?

var hashables = [AnyHashable]()
hashables.append("ABC")
hashables.append(123)

Our AnyDebt implementation is also limited such that we need to wrap each concrete type in AnyDebt before we can add to a collection (just like with AnyView in SwiftUI).

var debts = [AnyDebt]()
debts.append(AnyDebt(creditCardDebt))

So what gives?

Turns out, Swifts implementation of Any and AnyHashable has way way way more going on under the hood that allows this behavior. Doing type-erasure in a way that's especially useful is actually a non-insubstantial to massive undertaking that I ultimately decided I'd rather avoid.

And there's about a million different flavors that each have their trade-offs, and tutorials all over the internet that share their attempts at each. I actually once tried to reverse engineer Apple's approach but ended up with some really awkward usage patterns that I just scrapped.

All in all, type-erasure is not the panacea I thought it would be — not even close.

Architecting around type-erasure

Since type-erasure is actually a huge pain — pretty often — I eventually let go of my indignation and started to think about solving my problem from a fundamentally different perspective. I asked myself whether I really needed type-erasure at all.

Turns out, if you rethink the way you architect your classes, there's often better ways around this problem.

In my original example, the thing I really wanted was to have an array of debts. And I can in fact still have this by having concrete debt types conform to the Debt protocol. We just need to get rid of the associatedtype declaration within Debt.

But if we do that, how can we have different types of interest objects to encapsulate the various kinds of calculations while maintaining access to their type information?

One way is with enums.

We can keep our Interest protocol, but require an additional type variable of type enum.

protocol Interest {
    
    var type: InterestType { get }
    
    var amount: Decimal { get }
    
    mutating func calculateInterest(from principal: Decimal, ...)
}

enum InterestType {
    case apr
        ...
}

Then we have concrete implementations of Interest like so:

struct APR: Interest {
    
    let type = InterestType.apr
    
    var amount: Decimal
    
    mutating func calculateInterest(from principal: Decimal) {
        self.amount = 100
    }
}

And we can even ensure our CreditCardDebt necessarily uses the APR type in our initializer:

struct CreditCardDebt {
    
    let amount: Decimal
    
    let interest: Interest
    
    init(amount: Decimal) {
        self.amount = amount
        self.interest = APR()
    }
}

Sure, it feels a little redundant to have an enum type and a concrete interest type, and I can't helped but be irked by it, but it's a much lower hassle work-around than dealing with the complexities of type-erasure.

More often than not, there's probably some way you can redesign your protocols and structs to avoid type erasure altogether.

Discussion

Categories
Share Article

Continue Reading