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.

  1. 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.
  2. 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 a default: 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 in FileNotFoundError below).
  3. 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:

  1. Adding a new case is a source-breaking change for clients using exhaustive switching
  2. Adding associated values to an existing case is an API-breaking change
  3. 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: