Tutorials Ultimate Swift Series Chapter 18

Enumerations

SwiftChapter 18 of the Ultimate Swift Series33 minMay 31, 2026Intermediate

When your uploader pushes a build to App Store Connect, the very first thing it needs to know is which platform the build targets -- iOS, macOS, tvOS, or visionOS. Four choices, and exactly four. How should you represent that in code?

Your first instinct might be a String:

func upload(build: URL, platform: String) { ... }
 
upload(build: archive, platform: "iOS")

It works until it doesn't. Is it "iOS" or "ios" or "iphoneos"? What happens when a caller passes "iOs" with a stray capital, or "Android", which your uploader has no idea what to do with? Nothing stops them -- a String parameter accepts every string in the universe, and only a handful are valid.

An Int is no better. If platform: 3 means tvOS, you've just traded typos for a guessing game: nobody reading upload(build: archive, platform: 3) knows what 3 is.

What you actually want is a brand-new type whose values are exactly the four platforms, nothing more -- so the compiler rejects anything else before your app ever runs. That type is an enumeration.

An enum is a list of related, mutually exclusive values grouped under one type. Hand a function a Platform and it's guaranteed to receive one of the four real platforms -- never "Android", never 3, never a misspelling. The compiler checks it for you. Cardinal directions, card suits, user roles, HTTP methods, loading states -- anywhere you have a small, fixed set of possibilities, an enum is the right tool.

And Swift enums are far more capable than the integer constants you might know from C. As you'll see, they can hold methods, computed properties, and even data attached to each case. By the end of this chapter you'll also learn a secret that's been hiding in plain sight: the Optional you've used since Chapter 8 is itself an enum.

Declaring an Enumeration

You list the possibilities as case clauses:

enum Platform {
    case iOS
    case macOS
    case tvOS
    case visionOS
}

That's a complete new type, Platform, with exactly four possible values. The convention is to write each case in lowerCamelCase, the same as a property name. If you prefer, collapse the cases onto one line separated by commas:

enum Platform {
    case iOS, macOS, tvOS, visionOS
}

Create a value by naming the type and the case:

let target = Platform.iOS

Switching Over an Enum

Enums and switch are made for each other. Rewrite the upload helper to take a Platform and branch on it:

func uploadLabel(for platform: Platform) -> String {
    switch platform {
    case .iOS:
        return "iOS App"
    case .macOS:
        return "Mac App"
    case .tvOS:
        return "tvOS App"
    case .visionOS:
        return "visionOS App"
    }
}

Two things are worth slowing down on.

The dot shorthand. Inside the switch, you write .iOS, not Platform.iOS. Because the parameter is already typed Platform, the compiler infers the type and lets you drop the prefix. The same shorthand works anywhere the type is known -- you could have written let target: Platform = .iOS.

No default needed -- and that's a feature. Back in the control-flow chapters, every switch over a String or Int needed a default, because those types have infinitely many values you couldn't possibly list. An enum has a finite set, so once you've handled all four cases, the switch is exhaustive and a default would be dead code. Better still: if someone later adds a case watchOS to Platform, the compiler immediately flags this switch -- and every other non-exhaustive switch -- as an error, pointing you straight at the code that needs updating. A default clause would have silently swallowed the new case instead. Leaving default off turns the compiler into a checklist that can't be ignored.

Let the compiler find your TODOs

This "add a case, break every switch" behavior is one of the best reasons to model fixed sets as enums rather than strings. The day you support a fifth platform, the compiler hands you an exact list of every place in the codebase that has to change. That's a refactoring superpower you give up the moment you reach for a default.

Enums Carry Behavior

A Swift enum isn't just a bag of labels -- it's a full type that can have methods and computed properties, just like a struct. Move the display logic into Platform so it travels with the value:

enum Platform {
    case iOS, macOS, tvOS, visionOS
 
    var displayName: String {
        switch self {
        case .iOS: "iOS"
        case .macOS: "macOS"
        case .tvOS: "tvOS"
        case .visionOS: "visionOS"
        }
    }
 
    var supportsTouch: Bool {
        switch self {
        case .iOS, .visionOS: true
        case .macOS, .tvOS: false
        }
    }
}
 
Platform.iOS.displayName       // "iOS"
Platform.tvOS.supportsTouch    // false

Inside the enum, self is the current case, so you switch over it exactly as you did with the parameter. (Notice these computed properties have no return: a switch used as the whole body of a property or function is an expression, and Swift returns its value automatically. Use it for tidy lookups like these.)

Mini-exercise

Add a computed property var hasHomeIndicator: Bool to Platform that returns true for .iOS and .visionOS and false otherwise. Then read it off Platform.macOS.

Raw Values

Unlike C, Swift enum cases are not secretly integers. Platform.iOS is its own thing -- it has no 1 hiding behind it. But sometimes you want a backing value: a stable string or number to store in a database, send over the network, or match against an API. You opt in by declaring a raw value type after the enum name.

Your backend's upload endpoint expects a lowercase slug for the platform. Back the enum with String and pin each slug:

enum Platform: String {
    case iOS = "ios"
    case macOS = "macos"
    case tvOS = "tvos"
    case visionOS = "visionos"
}
 
Platform.iOS.rawValue       // "ios"
Platform.visionOS.rawValue  // "visionos"

Every case now has a rawValue property holding its slug. For String-backed enums there's a shortcut: if you don't specify the raw values, Swift uses the case names themselves:

enum FileKind: String {
    case png, jpeg, mov, pdf
}
 
FileKind.mov.rawValue   // "mov"  -- the case name, for free

Int-backed enums get a different freebie -- they auto-increment. Give the first one a value (or none) and the rest count up:

enum Tier: Int {
    case free      // 0
    case pro       // 1
    case proPlus   // 2
}

The raw values don't have to be sequential, either. When the numbers carry real meaning -- like HTTP status codes -- assign each one explicitly:

enum UploadStatus: Int {
    case ok = 200
    case unauthorized = 401
    case payloadTooLarge = 413
    case tooManyRequests = 429
}
 
UploadStatus.tooManyRequests.rawValue   // 429

Keep one thing straight: a Tier is not an Int, it merely has an Int raw value. You can't add two Tier values together, but you can add their rawValues. The raw value is a label attached to the case, not the case itself.

Building a Case From a Raw Value

Raw values flow both ways. Given a slug from the network, you can try to turn it back into a Platform with the failable initializer init?(rawValue:):

Platform(rawValue: "ios")       // Optional(Platform.iOS)
Platform(rawValue: "android")   // nil

Why optional? Because the string you pass in might not correspond to any case -- "android" matches nothing, so the initializer returns nil rather than inventing a value. This is a failable initializer, and it's exactly why the result is an Optional. Handle it the way you handle any optional -- bind it before use:

func platform(fromSlug slug: String) -> Platform {
    guard let platform = Platform(rawValue: slug) else {
        return .iOS   // sensible fallback for an unrecognized slug
    }
    return platform
}

Only force-unwrap with ! when you're certain the raw value is valid -- a literal you typed yourself, say. For anything coming from a file, a server, or a user, treat the optional with respect.

Looping Over Every Case: CaseIterable

App Store Connect wants screenshots for several device classes, and you'd like to build a checklist of all of them. Listing the cases by hand would be one more thing to forget to update. Instead, conform the enum to CaseIterable:

enum DeviceClass: CaseIterable {
    case iPhone67, iPhone61, iPadPro13, mac
 
    var label: String {
        switch self {
        case .iPhone67: "6.7-inch iPhone"
        case .iPhone61: "6.1-inch iPhone"
        case .iPadPro13: "13-inch iPad Pro"
        case .mac: "Mac"
        }
    }
}
 
for device in DeviceClass.allCases {
    print("Need screenshots for: \(device.label)")
}

Conforming to CaseIterable makes Swift synthesize a static property allCases -- an array of every case, in the order you declared them. You didn't write allCases yourself; the compiler generated it. It's the clean way to drive a settings list, validate against every possibility, or -- as here -- build a requirements checklist that can never drift out of sync with the enum.

Mini-exercise

Add a case .appleWatch to DeviceClass (with a label of "Apple Watch"). Without touching the loop, confirm the checklist now prints five lines. What did CaseIterable save you from updating by hand?

Associated Values

So far every value of a given case has been identical -- one Platform.iOS is the same as any other. Associated values change that: they let each case carry data, and different cases can carry different data.

Think about the result of an upload. It can succeed, it can be rejected, or the server can ask you to back off and retry -- and each outcome comes with different information. A success has a build number; a rejection has a reason; a retry has a wait time. One enum captures all three:

enum UploadOutcome {
    case accepted(build: Int)
    case rejected(reason: String)
    case retry(afterSeconds: Int)
}

A few rules govern associated values:

You create a value by supplying the data:

let outcome = UploadOutcome.accepted(build: 412)
let problem = UploadOutcome.rejected(reason: "Missing 6.7-inch screenshots")

To read the data back out, you pattern-match and bind it with let inside the switch:

func describe(_ outcome: UploadOutcome) -> String {
    switch outcome {
    case .accepted(let build):
        return "Build \(build) is processing."
    case .rejected(let reason):
        return "Rejected — \(reason)"
    case .retry(let afterSeconds):
        return "Server busy. Retrying in \(afterSeconds)s."
    }
}
 
describe(outcome)   // "Build 412 is processing."

The bound names (build, reason, afterSeconds) are local to each case and exist only inside that branch. They don't have to match the labels in the declaration -- it's just conventional and clear to reuse them.

When you only care about one case, a full switch is overkill. Use if case or guard case to match a single pattern and bind its value:

if case .accepted(let build) = outcome {
    print("Uploaded build \(build)")   // runs only when outcome is .accepted
}
 
func requireBuild(from outcome: UploadOutcome) -> Int {
    guard case .accepted(let build) = outcome else {
        return -1   // not an accepted outcome
    }
    return build
}

Read if case .accepted(let build) = outcome as "if outcome is an .accepted, pull its build number into build." It's the single-case counterpart to a switch.

You've seen this shape before

Modeling success-or-failure with an enum that carries data is so common that Swift ships a built-in version: Result<Success, Failure>, with cases .success(Success) and .failure(Failure). Your UploadOutcome is the same idea, hand-rolled for a domain with three outcomes instead of two. Once associated values click, you'll see this pattern everywhere networking and error handling appear.

Enums as State Machines

An enum is, by definition, in exactly one case at a time -- never two, never none. That makes it a natural state machine: a model of something that moves through a fixed set of states one at a time. A build's journey through review is a perfect example:

enum ReviewState {
    case draft
    case waitingForReview
    case inReview
    case pendingDeveloperRelease
    case readyForSale
    case rejected
}

A given version is in precisely one of these states. It can't be simultaneously inReview and readyForSale -- the type system makes that nonsensical state unrepresentable, which is a quieter but huge benefit: bugs that can't be expressed can't happen. Attach behavior that depends on the state:

extension ReviewState {
    var canEditMetadata: Bool {
        switch self {
        case .draft, .rejected: true
        default: false
        }
    }
 
    var isTerminal: Bool {
        switch self {
        case .readyForSale, .rejected: true
        default: false
        }
    }
}
 
ReviewState.inReview.canEditMetadata   // false — locked while Apple reviews
ReviewState.draft.canEditMetadata      // true

Vending machines, traffic lights, download managers, onboarding flows -- anything that follows a defined sequence of states is begging to be modeled as an enum, precisely because the enum guarantees only one state holds at a time.

Mini-exercise

Model a feature flag as an enum AutoUpload with cases .enabled and .disabled, and give it a computed property var symbol: String returning "●" when enabled and "○" when disabled.

Case-less Enums as Namespaces

Back in Chapter 17 you sprinkled some magic numbers through the asset code -- a 500 MB byte ceiling, a 30-second cap on app previews. Loose constants like that are easy to mistype and hard to find. Group them under one roof:

enum UploadLimits {
    static let maxAssetKB = 500_000
    static let maxPreviewSeconds = 30
    static let maxScreenshotsPerDevice = 10
}
 
UploadLimits.maxPreviewSeconds   // 30

UploadLimits has no cases at all -- it exists purely to hold static members under a tidy name. You might wonder why this is an enum and not a struct, since a struct could hold the same constants. Here's the reason:

let limits = UploadLimits()   // ⛔ 'UploadLimits' cannot be constructed because it has no accessible initializers

A struct can be instantiated, and an instance of a pure-constants type is meaningless -- there's no state to put in it. A case-less enum can't be instantiated, because creating an enum value means choosing a case, and there are none. The compiler turns "this is a namespace, not a value" from a comment into a guarantee. (Such an enum is sometimes called an uninhabited type -- a type with no possible values.)

Reach for a case-less enum whenever you want a namespace for related constants or static helpers and an instance would never make sense.

The Big Reveal: Optionals Are Enums

You've used optionals since Chapter 8 -- Int?, if let, nil, the ! and ? operators. Time to pull back the curtain: an optional is just an enum you've been using without seeing its shape. Stripped down, Optional is defined like this:

enum Optional<Wrapped> {
    case none
    case some(Wrapped)
}

(The <Wrapped> is a generic placeholder -- it's how one enum works for Int?, String?, and every other type. Generics get their own chapter soon.) Everything optional-related maps onto these two cases:

Which means every tool from this chapter works on optionals directly. You can switch over one:

let build: Int? = 412
 
switch build {
case .none:
    print("No build uploaded yet")
case .some(let number):
    print("Build number is \(number)")
}

And the everyday syntax is just sugar over those cases:

let missing: Int? = nil
missing == nil      // true
missing == .none    // true  — nil and .none are the same thing

The ?, !, if let, and nil you've leaned on are a friendly costume over Optional.none and Optional.some. Optionals were teaching you associated values and pattern matching all along -- you just didn't know the trick yet.

Challenges

Build these in a playground to lock the concepts in.

Challenge 1: the next tier up

Start from enum Tier: Int { case free, pro, proPlus }. Add a computed property var next: Tier that returns the next tier up the ladder, and stays at .proPlus when there's nowhere higher to go.

Hint: use rawValue + 1 with the failable Tier(rawValue:) initializer, and the nil-coalescing operator (??) to fall back to self when the next raw value doesn't exist.

Challenge 2: a user-facing message

Using the UploadOutcome enum, write func message(for outcome: UploadOutcome) -> String that returns a friendly sentence for each case -- the build number on success, the reason on rejection, and the wait time on retry. Test it against one value of each case.

Challenge 3: the missing-screenshots checklist

Using DeviceClass: CaseIterable, write func missing(have provided: [DeviceClass]) -> [DeviceClass] that returns every device class the user has not yet provided screenshots for. Calling it with [.iPhone67, .mac] should return the remaining classes.

Hint: filter DeviceClass.allCases by whether provided contains each case. (You'll need the enum to be Equatable -- which, for an enum with no associated values, Swift gives you automatically.)

Challenge 4: namespace refactor

Here's a constants holder written as a struct:

struct AppStoreLimits {
    static let maxKeywordsLength = 100
    static let maxPromoTextLength = 170
    static let maxSubtitleLength = 30
}

Convert it to a case-less enum. In a code comment, state exactly what the compiler now prevents that it didn't before, and why that's a good thing for a type that's nothing but constants.

Key Points

You've now met the third of Swift's custom types -- struct, class, and enum -- and you keep bumping into two ideas that cut across all of them. CaseIterable, Equatable, and Optional's <Wrapped> hinted at them: protocols, the contracts that let unrelated types share capabilities, and generics, code that works across many types at once. Those are the next two chapters, and together they're what make Swift's type system feel less like a set of boxes and more like a toolkit.

Ch 17: Advanced ClassesCh 19: Protocols
SwiftUIUltimate SwiftUI SeriesSwiftUI tutorials for building native app screens, layouts, navigation, and state-driven interfaces.Ship iOSShip iOS Apps SeriesShipping workflows for iOS apps: signing, TestFlight, App Store Connect, CI, and release hygiene.DeliveryModern Delivery PipelineCI/CD, review, runner, and deploy workflows for teams shipping apps and websites safely.

Ship your apps faster

When you're ready to publish your Swift app to the App Store, Simple App Shipper handles metadata, screenshots, TestFlight, and submissions — all in one place.

Try Simple App Shipper
5 free articles remainingSubscribe for unlimited access