Greetings, traveler!
Enums are one of the most useful modeling tools in Swift. We use them for screen states, navigation routes, user actions, errors, commands, feature flags, and many other parts of iOS applications.
A lot of these models have something in common: they describe a closed set of possible values. A screen can be loading, loaded, or failed. A route can open a list, details, or settings. An action can represent view appearance, refresh, or item selection. These concepts map naturally to enums.
Swift also allows us to describe shared capabilities with protocols. Sometimes we want a protocol to say: any type that conforms to this contract must provide a specific static value or a static factory-like method.
In modern Swift, enum cases can satisfy compatible static protocol requirements. A case without associated values can satisfy a static property requirement. A case with associated values can satisfy a static method requirement returning Self.
The old boilerplate
Imagine we have a protocol that describes a route contract:
protocol ProfileRouting {
static var root: Self { get }
static func details(id: String) -> Self
static var settings: Self { get }
}This protocol says that any route type should be able to create three destinations: root, details, and settings.
A natural implementation would be an enum:
enum ProfileRoute {
case root
case details(id: String)
case settings
}The enum already has everything the protocol asks for. ProfileRoute.root gives us a ProfileRoute. ProfileRoute.details(id:) creates a ProfileRoute from a String. ProfileRoute.settings gives us another ProfileRoute.
In older Swift versions, the compiler did not treat enum cases as implementations of these protocol requirements. You had to add forwarding members manually:
enum ProfileRoute: ProfileRouting {
case rootRoute
case detailsRoute(id: String)
case settingsRoute
static var root: ProfileRoute {
.rootRoute
}
static func details(id: String) -> ProfileRoute {
.detailsRoute(id: id)
}
static var settings: ProfileRoute {
.settingsRoute
}
}This code works, but it adds noise. The enum cases already express the domain, while the static members mostly repeat the same information for the compiler. Modern Swift removes this extra layer.
How enum cases satisfy protocol requirements
With enum cases as protocol witnesses, the same model can be written directly:
protocol ProfileRouting {
static var root: Self { get }
static func details(id: String) -> Self
static var settings: Self { get }
}
enum ProfileRoute: ProfileRouting {
case root
case details(id: String)
case settings
}The mapping is simple.
A case without associated values:
case rootcan satisfy a requirement like this:
static var root: Self { get }A case with associated values:
case details(id: String)can satisfy a requirement like this:
static func details(id: String) -> SelfThe enum case becomes the protocol witness because its shape matches the requirement.
This makes enums feel more consistent with how we already use them at call sites. When we write .root, we are creating a value. When we write .details(id: "42"), we are calling something that behaves like a factory function for the enum value.
Example: screen actions
Many iOS architectures use actions to describe what happened on a screen. A user tapped a button. The view appeared. A refresh was requested. An item was selected.
protocol ListAction {
static var viewDidAppear: Self { get }
static var refresh: Self { get }
static func selectItem(id: String) -> Self
}A concrete screen can model these actions with an enum:
enum AccountsAction: ListAction {
case viewDidAppear
case refresh
case selectItem(id: String)
}Then a generic view model, reducer, or helper can work with the action contract:
final class ListViewModel<Action: ListAction> {
func send(_ action: Action) {
// Handle action
}
func onAppear() {
send(.viewDidAppear)
}
func onRefresh() {
send(.refresh)
}
func onSelection(id: String) {
send(.selectItem(id: id))
}
}This pattern works well when several screens share a common interaction model, while each screen keeps its own concrete action enum.
For a single screen with no shared behavior, the protocol may add unnecessary abstraction. The feature helps when there is a real contract to express.
Example: view state
A common pattern in iOS applications is to have many screens with similar loading behavior.
Most of them can be represented with the same basic states:
protocol LoadableState {
associatedtype Value
static var idle: Self { get }
static var loading: Self { get }
static func loaded(_ value: Value) -> Self
static func failed(_ error: Error) -> Self
}This protocol describes only the shared part of the state machine. It does not try to describe every possible state that every screen may need.
Now we can create a small reusable base class that knows how to perform loading:
class LoadingViewModel<State: LoadableState> {
private(set) var state: State = .idle
func load(
operation: () async throws -> State.Value
) async {
state = .loading
do {
let value = try await operation()
state = .loaded(value)
} catch {
state = .failed(error)
}
}
}The base view model knows nothing about accounts, empty screens, permissions, or feature-specific UI. It only knows the common loading flow.
A concrete screen can still have a richer state enum:
struct Account {
let id: String
let title: String
}
enum AccountsState: LoadableState {
case idle
case loading
case loaded([Account])
case failed(Error)
case empty
}The first four cases satisfy the protocol requirements. The empty case belongs only to the accounts screen.
Now the concrete view model can reuse the base loading behavior and still add its own domain-specific logic:
final class AccountsViewModel: LoadingViewModel<AccountsState> {
func loadAccounts() async {
await load {
try await fetchAccounts()
}
if case let .loaded(accounts) = state, accounts.isEmpty {
state = .empty
}
}
private func fetchAccounts() async throws -> [Account] {
// Load accounts from API
}
}
This is the important part: the reusable layer depends only on the protocol, while the concrete implementation remains free to model additional states.
The protocol gives shared code a small common language:
.idle
.loading
.loaded(value)
.failed(error)The concrete enum can extend that language with screen-specific cases:
.emptyAccountsState does not need to write forwarding properties or methods just to satisfy LoadableState. The enum cases already match the protocol requirements.
Example: typed errors
An error enum often represents a closed set of failures. A protocol can describe failures that some generic logic knows how to produce.
protocol NetworkFailure: Error {
static var noInternet: Self { get }
static var unauthorized: Self { get }
static func serverError(code: Int) -> Self
}A concrete error enum can conform without wrappers:
enum APIError: NetworkFailure {
case noInternet
case unauthorized
case serverError(code: Int)
}Now shared mapping logic can create the correct error type:
func mapStatusCode<Failure: NetworkFailure>(_ statusCode: Int) -> Failure? {
switch statusCode {
case 200..<300:
return nil
case 401:
return .unauthorized
default:
return .serverError(code: statusCode)
}
}This can be useful when different modules define their own error enums, while some networking or infrastructure layer works with a common error contract.
Example: default values
A common example is a default value contract:
protocol Defaultable {
static var defaultValue: Self { get }
}Simple types can conform with static properties:
extension Int: Defaultable {
static var defaultValue: Int { 0 }
}
extension Array: Defaultable {
static var defaultValue: Array { [] }
}Enums can also participate:
enum Padding: Defaultable {
case pixels(Int)
case centimeters(Int)
case defaultValue
}Here, case defaultValue satisfies static var defaultValue: Self.
When this feature works well
Enum cases as protocol witnesses are most useful when three things are true.
First, the model is naturally a closed set of values. Routes, actions, states, errors, and commands usually fit this shape well.
Second, there is shared code that benefits from a protocol. A generic coordinator, reducer, mapper, factory, or state transformer can depend on the protocol instead of a concrete enum.
Third, the protocol requirements match the language of the domain. If the protocol says static var loading, and the enum has case loading, the code reads naturally.
Good candidates include:
enum Route
enum Action
enum State
enum Error
enum Command
enum Destination
enum FeatureThese models often appear in iOS applications, especially in modular codebases where features need local domain types while shared infrastructure needs common contracts.
When to avoid it
This feature removes boilerplate, but it does not make every enum need a protocol.
If a protocol has only one conforming type and no generic code depends on it, the abstraction may add more cost than value.
For example, this may be unnecessary:
protocol SettingsActionProtocol {
static var viewDidAppear: Self { get }
static var save: Self { get }
}
enum SettingsAction: SettingsActionProtocol {
case viewDidAppear
case save
}If SettingsAction is used only inside one view model, the protocol gives little benefit. The enum alone is enough. You aren’t gonna need it!
There is also a scaling concern. Enums work well for closed vocabularies. They can become uncomfortable when the list grows across many teams, modules, or feature areas.
Analytics events are a good example. A small feature-level enum can be clean:
protocol ProfileAnalyticsEvent {
static var screenOpened: Self { get }
static func buttonTapped(name: String) -> Self
}
enum ProfileEvent: ProfileAnalyticsEvent {
case screenOpened
case buttonTapped(name: String)
}A single global enum with hundreds of events can become hard to maintain:
enum AnalyticsEvent {
case profileOpened
case profileEditTapped
case profileAvatarTapped
case accountsOpened
case accountSelected(id: String)
case transferStarted
case transferConfirmed
case cardDetailsOpened
// many more cases over time
}In that situation, smaller event types, structs, namespaced builders, or module-local models may scale better.
Conclusion
Enum cases as protocol witnesses make Swift enums more expressive in protocol-oriented code.
A case without associated values can represent a static value requirement. A case with associated values can represent a static factory-like requirement. This matches how enums already feel when we use them.
The feature is especially useful for routes, actions, states, errors, and other closed domain models. It keeps the enum as the main representation and removes forwarding code that only exists to satisfy the compiler.
The best use cases are the quiet ones: less boilerplate, clearer domain names, and protocols that describe real shared behavior.
