Enums Are Swift’s Secret Superpower
If you’ve come from a language like Java, C#, or even Objective-C, you might think you know what an enum is.
A list of named constants. An integer with a pretty label. Something you use for a switch statement and then forget about.
That’s not what Swift enums are.
Swift enums are one of the most expressive, flexible, and genuinely powerful tools in the entire language. Once you really understand them, once you stop thinking of them as fancy integers - they’ll change the way you model data, handle errors, and architect your apps.
You might think I’m just overhyping it or being dramatic, but let me show you what I mean.
The Basics (Stick With Me)
Yes, Swift enums can be a simple list of cases. You know this part.
enum Direction {
case north
case south
case east
case west
}
Fine. But this isn’t what makes them interesting. What makes them interesting is everything else.
Associated Values: Enums That Carry Data
Here’s where things start getting fun.
Swift enums can have associated values - each case can carry its own payload of data, and each case can carry different data.
enum PaymentMethod {
case creditCard(number: String, expiry: String, cvv: String)
case bankTransfer(bsb: String, accountNumber: String)
case paypal(email: String)
case applePay
}
Think about what just happened here. We’ve modelled an entire domain concept (payment methods) in a single type. Each case captures exactly the data it needs and nothing more. A creditCard needs card details. applePay needs nothing. There’s no awkward optional properties that may or may not be set depending on which “type” this object happens to be.
You can extract the associated values with pattern matching:
func process(payment: PaymentMethod) {
switch payment {
case .creditCard(let number, let expiry, let cvv):
print("Charging card ending in \(number.suffix(4))")
case .bankTransfer(let bsb, let accountNumber):
print("Transferring to \(accountNumber)")
case .paypal(let email):
print("Sending PayPal request to \(email)")
case .applePay:
print("Processing via Apple Pay")
}
}
Clean, readable, and exhaustive. The compiler won’t let you forget a case.
The compiler has your back
This is where enums quietly become a design tool rather than just a language feature. Because Swift enforces exhaustive switch statements, every possible case must be handled at compile time. That means if you add a new case to an enum, the compiler immediately flags every place in your codebase that hasn’t accounted for it yet.
What would normally be a subtle runtime bug-something silently falling through the cracks-gets turned into a loud, actionable compiler error. Instead of relying on discipline, conventions, or test coverage to catch missing logic, the language itself guarantees it. As your app grows and evolves, that safety net becomes incredibly valuable: your model changes, and the compiler walks you directly to every piece of code that needs to change with it.
Raw Values: Enums That Map to Primitives
On the flip side, sometimes you do want that integer-or-string-underneath behaviour. Swift supports this too, via raw values.
enum HTTPStatus: Int {
case ok = 200
case created = 201
case badRequest = 400
case unauthorized = 401
case notFound = 404
case internalServerError = 500
}
You can initialise from a raw value (carefully - it’s failable):
let status = HTTPStatus(rawValue: 404) // Optional<HTTPStatus>
Raw values work with String too, which is incredibly useful when you’re decoding JSON:
enum UserRole: String, Codable {
case admin
case editor
case viewer
}
Because UserRole conforms to Codable, any struct or class that contains a UserRole property can encode and decode it automatically. The raw string value is used in the JSON. No boilerplate. No custom decoder. It just works.
Computed Properties and Methods
Here’s something that might surprise you: Swift enums can have methods and computed properties.
They’re not just passive data containers. They can carry behaviour.
enum Suit {
case hearts
case diamonds
case clubs
case spades
var isRed: Bool {
switch self {
case .hearts, .diamonds: return true
case .clubs, .spades: return false
}
}
var symbol: String {
switch self {
case .hearts: return "♥"
case .diamonds: return "♦"
case .clubs: return "♣"
case .spades: return "♠"
}
}
}
Now your Suit enum is a self-contained, intelligent type. You’re not writing if suit == .hearts || suit == .diamonds scattered around your codebase - the logic lives right where it belongs.
Enums and Error Handling
Swift’s Error protocol pairs beautifully with enums, and I’d argue it’s one of the best error modelling stories in any mainstream language.
enum NetworkError: Error {
case invalidURL
case requestFailed(statusCode: Int)
case decodingFailed(underlying: Error)
case timeout
case noInternetConnection
}
Look how much information is packed in there. A requestFailed error carries the status code. A decodingFailed error wraps the underlying parsing error so you don’t lose it. And at the call site, you handle each case deliberately:
do {
let user = try await fetchUser(id: 42)
} catch NetworkError.invalidURL {
// Show a developer-facing assertion - this shouldn't happen
} catch NetworkError.requestFailed(let statusCode) {
// Show the user an appropriate message based on the status
} catch NetworkError.noInternetConnection {
// Prompt the user to check their connection
} catch {
// Catch-all for anything unexpected
}
Compare this to returning nil and hoping the caller figures it out, or passing back an NSError with a userInfo dictionary. Swift enum errors are explicit, typed, and a joy to handle.
Recursive Enums
Okay, this one is a bit of a mind-bender. Swift enums can be recursive - a case can contain an associated value of the same enum type. You opt into this with the indirect keyword.
The classic example is a linked list:
indirect enum LinkedList<T> {
case empty
case node(value: T, next: LinkedList<T>)
}
Or an expression tree:
indirect enum Expression {
case number(Double)
case addition(Expression, Expression)
case multiplication(Expression, Expression)
}
let expr = Expression.addition(
.number(3),
.multiplication(.number(4), .number(5))
)
You probably won’t reach for this every day, but when you need it - say, when modelling a tree structure or a recursive data format - it’s incredibly elegant. No class hierarchies, no protocols, no inheritance chains. Just an enum.
The Real Superpower: Replacing Class Hierarchies
Here’s the thing that took me a while to fully appreciate.
In other languages, if you want to model “a thing that can be one of several different shapes”, you typically reach for class inheritance or interfaces. You end up with a base class, a bunch of subclasses, and a lot of is checks or virtual dispatch.
Swift enums let you throw all of that away.
Consider a notification system for an app. In an OOP-heavy approach, you might have a Notification base class with PushNotification, EmailNotification, and SMSNotification subclasses. Checking the type at runtime, casting, maybe a visitor pattern to avoid if chains…
In Swift:
enum Notification {
case push(title: String, body: String, deviceToken: String)
case email(subject: String, body: String, recipient: String)
case sms(message: String, phoneNumber: String)
}
func send(_ notification: Notification) {
switch notification {
case .push(let title, let body, let deviceToken):
pushService.send(title: title, body: body, to: deviceToken)
case .email(let subject, let body, let recipient):
emailService.send(subject: subject, body: body, to: recipient)
case .sms(let message, let phoneNumber):
smsService.send(message, to: phoneNumber)
}
}
The switch is exhaustive. If you add a new notification type later, the compiler will point at every switch statement that needs updating. You can’t accidentally forget to handle a new case.
That’s a guarantee that inheritance simply cannot give you.
A Pattern Worth Knowing: Enums as State
One of my favourite uses of enums in practice is modelling view state. Instead of scattering optional properties and boolean flags around your view model, you can represent the entire state of a screen as a single enum:
enum ViewState<T> {
case idle
case loading
case loaded(T)
case failed(Error)
}
Then in your view model:
class UserProfileViewModel: ObservableObject {
@Published private(set) var state: ViewState<User> = .idle
func loadUser(id: Int) async {
state = .loading
do {
let user = try await userService.fetchUser(id: id)
state = .loaded(user)
} catch {
state = .failed(error)
}
}
}
And in your view, you switch on state to render exactly the right UI for exactly the right situation. No isLoading: Bool. No user: User? sitting next to an error: Error?. Just one source of truth.
Versus Kotlin’s Sealed Interfaces
At this point, if you’re a Kotlin developer reading over someone’s shoulder (or one of my poor coworkers who got hit with my rant on how goated Swift’s enums are), you might be thinking: “Okay, but Kotlin has sealed interfaces. Isn’t this basically the same thing?”
It’s a fair question. And the honest answer is: sealed interfaces are great, and Kotlin deserves credit for having them. But they’re not the same, and the differences matter.
In Kotlin, to model our PaymentMethod example from earlier, you’d write something like this:
sealed interface PaymentMethod {
data class CreditCard(val number: String, val expiry: String, val cvv: String) : PaymentMethod
data class BankTransfer(val bsb: String, val accountNumber: String) : PaymentMethod
data class PayPal(val email: String) : PaymentMethod
object ApplePay : PaymentMethod
}
That works. The when expression is exhaustive, the compiler enforces it, and you get the same safety guarantees at the call site. No argument there.
But look at what you had to write to get there.
For each “case”, you’ve declared a full class - either a data class or an object. That means each variant is its own separate type, with its own file conventions, its own inheritance chain, its own potential for being subclassed or misused elsewhere. It’s a collection of classes wearing a sealed interface as a uniform.
Swift’s enum is a single, unified type. There’s no class involved. No inheritance. No object singleton for the payload-free cases. You write one enum block and you’re done:
enum PaymentMethod {
case creditCard(number: String, expiry: String, cvv: String)
case bankTransfer(bsb: String, accountNumber: String)
case paypal(email: String)
case applePay
}
The difference in noise is obvious. But the more important difference is conceptual.
Kotlin’s sealed interface is a hierarchy - it’s inheritance with a restricted family tree. Swift’s enum is an algebraic data type - it’s a value that is exactly one of a fixed set of possibilities, with no inheritance, no polymorphism, and no open questions about what else might be lurking in the codebase.
This has practical consequences. With a Kotlin sealed interface, each subclass can independently implement methods, add new properties, and conform to additional interfaces. That flexibility is sometimes useful. But it also means the cases aren’t really equal citizens of a single concept - they’re separate types that happen to share a parent.
With a Swift enum, all cases are literally part of the same type. Methods and computed properties are defined once, on the enum itself, and they apply uniformly across all cases. There’s no risk of one subclass “forgetting” to override a method, or implementing the protocol slightly differently from its sibling.
There’s also the matter of raw values, which sealed interfaces simply don’t have an equivalent for. You can’t write:
sealed interface HTTPStatus : Int // This doesn't exist
But in Swift:
enum HTTPStatus: Int {
case ok = 200
case notFound = 404
case internalServerError = 500
}
Works perfectly, including failable initialisation from a raw integer and automatic Codable synthesis.
To be fair to Kotlin, sealed interfaces do have one genuine advantage: because each case is its own class, they can each hold mutable state, implement their own interfaces independently, and be extended in more complex ways. If you need that flexibility, sealed interfaces deliver it.
But for the vast majority of use cases - modelling states, errors, configurations, API responses - that flexibility isn’t something you need. It’s complexity you’re carrying around for no reason.
Swift enums give you the right amount of power for the job. Nothing more, nothing less.
Wrapping Up
Swift enums are not a list of named constants.
They’re a tool for modelling state exhaustively, carrying typed data without optional pollution, expressing errors meaningfully, and replacing entire class hierarchies with something simpler and safer.
Once you start thinking in enums, you’ll notice yourself reaching for them constantly - for API responses, navigation paths, UI state, error handling, configuration. They’re everywhere once you see them.
And the best part? The Swift compiler has your back the whole way. Exhaustive switch statements mean that when your model changes, the compiler finds every place that needs updating. That’s a safety net that scales.
If there’s one feature I’d point to and say “this is what makes Swift genuinely great to write” - it’s this. Enums are Swift’s superpower.
