Over the course of this week, some bloggers have written about a problem — analytics events — a proposed multiple solutions to for this problem: enums, structs, and protocols. I also chimed in with a cheeky post about using inheritance and subclassing to solve this problem. I happened to have a post in my drafts about a few problems very similar to the analytics events that John proposed, and I think there’s no better time to post it than now. Without further ado:
If a Swift programmer wants to bring two values together, like an Int
and a String
, they have two options. They can either use a “product type”, the construction of which requires you to have both values; or they can use a “sum type”, the construction of which requires you to have one value or the other.
Swift is bountiful, however, and has 3 ways to express a product type and 3 ways to express a sum type. While plenty of ink has been spilled about when to use a struct, a class, or a tuple, there isn’t as much guidance on when to how to choose between an enum, protocol, or subclass. I personally haven’t found subclassing all that useful in Swift, as my post from early today implies, since protocols and enums are so powerful. In my year and a half writing Swift, I haven’t written an intentionally subclassable thing. So, for the purpose of this discussion, I’ll broadly ignore subclassing.
Enums and protocols vary in a few key ways:
- Completeness. Every case for an enum has to be declared when you declare the enum itself. You can’t add more cases in an extension or in a different module. On the other hand, protocols allow you to add new conformances anywhere in the codebase, even adding them across module boundaries. If that kind of flexibility is required, you have to use protocols.
- Destructuring. To get data out of an enum, you have to pattern match. This requires either a
switch
or anif case
statement. These are a bit unwieldy to use (who can even remember thecase let
syntax?) but are better than adding a method in the protocol for each type of variant data or casting to the concrete type and accessing it directly.
Based on these differences, my hard-won advice on this is to use enums when you care which case you have, and use protocols you don’t — when all the cases can be treated the same.
I want to take a look at two examples, and decide whether enums or protocols are a better fit.
Errors
Errors are frankly a bit of a toss up. On the one hand, errors are absolutely a situation where we care which case we have; also, catch
pattern matching is very powerful when it comes to enums. Let’s take a look at an example error.
enum NetworkError: Error {
case noInternet
case statusCode(Int)
case apiError(message: String)
}
If something throws an error, we can switch in the catch
statement:
do {
// something that produces a network error
} catch NetworkError.noInternet {
// handle no internet
} catch let NetworkError.statusCode(statusCode) {
// use `statusCode` here to handle this error
} catch {
// catch any other errors
}
While this matching syntax is really nice, using enums for your errors comes with a few downsides. First, it hamstrings you if you’re maintaining an external library. If you, the writer of a library, add a new enum case to an error, and a consumer of your library updates, they’ll have to change any code which exhaustively switches on your error enum. While this is desirable in some cases, it means that, per semantic versioning, you’ll have to bump the major version number of your library. This makes adding a new enum case in an external libraries is currently a breaking change. Swift 5 should be bringing nonexhaustive enums, which will ameliorate this problem.
The second issue with enums is that this type gets muddy fast. Let’s say you want to provide conformance to the LocalizedError
protocol to get good bridging to NSError
. Because each case has its own scheme for how to convert its associated data into the userInfo
dictionary, you’ll end up with a giant switch statement.
When examining this error, it becomes apparent that the cases of the enum don’t really have anything to do with each other. NetworkError
is really only acting as a convenient namespace for these errors.
One approach here is to just use structs instead.
struct NoInternetError: Error { }
struct StatusCodeError: Error {
let statusCode: Int
}
struct APIError: Error {
let message: String
}
If each of these network error cases become their own type, we get a few cool things: a nice breakdown between types, custom initializers, easier conformance to things like LocalizedError
, and it’s just as easy to pattern match:
do {
// something that produces a network error
} catch let error as StatusCodeError {
} catch let error as NoInternetError {
} catch {
// catch any other errors
}
You could even make all of the different structs conform to a protocol, called NetworkError
. However, there is one downside to making each error case into its own type. Swift’s generic system requires a concrete type for all generic parameters; you can’t use a protocol there. Put another way, if the type’s signature is Result<T, E: Error>
, you have to use an error enum. If the type’s signature is Result<T>
, then the error is untyped and you can use anything that conforms to Swift.Error
.
API Endpoints
Because talking to an API has a fixed number of endpoints, it can be tempting to model each of those endpoints as an enum case. However, all requests more or less have the same data: method, path, parameters, etc.
If you implement your network requests as an enum, you’ll have a methods with giant switch statements in them — one each for the method, path, and so on. If you think about how this data is broken up, it’s exactly flipped. When you look at a chunk of code, do you want to see all of the paths for all the endpoints in one place, or do you want to see the method, path, parameters, and so on for one request in one place? How do you want to colocate your data? For me, I definitely want to have all the data for each request in one place. This is the locality problem that Matt mentioned in his post.
Protocols shine when the interface to all of the different cases are similar, so they work well for describing network requests.
protocol Request {
var method: Method { get }
var path: String { get }
// etc
}
Now, you can actually conform to this protocol with either a struct or enum (Inception gong), if it does happen to be the right time to use an enum for a subset of your requests. This is Dave’s point about flexibility.
More importantly, however, your network architecture won’t care. It’ll just get an object conforming to the protocol and request the right data from it.
Protocols confer a few other benefits here as well:
- You might find that it makes life easier to conform
URL
to yourRequest
protocol, and you can easily do that. - Associating a type with a protocol is possible, associating a type with an enum case is meaningfully impossible. This means you can get well-typed results back from your network architecture.
-
Implementations of the protocol are so flexible that you can bring your own sub-abstractions as you need to. For example, in the Beacon API, we needed to be able to get Twitter followers and following. These requests are nearly identical in their parameters, results, and everything save for the path.
struct FollowersRequest: TypedRequest { typealias OutputType = IDsResult enum Kind { case followers, following var pathComponent: String { return self == .followers ? "followers" : "friends" } } let path: String init(kind: Kind) { self.path = kind.pathComponent + "/ids.json" } }
Being able to bring your own abstractions to the protocol is just one more reason protocols are the right tool for the job here.
There are other cases that are useful for exploring when to use enums and protocols, but these two I think shine the most light on the problem. Use enums when you care which case you have, and use protocols when you don’t.