Introduction
When you need to represent a value that can be one of several different types or states, enums are your go-to tool in Swift. But standard enums with simple cases only get you so far. Swift enums with associated values: a step-by-step guide will show you how to make your enums carry data along with their cases, letting you model complex application states with precision and type safety. This pattern is essential for handling real-world scenarios in production iOS apps where each state might need to carry different information.
Cases can carry data
The power of associated values is that each enum case can bundle its own data alongside the case itself. Instead of having separate variables floating around your codebase, you encapsulate state and data together.
Let's say we're building a network request handler. Without associated values, we might write:
enum RequestStatus {
case idle
case loading
case success
case failure
}
// Then elsewhere in the code:
var currentStatus: RequestStatus
var responseData: Data?
var errorMessage: String?
This approach is error-prone. You can end up with inconsistent states where currentStatus is .failure but responseData is still populated. With associated values, everything stays coherent:
enum RequestStatus {
case idle
case loading
case success(Data)
case failure(Error)
}
// Usage:
var status = RequestStatus.idle
status = .loading
status = .success(fetchedData)
status = .failure(networkError)
Now each case carries exactly what it needs. A .success case always has data, and a .failure case always has an error. You can't accidentally have a .success state without data because the compiler won't allow it.
Associated values can be named or unnamed. Named associated values make code clearer:
enum AuthenticationResult {
case success(user: User, sessionToken: String)
case failure(reason: String, retryable: Bool)
}
// Extracting values:
switch authResult {
case .success(let user, let token):
print("User: \(user.name), Token: \(token)")
case .failure(let reason, let retryable):
print("Failed: \(reason), Retryable: \(retryable)")
}
When you read .failure(reason: String, retryable: Bool), the intent is immediately clear. Compare that to .failure(String, Bool) where you'd have to memorize or look up the order.
Exhaustive switch statements
One of Swift's greatest safety features is that switch statements on enums must be exhaustive. When you switch over an enum with associated values, the compiler requires you to handle every case. This prevents entire categories of bugs that plague many codebases.
enum FileDownloadState {
case notStarted
case downloading(progress: Double)
case completed(fileURL: URL)
case failed(error: Error)
}
let state = FileDownloadState.downloading(progress: 0.75)
switch state {
case .notStarted:
print("Download has not started")
case .downloading(let progress):
print("Downloading: \(Int(progress * 100))%")
case .completed(let fileURL):
print("File ready at: \(fileURL)")
case .failed(let error):
print("Download failed: \(error.localizedDescription)")
}
If you later add a new case to FileDownloadState, every switch statement that handles it will produce a compiler error until you add handling for that case. This is invaluable in team environments where multiple people touch the same enum definition.
You can use the @unknown default syntax for future-proofing when dealing with external enums that might gain new cases:
switch state {
case .notStarted:
handleNotStarted()
case .downloading(let progress):
updateProgressUI(progress)
case .completed(let fileURL):
handleCompletion(fileURL)
case .failed(let error):
handleError(error)
@unknown default:
print("Unhandled state encountered")
}
This tells Swift that you've handled all currently known cases, but you're aware that new cases might be added in the future. It's a conscious acknowledgment rather than a silent footgun.
Great for loading states
Loading states are a cornerstone of modern mobile app development. You need to show different UI based on whether data is being fetched, available, or failed. Enums with associated values are perfect for this pattern.
Consider a typical data loading scenario in a SwiftUI view:
enum LoadingState<T> {
case idle
case loading
case success(T)
case failure(Error)
}
class DataViewModel: ObservableObject {
@Published var state: LoadingState<[User]> = .idle
func fetchUsers() async {
state = .loading
do {
let users = try await APIClient.fetchUsers()
state = .success(users)
} catch {
state = .failure(error)
}
}
}
In your SwiftUI view, you can render different UIs for each state:
struct UsersView: View {
@StateObject var viewModel = DataViewModel()
var body: some View {
Group {
switch viewModel.state {
case .idle:
Text("Ready to load users")
.onAppear { Task { await viewModel.fetchUsers() } }
case .loading:
ProgressView()
case .success(let users):
List(users) { user in
Text(user.name)
}
case .failure(let error):
VStack {
Text("Failed to load: \(error.localizedDescription)")
Button("Retry") {
Task { await viewModel.fetchUsers() }
}
}
}
}
}
}
This pattern scales beautifully. As your app grows, you can refine it further. For example, you might want to track partial data while loading:
enum LoadingState<T> {
case idle
case loading
case loadingMore(current: T)
case success(T)
case failure(Error, lastKnownValue: T?)
}
Now you can show cached results while fetching fresh data, providing a smoother user experience. The associated values ensure you have exactly the data you need for each state, no more, no less.
Raw values for simple mappings
Associated values are powerful, but sometimes you just need a simple mapping between cases and primitive values. That's where raw values come in. Raw values are distinct from associated values—each case has one raw value, and all raw values must be the same type.
enum HTTPStatusCode: Int {
case ok = 200
case created = 201
case badRequest = 400
case unauthorized = 401
case notFound = 404
case serverError = 500
}
// Initialize from raw value:
if let status = HTTPStatusCode(rawValue: 404) {
print(status) // notFound
}
// Access raw value:
print(HTTPStatusCode.ok.rawValue) // 200
You can use String raw values too:
enum Environment: String {
case development = "dev"
case staging = "staging"
case production = "prod"
}
let env = Environment(rawValue: "prod") // .production
Raw values are useful for serialization, API communication, and configuration. However, don't confuse them with associated values. You can't have both on the same case:
// This is NOT valid:
enum Mixed {
case example(data: String) = "fixed_raw_value"
}
// You have to choose—associated values OR a raw value per case
If you need both a fixed identifier and variable data, use associated values and store the identifier as a computed property:
enum Event {
case userLoggedIn(user: User)
case pageViewed(pageName: String)
case purchaseCompleted(orderId: String, amount: Double)
var eventType: String {
switch self {
case .userLoggedIn:
return "user.login"
case .pageViewed:
return "page.view"
case .purchaseCompleted:
return "purchase.complete"
}
}
}
Common mistakes
Mixing raw values and associated values: As mentioned, you can't assign a raw value to a case that has associated values. If you need an identifier, compute it as a property instead.
Overly broad associated value types: Don't just throw everything into a single associated value. If a case needs multiple pieces of data, use named associated values so the intent is clear:
// Avoid:
case error(String) // Is this message, code, or description?
// Better:
case error(message: String, code: Int)
Forgetting exhaustiveness in switches: Always ensure you handle all cases or use @unknown default. Leaving out a case silently might compile in older code paths and fail at runtime when you least expect it.
Using enums when you should use structs: Enums are for representing one-of-many states. If you have a type that always exists with potentially optional fields, a struct is more appropriate. Don't force a square peg into a round hole.
Not leveraging generics: When you have a pattern that repeats across multiple enums (like LoadingState, APIResponse, etc.), reach for generics. This keeps your code DRY and testable.
Key takeaways
- Associated values let enum cases carry data, making states self-contained and type-safe
- Exhaustive switch statements prevent bugs by forcing you to handle every case
- Generic enums like
LoadingState<T>are powerful patterns for managing asynchronous operations in SwiftUI - Use named associated values to improve code clarity and reduce mistakes
- Raw values are for simple, fixed mappings; associated values are for flexible, case-specific data
- Computed properties let you extract derived information from enums when needed






Comments
Loading comments…