Maximizing enum usage in Swift is compelling because it moves beyond treating them as simple lists of constants and showcases their power as first-class citizens. This approach not only makes a codebase more expressive and readable but also teaches developers how to leverage Swift’s robust type system to model complex business logic with minimal overhead.
default and @unknown default
To set the stage, let’s warm up by distinguishing between default and @unknown default. While both serve as a safety net for unlisted cases in a switch statement, they play very different roles.
The default Case
The default keyword is a «catch-all» that silences the compiler’s requirement for exhaustiveness. It tells Swift: «I don’t care what else might be in this enum; treat everything else the same way.»
When to use it: Use
defaultwhen you genuinely want to group a large number of existing cases together, or when you are switching over types that aren’t enums (like Strings or Ints).The Risk: If you add a new case to your enum later, the compiler will not warn you. The new case will simply fall into the
defaultblock, which can lead to «silent» logic bugs.
enum UserRole {
case admin, editor, viewer, guest
}
let role = UserRole.guest
switch role {
case .admin:
print("Full Access")
default:
// Covers editor, viewer, and guest identically
print("Limited Access")
} @unknown
@unknown default is a «cautionary» catch-all. It handles any cases that aren’t explicitly defined, but it triggers a compiler warning if you haven’t accounted for all known cases.
When to use it: Use it when dealing with enums that might change in the future (especially those from Apple’s frameworks like
UNAuthorizationStatus).The Benefit: It provides the best of both worlds: your code still compiles if a new case is added (preventing a crash), but the compiler warns you that you need to go back and handle that specific new case properly.
import NotificationCenter
let status: UNAuthorizationStatus = .authorized
switch status {
case .authorized:
print("Authorized")
case .denied:
print("Denied")
case .notDetermined:
print("Not Determined")
case .provisional, .ephemeral:
print("Trial/Limited")
@unknown default:
// If Apple adds a new status in iOS 20, this code still runs,
// but the compiler will show a warning saying:
// "Switch implies matching of 'newFutureCase'..."
print("Handle unknown future state")
} Beyond Swift.Result switching...
While Swift.Result is typically handled by switching over basic .success and .failure cases, we can unlock more power by inspecting the data within those cases. In the following example, we take it a step further: we handle the .failure state while splitting the .success state into two distinct logic paths—one for a populated list of items and another for when no data is received.
Here is how you can implement this using pattern matching to keep your code clean and expressive:
public func processFeedRequest() {
fetchFeed() { result in
switch result {
case .success(.some(let feedResponse)):
print("Feed Response: \(String(describing: feedResponse))")
case .success(.none):
print("No feed response")
case .failure(let error):
print("Error: \(error)")
}
}
} In Swift, an Optional is actually an enum under the hood! When you write FeedResponse?, the compiler sees it as Optional<FeedResponse>.
.none: This is exactly the same asnil. It represents the absence of a value..some(let value): This represents the presence of a value. Thevalueis «wrapped» inside the enum, and thelet feedResponsesyntax «unwraps» it so you can use it directly.
Switching Tuples
Actually, you have been able to evaluate tuples in Swift’s switch statements since Swift 1.0, released in 2014.
Swift was designed from the ground up with Pattern Matching as a core feature. This allows the switch statement to decompose complex data structures, like tuples and enums with associated values, very easily.
You can combine an enum and another variable into a tuple right inside the switch statement. This is incredibly useful for conditional logic that depends on two different factors.
Using tuples in your enum logic is a «pro move» because it allows you to avoid nested if statements. Instead of checking the enum and then checking a secondary condition, you can express the entire business rule in a single, readable line.
func testExample() throws {
let expectedResult: ResultImageURL = .success(aFeed)
let exp = expectation(description: "Wait for cache retrieval")
fetchFeed { retrievedResult in
switch (expectedResult, retrievedResult) {
case (.success(.none), .success(.none)),
(.failure, .failure):
break
case let (.success(.some(expected)), .success(.some(retrieved))):
XCTAssertEqual(retrieved.feeds, expected.feeds)
XCTAssertEqual(retrieved.timestamp, expected.timestamp)
default:
XCTFail("Expected to retrieve \(expectedResult), got \(retrievedResult) instead")
}
exp.fulfill()
}
wait(for: [exp], timeout: 1.0)
} This asynchronous XCTest verifies that fetchFeed completes with the expected Result by waiting on an expectation, comparing the retrieved result against a predefined expected one, and asserting correct behavior for all valid cases: both being failures, both being successful with no cached value, or both being successful with a cached value whose contents (feeds and timestamp) are validated for equality; any mismatch between expected and retrieved outcomes causes the test to fail explicitly, ensuring both correctness of the async flow and the integrity of the returned data.
Of course, this function could be refactored into a helper not only to validate the happy-path scenario, but also to cover the empty and error cases as well.
where clause in case
In Swift, a where clause in a case branch adds an additional boolean condition that must be satisfied for that pattern to match, allowing you to refine or differentiate the same enum case based on contextual rules (such as values, ranges, or predicates) without introducing nested if statements; this makes the control flow more declarative, improves readability, and keeps the conditional logic tightly coupled to the pattern being matched.
enum NetworkResult {
case success(Data, HTTPURLResponse)
case failure(Error)
var isValid: Bool {
switch self {
case let .success(_, response)
where (200..<300).contains(response.statusCode):
return true
case let .success(_, response)
where response.statusCode == 401:
return false
case .failure:
return false
default:
return false
}
}
} This code defines an enum NetworkResult with a computed property isValid that uses a switch on self to derive a Boolean value based on both the enum case and associated values: when the result is .success, the switch further refines the match using where clauses to inspect the HTTP status code, returning true for successful 2xx responses and false for an unauthorized 401, while any .failure or other non-matching success cases also return false, making isValid a calculated variable that encapsulates response-validation logic directly within the enum.
Conclusions
Switch enum structures in Swift are more than a control-flow construct that selects and executes a specific block of code based on matching a value against a set of predefined patterns or cases. In this post, I have presented a few examples to illustrate that.
You can find the source code for this example in the following GitHub repository.
References
- Control flow / Switch
Swift.org