What's wrong with enums?
TL;DR
It’s all about imperative conditions (enums just enforce them) vs. object-oriented design.
Enums are data. By introducing them, you say: I emit the data, and I need to process it in every place where it is used. I will suffer from endlessly expanding conditional checks every time a new data definition comes.
Protocols are behaviors. By introducing them, you say: I want it to do something, but I don’t care how. Once I define inputs and outputs, it’s up to the implementation to fulfill the contract.
So, choose your parenting style: authoritarian or indulgent. Both have pros and cons, but avoid extremes to prevent causing trauma to your children. I presume that with each child, you become a more relaxed parent.
Personal note
I suffer from self-discovered and self-diagnosed Conditional Statement Aversion.
It’s a serious condition, and I even have it on my CV. It is triggered by any use of if-else
or switch
statements in the code. So, basically, if you want my attention in a PR, just include it, and I won’t be able to pass it without trying to remove or simplify it.
Hence this document—I’m trying to ease my pain.
It might sound silly, but that condition has deeper roots, actually. It’s not just about conditionals; it’s more about how to build object-oriented software correctly, which can also be easily transposed to the “Tell, Not Ask” rule as well.
So, let’s dive in if TL;DR was not enough! Let me tell you what’s wrong with enums.
Enums
Enums are a valuable tool in Swift. They provide a means of defining a type with a finite set of values, offering a level of safety and clarity in our programs. Swift also makes them very powerful; they can represent errors, handle associated data, contain functions, etc.
And for this reason, the saying “just because you can, doesn’t mean you should,” holds significant weight when considering the use of enums. They are powerful, but there are times when enums might not be the best choice.
Especially in the dynamic and ever-changing landscape of software development—where the only constant is change—enums can inadvertently couple unrelated concepts, creating dependencies that may be difficult to decouple as requirements evolve.
While enums might be useful in the initial phase of code design as they help us understand the problem and make it work, they might not be ideal as the codebase grows and evolves. They’re great for creating something quickly, but as the software scales and becomes more complex, the limitations of enums can become apparent.
Protocols
Swift protocols are essentially contracts that define a blueprint of methods, properties, and other requirements. They offer a means to define behaviors and relationships in a flexible way. Unlike Enums, they don’t represent a closed set of values but rather provide a contract for certain behaviors.
Let’s illustrate this with an example. Suppose we’re building an app where we have different routes to navigate through screens. We may be tempted to use an Enum to represent these routes:
enum AppRoute {
case home
case profile(userId: Int)
case settings
}
func navigateTo(route: AppRoute) {
switch route {
case .home:
// Navigate to home
case .profile(let userId):
// Navigate to profile with userId
case .settings:
// Navigate to settings
}
}
This approach works fine until we have a new screen that takes additional parameters. Every time a new route is introduced, or parameters change, we’d need to revisit our navigateTo(route:)
function. Plus, it couples unrelated routes together, creating dependencies that can make our code harder to manage.
On the other hand, using protocols, we can make our routes more flexible and decoupled:
protocol AppRoute {
func navigate()
}
struct HomeRoute: AppRoute {
func navigate() {
// Navigate to home
}
}
struct ProfileRoute: AppRoute {
let userId: Int
func navigate() {
// Navigate to profile with userId
}
}
struct SettingsRoute: AppRoute {
func navigate() {
// Navigate to settings
}
}
func navigateTo(route: AppRoute) {
route.navigate()
}
Now, each route is a struct that conforms to the AppRoute
protocol. If a new route is introduced, we just need to create a new struct that conforms to AppRoute
. The navigateTo(route:)
function doesn’t need to change because it operates on the AppRoute
protocol, not a specific enum. This approach decouples the routes and provides flexibility.
Imagine adding a new route - SearchRoute
, that requires a query parameter:
struct SearchRoute: AppRoute {
let query: String
func navigate() {
// Navigate to search with query
}
}
let searchRoute = SearchRoute(query: "Swift Protocols")
navigateTo(route: searchRoute)
In this case, we’ve simply created a new struct, SearchRoute
, that conforms to AppRoute
. Our navigateTo(route:)
function doesn’t need to change.
Errors as enums
Errors as enums with internal conditionals are strongly baked into daily Swift practices. So, challenging that paradigm is like battling windmills, but I will do it anyway.
Let’s use this snippet for the further discussions:
enum LeagueError: Error, CustomStringConvertible {
case fileNotFound(name: String, path: String)
case invalidData(url: URL)
case decodingFailed(error: Error)
var description: String {
switch self {
case .fileNotFound(let name, let path):
"File `\(name).json` could not be found at path: \(path)"
case .invalidData(let url):
"Failed to load data from URL: \(url)"
case .decodingFailed(let error):
"Failed to decode JSON: \(error.localizedDescription)"
}
}
}
That small piece of code with conditions to unpack some hidden rules from a type with a closed set of variants breaks several principles of SOLID and OOP.
- Single Responsibility Principle. Because you define one type with a closed set of variants that is expected to be used in different, potentially unrelated places, it unnecessarily couples them all together with a single type.
- Open-Closed Principle. There are actually three independent issues here, but they are all related to OCP:
- Once you need to add another error (variant/case), you can’t add it freely as an independent one somewhere in the code—you have to change the existing enum. And what if someone from outside of the team or even the organization wants to do it? Not a chance! Enums are not extensible by design.
- When you need to handle a property other than
description
, you need to add another switch statement and implement it for all cases (or cheat and create adefault:
case). This code duplication can quickly accumulate. - Dedicated types (structs) are much simpler to extend with specific logic if there’s more than one attribute to add or some specific logic (Example:
FileOperation
inFileNotFoundError
below).
- Tell, Don’t Ask. OOP follows this principle a lot. You should instruct the object to perform an action rather than inquire about its state and then decide what to do (in this case, inquiring the data to get the
description
).
There’s also good discussion on Swift Forum about it, which can be summarized in the following points:
- Adding a new
case
is a source-breaking change for clients using exhaustive switching - Adding associated values to an existing
case
is an API-breaking change - Adding extra fields is cumbersome compared to structs
Typed throws in Swift 6
Also in Swift 6, it is possible to use typed throws, but I don’t think an enum value is valid to use in that case, since it must be a specific type.
protocol LeagueError: Error, CustomStringConvertible {}
struct InvalidDataError: LeagueError {
let url: URL
var description: String {
"Failed to load data from URL: \(url)"
}
}
struct DecodingFailedError: LeagueError {
let error: Error
var description: String {
"Failed to decode JSON: \(error.localizedDescription)"
}
}
struct FileNotFoundError: LeagueError {
let name: String
let path: String
let operations: FileOperation
struct FileOperation: OptionSet {
let rawValue: Int
static let read = FileOperation(rawValue: 1 << 0)
static let write = FileOperation(rawValue: 1 << 1)
static let delete = FileOperation(rawValue: 1 << 2)
static let move = FileOperation(rawValue: 1 << 3)
static let copy = FileOperation(rawValue: 1 << 4)
static let readWrite: FileOperation = [.read, .write]
static let all: FileOperation = [.read, .write, .delete, .move, .copy]
}
init(name: String, path: String, operations: FileOperation = .read) {
self.name = name
self.path = path
self.operations = operations
}
var description: String {
"File `\(name).json` could not be found at path: \(path) during \(operations)"
}
}
Where do enums shine?
While we’ve discussed the pitfalls of enums, it’s important to note that they’re not inherently bad. Enums excel in representing a well-defined set of related values. They can be incredibly useful for representing a fixed set of options, such as API data responses:
enum APIResponse {
case success(data: Data)
case failure(error: Error)
}
They can also be handy for tasks like filtering data:
enum FilterOption {
case date
case name
case location
}
func filter(by option: FilterOption) -> APIResponse {
let endpoint = ProductsEndpoint(filter: option)
return apiClient.list(from: endpoint)
}
In these scenarios, enums provide a closed set of values that makes sense for the use case and adds clarity and safety to the code.
Conclusion
Enums in Swift represent a finite set of conditions or states. Each time an enum is used, the condition it represents needs to be explicitly handled. This often results in switch statements to manage each case, necessitating additional logic whenever the enum is employed. Enums inherently don’t carry behavior but rather represent conditions that need careful handling.
Protocols, contrastingly, define a contract of methods and properties. When a type adopts a protocol, it implements these methods and properties, which encapsulate specific behaviors. Thus, any type adhering to the protocol can be used seamlessly without checking for conditions, as it’s guaranteed to have the required behavior. This means that protocols, unlike enums, enable types to “just act” in accordance with the specified contract.
As always in software development, it’s about using the right tool for the right job.
References
You can get a much better explanation of “tell, not ask” from Sandi here:
- Nothing is something - Not everyone likes Ruby, but it’s actually an easy-to-understand and very powerful OO language, so that should not stop you from watching it.
- 99 Bottles of OOP - 2nd Edition – Her wonderful book about writing a 99-bottle poem with a well-designed piece of software step by step. No Swift, no Kotlin, only Javascript, PHP, or Ruby. Still, very good.
- Tell, don’t ask
- Phantom types in Swift