Swift enum and modular design

2 minutes read

I’ve been thinking about Swift enums lately, especially how we use them in modular architectures. They’re fantastic until they’re not - and that transition happens quietly.

enum ErrorReason {
    case timeout
    case invalidResponse
    case serverError(Int)
}

This looks innocent enough. Every Swift developer has written something similar. The problem emerges when this enum lives in a shared module and suddenly three different modules need to extend it with their own error cases.

Here’s an alternative approach I’ve been experimenting with:

// Module: Core
struct ErrorReason {
    public let value: String

    public static var unknown = Self("unknown")
}

// Other modules can define their own error reasons
// Module: Subscription
internal extension ErrorReason {
    static var storeKitFailure = Self("storeKitFailure")
}

// Module: Account
internal extension ErrorReason {
    static var invalidAccount = Self("invalidAccount")
}

It’s more verbose, but each module can define its own error types without touching shared code. The tradeoff is losing some of Swift’s pattern matching elegance while gaining modularity and extensibility.

This lets each module contribute types or cases without central coordination. I’ve noticed this same pattern in the Swift StoreKit API, where paymentMode is a struct with static instances rather than an enum.

I’m not suggesting abandoning enums entirely. They’re perfect for truly closed sets - compass directions, HTTP methods, parsing states. The key is recognizing when a set feels closed but might actually need to be open.

The question arises - why not use the Error protocol, or create a protocol and extend it in each module? The challenge there is losing the shared type that groups all error reasons together. Using a struct with static instances maintains a common type while allowing extensions.

This example is part of a larger attempt at having a common error handling strategy across modules while preserving modularity:

public struct ApplicationError: Error, Equatable {
    // Open structure instead of enum
    public let recoveryHint: ApplicationErrorRecoveryHint
    // Open structure instead of enum
    public let reason: ApplicationErrorReason
    public let message: String
    // Open structure instead of enum
    public let origin: ApplicationErrorOrigin
    public let code: Int
    public let file: String
    public let line: UInt
}

The entire application uses ApplicationError across all modules, but each module can define its own reasons, recovery hints, and origins without touching shared code. Cross-cutting concerns are defined in the core module, while feature modules extend them as needed.

This approach trades compile-time exhaustiveness for runtime flexibility. In modular systems, that might be the right tradeoff.