|

if case and guard case: Match One Shape Without a switch

One selected path among several enum cases, Molecules of Swift #007

Swift enums help us represent values that can take one of several forms. A sync operation, for example, might be idle, currently syncing, or failed. Instead of keeping separate flags for these states, we can describe them as cases of one enum. Some cases can also carry useful data: a failed operation might include a reason that the interface can display.

When a screen needs to present the entire operation state, a switch is a good fit: it makes us consider each case. But not every part of an app needs that full decision. A small error label might only need to know whether the operation failed and, if it did, extract the reason. Writing branches for the other states adds code without answering a new question.

This is where Swift’s if case and guard case help. They match a specific enum pattern and can bind its associated values. The difference is how the surrounding code should proceed: if case handles a match locally, while guard case requires a match before continuing.

In this Molecule, we’ll start with a single case, then explore value binding, additional conditions, optional enums, and the limits of partial matching. We’ll also see when an exhaustive switch is still the clearer and safer choice.

Ask for one case, not every case

Consider a sync state that can carry an error message:

enum SyncState {
    case idle
    case syncing
    case failed(String)
}

func statusLine(_ state: SyncState) -> String {
    if case let .failed(reason) = state {
        return "Failed: \(reason)"
    }
    return "No failure"
}

print(statusLine(.failed("Token expired"))) // Failed: Token expired
print(statusLine(.syncing))                 // No failure

let failedState = SyncState.failed("Disk full")
if case .failed = failedState {
    print("A failure exists") // A failure exists
}

The first call receives .failed("Token expired"). The pattern .failed(reason) matches that case, binds the associated string to reason, and returns "Failed: Token expired". The second call receives .syncing; the pattern does not match, so execution continues after the if and returns "No failure". The final check receives .failed("Disk full") and prints "A failure exists" without reading the message.

Read if case let .failed(reason) = state from right to left: take state, see whether it has the .failed shape, and bind its associated value if it does. The = here belongs to the pattern-matching condition; it is not an assignment to state. You can also place let inside the case as .failed(let reason). Both forms bind the same value. When you only care about the case, if case .failed = failedState is enough; the associated value can be ignored.

This is a deliberate partial check. There is no compiler requirement to cover .idle and .syncing. That is useful for a local action, but it becomes a risk if the code is supposed to decide what every state means.

Try it: Change the first call to .idle. It will return "No failure", because the function is asking only whether the value is .failed. Then change "Disk full" to another message. The final print remains unchanged: that pattern ignores the payload.

A bound value has a small scope

An enum case can carry a collection rather than a single message. The binding rule is the same:

enum LoadState {
    case empty
    case loaded([String])
}

let ready = LoadState.loaded(["Mira", "Noah"])
let missing = LoadState.empty

if case let .loaded(names) = ready {
    print("Loaded: \(names.count)") // Loaded: 2
}

if case let .loaded(names) = missing {
    print("Loaded: \(names.count)")
} else {
    print("Nothing loaded") // Nothing loaded
}

ready contains .loaded(["Mira", "Noah"]), so names is the two-element array and the first branch prints "Loaded: 2". missing contains .empty; the second pattern fails and its else prints "Nothing loaded". There is no names binding in that else branch.

Like if let, a value bound by if case is available in the matching branch, not after the whole statement. This keeps the temporary name tied to the case that proved it exists. The syntax is useful when an enum case contains data that should not be read for any other case.

Notice what the example does not say. .empty is an enum case, not an empty array inside .loaded. A loaded empty array would still match .loaded(names) and print "Loaded: 0". Those two states may need different handling in a real UI.

Try it: Change ready to .loaded([]). The first branch still runs and prints "Loaded: 0". Pattern matching checks the enum case first; it does not validate the contents of the array.

Check a rule after the shape matches

Sometimes the correct case is not enough. An approved payment with a zero amount should not produce the same receipt as a positive amount:

enum Payment {
    case pending
    case approved(cents: Int)
    case declined(reason: String)
}

func receipt(_ payment: Payment) -> String {
    if case let .approved(cents) = payment, cents > 0 {
        return "Receipt: \(cents) cents"
    }
    return "No receipt"
}

print(receipt(.approved(cents: 2500))) // Receipt: 2500 cents
print(receipt(.approved(cents: 0)))    // No receipt
print(receipt(.declined(reason: "Card expired"))) // No receipt

The first payment is .approved(cents: 2500), so the case matches, cents > 0 is true, and the function returns "Receipt: 2500 cents". The second payment is also .approved, but its amount is 0; the extra condition fails and the function returns "No receipt". The declined payment has the wrong case, so it also returns "No receipt" without reaching the positive-amount check.

The comma is doing important work. An if condition can contain a case match followed by a Boolean expression. Swift evaluates these conditions in order; cents is available to the later condition only after the pattern binds it. This is not the where syntax used in switch cases or for case loops. For if case, write the additional check after a comma.

The example intentionally combines two reasons for not issuing a receipt: a nonpositive approved amount and a declined payment. That is fine if the caller only needs a receipt or no receipt. It is not enough if the caller needs to explain which rule failed. In that situation, return a richer result or handle the cases separately.

Try it: Change 2500 to -10. The enum case still matches, but the Boolean condition fails, so the output becomes "No receipt".

Use guard case when the match is required

An if case keeps the matched data inside a branch. When the rest of a function should run only for one case, guard case often reads better:

enum Upload {
    case waiting
    case finished(filename: String)
    case failed(reason: String)
}

func resultLine(_ upload: Upload) -> String {
    guard case let .finished(filename) = upload else {
        return "Not ready"
    }
    return "Ready: \(filename)"
}

print(resultLine(.finished(filename: "report.pdf"))) // Ready: report.pdf
print(resultLine(.waiting))                         // Not ready

The finished upload carries "report.pdf", so the guard matches and the function continues to "Ready: report.pdf". The waiting upload does not match; the else branch returns "Not ready" immediately. The filename binding is available after the guard, in the remaining function scope.

The else block is not optional. As with other guard statements, it must leave the current scope—for example, with return or throw. This is the key difference in intent: if case says do something when this case appears; guard case says we cannot continue unless this case appears.

Neither form changes the underlying enum. Both inspect the current value. If a function has several valid states that need different work, repeatedly stacking guards is usually harder to read than one switch.

Try it: Pass .failed(reason: "Network offline") to resultLine. It returns "Not ready", just like .waiting. If that failure needs a separate message, this function must make a different design choice.

An optional enum has two layers

Earlier Molecules covered optional binding and optional chaining. Here the same idea appears inside a pattern: an optional enum can be missing entirely, or it can contain an enum case.

enum Session {
    case guest
    case signedIn(user: String)
}

func greeting(_ session: Session?) -> String {
    if case .some(.signedIn(let name)) = session {
        return "Hello, \(name)"
    }
    return "Sign in needed"
}

print(greeting(.signedIn(user: "Mira"))) // Hello, Mira
print(greeting(.guest))                  // Sign in needed
print(greeting(nil))                     // Sign in needed

The .some(...) pattern first requires a present Session. Inside it, .signedIn(let name) requires the signed-in case and binds "Mira". That is why the first call returns "Hello, Mira". The .guest value is present but has the wrong case; nil has no Session value at all. Both return "Sign in needed" in this particular function.

That shared output does not make .guest and nil equivalent. The pattern deliberately groups them because the caller only asks for a signed-in greeting. If you need to distinguish an absent session from a guest session, use separate checks or an exhaustive switch over the optional value.

You could write this as nested optional binding and a case check. The single pattern is useful when the whole question really is “does this optional contain .signedIn?”. It is less useful if readers must decode several nested patterns just to follow the happy path.

Try it: Replace the first call with .guest. It will return "Sign in needed"; the pattern cannot bind name from a case that does not carry it.

Let switch cover the whole decision

The shorter syntax has a clear limit. If a function must produce a different label for every access state, a switch makes that promise visible:

enum Access {
    case guest
    case member(String)
    case blocked(String)
}

func label(_ access: Access) -> String {
    switch access {
    case .guest:
        return "Guest"
    case let .member(name):
        return "Member: \(name)"
    case let .blocked(reason):
        return "Blocked: \(reason)"
    }
}

print(label(.guest))                // Guest
print(label(.member("Mira")))       // Member: Mira
print(label(.blocked("Expired")))   // Blocked: Expired

All three cases are handled: .guest returns "Guest", .member("Mira") returns "Member: Mira", and .blocked("Expired") returns "Blocked: Expired". There is no default because the listed cases already cover this enum. If you add another case to Access, the compiler can point out that this switch is no longer exhaustive.

An if case does not give that coverage check. It is intentionally narrow, so a new enum case can silently take the unmatched path. That is the right behavior for an optional side action such as logging one event. It is a poor contract for business logic that must classify every possible state.

Try it: Add case suspended to Access without changing label. The compiler reports a missing case in the switch. This is a compile-time experiment, not an output from the code above.

Choose by intent, not by line count

if case is a good fit for a focused event handler: react to .failed and ignore ordinary progress. It also works when a view only needs to extract one case’s associated value, or when a function checks a special condition before doing unrelated work. guard case is better when that case is a requirement for the rest of the function.

A switch is the stronger choice when the output must depend on every state, when failures need different recovery paths, or when a future enum case should force a code review. Avoid turning a chain of if case checks into a hidden replacement for an exhaustive decision. Also remember that matching a case is not the same as validating its payload: .loaded([]) still matches .loaded, and .approved(cents: 0) still matches .approved.

The useful micro-pattern is small: match the shape you need, bind only the data it carries, then apply any domain rule separately. Use if case for a local question, guard case for a required state, and switch when the full set of states is part of the contract.

Sources