Writing Safer Code with Enums and Associated Values

Swift enums are not just for days of the week or a few fixed flags. Used well, they let you model real application state with precision, eliminate impossible combinations, and make entire categories of bugs much harder to write.

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.

The real power of Swift enums is not “named constants.” It is the ability to model mutually exclusive states and force the compiler to help you handle them correctly.

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)")
}
Associated values are especially powerful when each state needs different data. Instead of one giant struct with many optional fields, each case carries only what it needs.

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.

If you find yourself writing code that returns 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):
    ...
}
Exhaustive switches are one of the hidden safety wins of Swift enums. When you add a new case later, the compiler points out every place that needs updating.