bauke.dev

What's wrong with Swift's enums?

· Daniel Bauke

TL;DR

It’s all about the choice between imperative conditions (enums just enforce them) and object-oriented design (here: protocols).

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 handle 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, think of it like choosing a parenting style: strict control versus granting independence. Supervising every single action will eventually exhaust you and rather not help your kid, while setting clear boundaries and trusting things to work themselves out lets your system grow naturally. And much like raising a family, with each new project you will probably tend to become a calmer, more relaxed parent who learns when to let go.

Personal note

I suffer from self-discovered and self-diagnosed Conditional Statement Aversion.

It’s a serious condition, since it is triggered by any use of if-else or switch statements in the code. So, basically, if you want my attention in any code review, just include it, and I won’t be able to pass it without trying to remove or simplify it.

Hence this post: I’m trying to ease my pain.

I know, it sounds silly, but it has deeper roots. It’s not just about conditionals, but about how to build object-oriented software correctly, which can also be easily transposed to the “Tell, Not Ask” rule.

So, let’s dive in! Let me tell you what is 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 code. Swift also makes them very powerful; they can represent errors, handle associated data, and can even be riddled with their own functions.

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 can become a bottleneck 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 and even create unexpected breaking changes.

Protocols

In Swift, protocols act as blueprints that define what a conforming type can do. Rather than holding concrete data, a protocol outlines requirements that any adopting type must fulfill: methods, properties, initializers, including inheritance from other protocols. Unlike enums, they don’t contain closed set of values , but provide a contract for certain behaviors where it doesn’t matter how it is implemented.

Let’s illustrate it with an example. Suppose we’re building an app where we have different routes to navigate through screens, and some of them can also be shared as a link. We may be tempted to use an Enum to represent these routes:

enum AppRoute {
    case home
    case profile(userId: Int)
    case settings(section: String?)

    var shareURL: URL? {
        switch self {
        case .profile(let userId):
            URL(string: "https://example.com/users/\(userId)")
        case .home, .settings:
            nil
        }
    }
}

func navigateTo(route: AppRoute) {
    switch route {
    case .home:
        // Pop to the root screen
    case .profile(let userId):
        // Push the profile screen for userId
    case .settings(let section):
        // Present settings modally, scrolled to section if given
    }
}

func share(route: AppRoute) {
    guard let url = route.shareURL else { return }
    // Present the share sheet with url
}

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 both navigateTo(route:) and shareURL. Plus, it couples unrelated routes together, creating dependencies that can make our code harder to manage. Every case also has to answer every question: home and settings can’t be shared, so shareURL becomes optional and share(route:) has to check it at runtime.

Using protocols we can make our routes more flexible and decoupled:

protocol AppRoutable {
    func navigate()
}

protocol Shareable {
    var shareURL: URL { get }
}

struct HomeRoute: AppRoutable {
    func navigate() {
        // Pop to the root screen
    }
}

struct ProfileRoute: AppRoutable, Shareable {
    let userId: Int

    var shareURL: URL {
        URL(string: "https://example.com/users/\(userId)")!
    }

    func navigate() {
        // Push the profile screen for userId
    }
}

struct SettingsRoute: AppRoutable {
    let section: String?

    func navigate() {
        // Present settings modally, scrolled to section if given
    }
}

func navigateTo(route: any AppRoutable) {
    route.navigate()
}

func share(item: any Shareable) {
    // Present the share sheet with item.shareURL
}

Now, each route is a struct that conforms to the AppRoutable protocol. AppRoutable does one thing: it navigates. It knows nothing about sharing. Sharing lives in a separate, small Shareable protocol, adopted only by routes that can actually be shared. That’s the Interface Segregation Principle: HomeRoute and SettingsRoute don’t have to pretend they have a URL, and share(item:) accepts only things that can be shared. The compiler now checks what the enum version had to check at runtime.

Each route also carries only what it needs. HomeRoute has no properties at all, ProfileRoute has a userId and a share link, and SettingsRoute has an optional section. Each route is responsible only for its own screen, so changing how settings open touches SettingsRoute and nothing else.

If a new route is introduced, we just need to create a new struct that conforms to AppRoutable. The navigateTo(route:) function doesn’t need to change because it operates on the AppRoutable protocol, not a specific enum. This approach decouples the routes and provides flexibility.

Imagine adding a new route - SearchRoute, that requires a query parameter and can be shared as well:

struct SearchRoute: AppRoutable, Shareable {
    let query: String

    var shareURL: URL {
        URL(string: "https://example.com/search")!
            .appending(queryItems: [URLQueryItem(name: "q", value: trimmedQuery)])
    }

    func navigate() {
        // Push the search results for trimmedQuery
    }

    private var trimmedQuery: String {
        query.trimmingCharacters(in: .whitespacesAndNewlines)
    }
}

let searchRoute = SearchRoute(query: "Swift Protocols")
navigateTo(route: searchRoute)
share(item: searchRoute)

In this case, we’ve simply created a new struct, SearchRoute, that conforms to AppRoutable and Shareable. It even has its own private helper, trimmedQuery, that no other route or caller needs to know about. Neither navigateTo(route:) nor share(item:) needs to change. With the enum, the same addition means a new case and an update to every switch: navigateTo(route:), shareURL, and any other one added since.

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 AppError: 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

The usual answer to the first point is @unknown default. It softens the break: a missing case becomes a warning instead of an error. But the code still compiles, and at runtime the new case falls into a catch-all branch that knows nothing about it. You trade a compile error for a silent fallback, and every switch still needs a fix once someone reads the warnings.

Errors as structs

Here’s the same set of errors, split into one struct per error behind a shared protocol:

protocol AppError: Error, CustomStringConvertible {}

struct InvalidDataError: AppError {
    let url: URL

    var description: String {
        "Failed to load data from URL: \(url)"
    }
}

struct DecodingFailedError: AppError {
    let error: Error

    var description: String {
        "Failed to decode JSON: \(error.localizedDescription)"
    }
}

struct FileNotFoundError: AppError {
    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)
    }

    let name: String
    let path: String
    var operations: FileOperation = .read

    var description: String {
        "File `\(name).json` could not be found at path: \(path) during \(operations)"
    }
}

Limitations in errors as structs

One limitation of typed throws in Swift 6: a function can name only one error type. throws(FileNotFoundError) works fine, but you can’t write throws(any AppError). A function that can fail in several ways falls back to plain throws, and the caller has to cast the error back with as? any AppError. An enum doesn’t have this problem, since all its cases share one type.

Where typed throws really helps is generic code. A function that takes a closure can declare throws(E) and pass on the closure’s error with its exact type, the same way Result<Success, Failure> does. That works equally well with enums and structs.

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, ideally non-public and on a small scale. 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 turn your code into a helicopter parent. Every call site needs a switch that asks the value “which case are you?” before it can do anything. The logic for a single case ends up scattered across navigateTo(route:), shareURL, description, and every other switch in the codebase. Add a case, and each of those switches has to change. If the enum is public, that’s a source-breaking change for every client.

Protocols let you step back. The protocol defines the contract, each conforming type implements it, and the caller just calls route.navigate() without knowing which type it holds. A new route is a new struct, and no existing code has to change. That’s the Open-Closed Principle at work. Small protocols like Shareable also move checks from runtime to compile time: there’s no optional shareURL to unwrap, because only types that can be shared conform to it.

Enums still have their place, the same way a toddler next to a busy road still needs a hand to hold. Take success and failure: a textbook closed set, where an exhaustive switch is exactly what you want. Except it almost never stays at two. Soon someone needs .cancelled, because a user leaving the screen shouldn’t trigger an error alert. Then comes .notModified for a cached response, and .unauthorized to send the user back to login. Every new case lands in every switch, and every switch grows its own branch of logic. The toddler grows up, and holding hands stops working.

Keep the enum only when the set is truly closed and will stay that way. Everywhere else, stop asking and start telling.

References