Why enums make code safer
A lot of bugs come from representing state too loosely. A string can hold anything. Three booleans can contradict each other. A half-filled struct can quietly exist in an invalid state. Enums let you say: these are the only valid cases, and if one case needs extra data, here is exactly what it carries.
This is one of Swift’s strongest features because it pushes you toward explicit design. Instead of hoping your app state stays coherent, you encode the rules directly in the type system.
The problem with loose state
Suppose you are modeling a file download. A common first draft looks like this:
struct DownloadState {
var isLoading: Bool
var data: Data?
var errorMessage: String?
}
This looks simple, but it allows a lot of impossible combinations:
Invalid but possible
isLoading == true and data != nil
errorMessage != nil and data != nil
Also possible
isLoading == false, data == nil, and errorMessage == nil
What state is that supposed to mean?
The type does not protect you. It lets you build contradictory or meaningless values, and now every caller has to guess what combination is “actually” valid.
Here is the same idea modeled with an enum:
enum DownloadState {
case idle
case loading
case success(Data)
case failure(String)
}
Now the invalid combinations disappear. A value can only be one case at a time. If it is .success, it has data. If it is .failure, it has an error message. That is a much stronger contract.
Enums with associated values
A basic enum defines a fixed set of cases:
enum Direction {
case north
case south
case east
case west
}
An enum with associated values lets each case carry extra data:
enum PaymentMethod {
case cash
case creditCard(last4: String, network: String)
case applePay(deviceAccountNumber: String)
}
This is where enums stop being “just flags” and become modeling tools. Each case can carry only the data that makes sense for that case.
let method = PaymentMethod.creditCard(last4: "4242", network: "Visa")
To read the data back, you pattern match:
switch method {
case .cash:
print("Paid with cash")
case .creditCard(let last4, let network):
print("Paid with \(network) ending in \(last4)")
case .applePay(let deviceAccountNumber):
print("Paid with Apple Pay: \(deviceAccountNumber)")
}
Modeling network state cleanly
Networking is one of the best places to use enums because request state is naturally mutually exclusive.
enum Loadable<Value> {
case idle
case loading
case loaded(Value)
case failed(Error)
}
This generic enum is reusable across many screens:
struct User: Decodable {
let id: Int
let name: String
}
var userState: Loadable<User> = .idle
And when you render UI:
switch userState {
case .idle:
print("No request started yet")
case .loading:
print("Show spinner")
case .loaded(let user):
print("Render user: \(user.name)")
case .failed(let error):
print("Show error: \(error.localizedDescription)")
}
Compare this with separate booleans like isLoading, hasLoaded, and error. The enum version is easier to reason about and much harder to misuse.
| Approach | Main issue | Enum benefit |
|---|---|---|
| Several booleans | Contradictory combinations are possible | Only one state can exist at once |
| Optional payloads | Callers must guess which values are meaningful | Payload is tied to the correct case |
| Magic strings | Typos and unclear meaning | Compiler-checked cases |
Modeling success and failure explicitly
Swift already includes Result<Success, Failure>, which is a good example of how effective this pattern is:
enum Result<Success, Failure: Error> {
case success(Success)
case failure(Failure)
}
If you are designing your own domain types, you can use the same idea:
enum LoginResult {
case success(userID: Int)
case invalidCredentials
case lockedOut(retryAfterSeconds: Int)
case networkUnavailable
}
That is far clearer than returning a generic string and hoping the caller interprets it properly.
Bool plus separate error text or separate metadata, that is often a sign an enum would be a better fit.
Useful pattern matching techniques
Once you start using associated values, pattern matching becomes part of everyday Swift.
Basic switch matching
switch loginResult {
case .success(let userID):
print("Welcome \(userID)")
case .invalidCredentials:
print("Wrong email or password")
case .lockedOut(let seconds):
print("Try again in \(seconds) seconds")
case .networkUnavailable:
print("Check your connection")
}
if case for a single pattern
if case .lockedOut(let seconds) = loginResult {
print("Locked out for \(seconds) seconds")
}
Matching with conditions
switch loginResult {
case .lockedOut(let seconds) where seconds > 300:
print("Long lockout")
case .lockedOut:
print("Short lockout")
default:
break
}
Extracting values in loops
let events: [LoginResult] = [
.invalidCredentials,
.success(userID: 101),
.lockedOut(retryAfterSeconds: 600)
]
for event in events {
if case .success(let userID) = event {
print("Successful login: \(userID)")
}
}
These patterns make enum-based designs ergonomic, not just safe.
When to use enums with associated values
- Use them when a value can only be in one valid state at a time.
- Use them when each state needs different data.
- Use them when you want exhaustive handling through
switch. - Use them when booleans or strings start creating invalid combinations.
- Use them when you want domain rules to live in the type system instead of in comments.
Great candidates include:
enum APIResponse {
case success(Data)
case unauthorized
case rateLimited(retryAfter: TimeInterval)
case serverError(code: Int)
}
enum MediaItem {
case image(url: URL, altText: String?)
case video(url: URL, duration: TimeInterval)
case audio(url: URL, artist: String)
}
enum CheckoutStep {
case cart
case address(Address)
case payment(PaymentMethod)
case review(OrderSummary)
case completed(orderID: String)
}
Common mistakes
1. Using enums but still keeping redundant flags
enum State {
case loading
case loaded(Data)
case failed(Error)
}
struct ViewModel {
var state: State
var isLoading: Bool // redundant and risky
}
If the enum already models the truth, do not duplicate it in separate properties unless there is a very strong reason.
2. Packing too much into one case
If a single case carries a giant bag of loosely related values, you may just be recreating the same ambiguity in a different shape. Prefer small supporting structs when the payload gets large.
struct FailureContext {
let message: String
let code: Int
let retryable: Bool
}
enum RequestState {
case failed(FailureContext)
}
3. Using raw values when associated values are what you really need
Raw-value enums are useful for fixed representations like strings or integers:
enum HTTPMethod: String {
case get = "GET"
case post = "POST"
}
But they do not replace associated values. If each case needs dynamic data, use associated values instead.
4. Adding a default case too quickly
One of the best parts of switching over enums is exhaustiveness. If you add default too casually, you lose compiler help when new cases are introduced.
switch state {
case .idle:
...
case .loading:
...
case .loaded(let value):
...
case .failed(let error):
...
}