How to Design Swift Enums With Payloads That Don’t Become Technical Debt
Swift enums with associated values are the language’s most distinctive modeling tool. They can carry per-case payloads, conform to protocols, and participate in exhaustive pattern matching — none of which C or Java enums can do. Used well, they replace entire class hierarchies and give you compile-time guarantees that no state goes unhandled. Used poorly, they accumulate into a different kind of debt, one that’s harder to refactor than the class hierarchies they replaced.
The difference between a well-designed enum and a debt-laden one almost always comes down to the payloads. Case names are easy. Payloads are where the design discipline matters. What follows is a walkthrough of the specific decisions that determine whether your enums stay maintainable or become a burden — with concrete examples and migration patterns for codebases where the damage is already done.
When Payloads Become Technical Debt
The most common form of enum debt I see in production codebases is the unlabeled, untyped payload. It starts innocently:
enum APIError: Error {
case network(String)
case parsing(String)
case server(Int, String)
case unknown
}
Three months later, a developer calling throw APIError.server(500, "timeout") has to check the enum definition to remember whether the Int is the status code or the retry count, and whether the String is a message or a URL. The payload is structurally opaque. The type system knows it’s an Int and a String, but the call site communicates nothing about what those values mean.
This is the enum equivalent of a function with unnamed parameters. We’d never accept func server(Int, String) in a public API. But we accept it in enum payloads because the case name provides a thin veneer of documentation. That veneer wears off fast.
The second form of debt is the stringly-typed payload. case validationFailed(String) where the string is an error message callers are expected to display to users. This couples your error model to your presentation layer, makes localization impossible without a lookup table, and gives you no compile-time guarantee that the message is meaningful. You’ve replaced a type-safe enum with a string-typed field that could carry anything.
The third form is the god-payload enum — a case whose associated value is a large, loosely-structured type that accumulates fields over time:
enum UserEvent {
case profileUpdated(UserProfileUpdateData)
case settingsChanged(SettingsChangeData)
case accountAction(AccountActionData)
}
Each of those payload types starts small. Six months later, AccountActionData has eleven optional fields, only three of which are relevant to any given event. The enum hasn’t prevented the problem it was supposed to solve. It’s just moved the unstructured data from a class hierarchy into a struct that nobody validates.
Labeled Associated Values Create Self-Documenting APIs
The fix for unlabeled payloads is trivial in mechanics and significant in impact: add labels.
enum APIError: Error {
case network(underlying: URLError)
case parsing(failedType: any Type.Type, context: String)
case server(statusCode: Int, message: String)
case unknown
}
Now the call site reads throw APIError.server(statusCode: 500, message: "timeout"). The payload documents itself. A developer reading the throw site doesn’t need to look up the enum definition. A developer reading the pattern match sees exactly which bindings they’re extracting:
switch error {
case .server(let statusCode, let message) where statusCode >= 500:
logger.error("Server error: \(message)")
retryAfterDelay()
case .network(let underlying):
logger.error("Network error: \(underlying.code)")
default:
break
}
Labeled associated values also make the pattern match self-documenting. When you see let statusCode in a switch case, you know what it represents. When you see let message, you know it’s a human-readable string, not a URL or a retry count.
The labeling convention also creates a natural review checkpoint. When a pull request adds a new case with an unlabeled payload, the reviewer sees it immediately. The absence of a label is a signal that the author hasn’t fully thought through the contract. It’s the same instinct that catches a function parameter named data or value — vague names mean vague design. The broader principle — that structured naming systems produce more maintainable artifacts than ad-hoc approaches — extends beyond Swift. Novelists planning character arcs with a structured tool like the Unsloppy AI Novel Writing App’s character name generator get the same benefit a Swift developer gets from labeled enum payloads: the structure itself carries meaning that survives the passage of time between authorship and comprehension.
Pattern-Matching Exhaustiveness as a Design Constraint
Swift’s exhaustive switch checking is the feature that makes enums more than a naming convention. When you add a new case, the compiler forces every switch statement to handle it (or explicitly mark it as @unknown default). This is a design constraint, not just a safety feature. It changes how you think about evolution.
The constraint works in your favor when the enum models something with a genuinely finite set of states. An authentication flow with unauthenticated, authenticating, authenticated, and error cases is a good fit. Adding a new state forces every consumer to decide how to handle it, which is exactly what you want when the state machine changes.
The constraint works against you when the enum is effectively open-ended. If you’re modeling HTTP status codes as an enum with a case per status, you’ll spend your life adding cases and updating switch statements that don’t care about the difference between 418 and 451. For open-ended categorization, a struct with a raw value is often better than an enum that pretends to be exhaustive.
The key question: will new cases require existing code to make a meaningful decision? If yes, exhaustiveness is a feature. If no, you’re creating busywork for every developer who touches a switch statement. The value of forcing every state to be acknowledged isn’t unique to Swift — it’s the same discipline that structured incident response brings to production systems, though Swift’s compiler-enforced pattern matching makes it automatic where SREs must enforce it through process.
Modeling Finite State Machines for Async Operations
One of the highest-value uses of enums with associated values is modeling the state of an async operation. Instead of scattering boolean flags and optional values across a view model, you encode the operation’s lifecycle as a single enum:
enum LoadState<Value> {
case idle
case loading
case loaded(Value)
case failed(Error)
}
This gives you several things at once. First, the states are mutually exclusive by construction — you can’t be simultaneously loading and loaded. Second, the loaded state carries its value, so you don’t need a separate optional property. Third, every consumer of the state must handle all four cases, which means the UI can’t silently forget to render an error state.
The pattern scales to more complex state machines. A paginated feed might use:
enum FeedState<Item> {
case initial
case loadingFirstPage
case loaded(items: [Item], canLoadMore: Bool)
case loadingMorePage(currentItems: [Item])
case error(message: String, retryAction: () -> Void)
}
Each case carries exactly the data its view needs. The loadingMorePage case preserves the current items so the UI can show them with a loading indicator appended. The error case carries a retry closure, which is cleaner than exposing a separate retry() method that the view must call conditionally.
This approach replaces what would otherwise be a class or struct with isLoading: Bool, items: [Item]?, error: Error?, and a canLoadMore: Bool — four properties that can represent sixteen invalid combinations. The enum eliminates the invalid combinations by construction.
The tradeoff: this pattern requires discipline about state transitions. You need to ensure that the state machine doesn’t get stuck — for example, that a loadingFirstPage state always transitions to either loaded or error, never stays loading forever. In a production app, this means your async operation code should be the only thing that mutates the state, and the transitions should be testable in isolation.
The Performance Implications of Large Enum Payloads
Enums with associated values are value types, which means the enum’s memory size is the size of its largest case payload plus a tag byte (or more for enums with many cases). If one case carries a 1KB struct, every instance of the enum occupies at least 1KB, even when the active case is idle with no payload.
For most app-level code, this doesn’t matter. For performance-sensitive paths — tight loops, large collections of enum values, or memory-constrained environments — it can bite. The fix is the indirect keyword, which stores the payload on the heap:
enum TreeNode {
case leaf(value: Int)
indirect case branch(left: TreeNode, right: TreeNode)
}
indirect adds a pointer indirection, so the enum case stores a heap-allocated box instead of the payload inline. This keeps the enum’s fixed size small (a pointer plus a tag) at the cost of an allocation and a pointer chase on access.
The decision of when to use indirect follows a simple heuristic: if the largest payload is significantly bigger than a pointer (8 bytes on 64-bit), and the enum is stored in collections or frequently copied, measure the impact. If the enum is stored as a single property on a class, the size rarely matters.
One subtlety: indirect can be applied to the entire enum or to individual cases. Applying it to the enum makes all cases indirect, which is usually overkill. Apply it to specific cases with large payloads.
enum NetworkResponse<Body: Decodable> {
case success(body: Body)
indirect case failure(error: DeepErrorContext)
}
Here, DeepErrorContext might carry a full error chain with request/response metadata. Making only the failure case indirect keeps the success path — which is usually the hot path — allocation-free.
A Practical Migration Path for Debt-Laden Enums
If your codebase already has enums with unlabeled or stringly-typed payloads, you can migrate incrementally without a big-bang refactor. The key: adding labels to existing associated values is a source-compatible change at the call site when you use the labeled form, and the compiler will guide you to fix unlabeled call sites.
Step one: add labels to the enum definition. This is a breaking change for call sites that use positional arguments, but the compiler will flag every one:
// Before
case server(Int, String)
// After
case server(statusCode: Int, message: String)
Step two: fix the call sites. For a small enum, this is a few minutes of work. For a large one, Xcode provides fix-its for labeled associated value migration, and SE-0155’s normalization of enum case declarations means you can also decompose shared payload types into separate cases without touching every switch. Each fix site is an opportunity to verify that the call site’s intent matches the label — if you find a call site passing a retry count where the label says statusCode, you’ve found a bug the unlabeled payload was hiding. Structured taxonomies like the NIST Cybersecurity Framework demonstrate the same principle at a systems level: explicitly-labeled categories outperform ad-hoc categorization, and the practice of designing structured naming systems is an established cross-disciplinary engineering principle, not a Swift-specific idiom.
Step three: replace stringly-typed payloads with structured types. If case validationFailed(String) is carrying a localized error message, replace it with a case that carries a validation error code or a structured error type:
// Before
case validationFailed(String)
// After
case validationFailed(field: String, rule: ValidationRule)
where ValidationRule is an enum describing which rule failed (required, minLength(Int), pattern(String), etc.). This moves the string generation to the presentation layer where it belongs, and gives the error type enough structure to be tested and logged meaningfully.
Step four: split god-payload cases. If case accountAction(AccountActionData) has grown to eleven optional fields, split it into multiple cases that each carry only the data they need:
// Before
case accountAction(AccountActionData)
// After
case accountCreated(userId: UUID)
case accountDeleted(userId: UUID, reason: DeletionReason)
case accountSuspended(userId: UUID, until: Date)
case passwordChanged(userId: UUID)
This is the most invasive step because it changes the enum’s case count, which means every switch statement needs new cases. But it’s also the step that delivers the most value — it converts a loosely-structured payload into a set of exhaustive, self-documenting states that the compiler will help you handle completely.
Conclusion
A well-designed Swift enum with associated values is one of the few language features that genuinely replaces class hierarchies — not by providing inheritance, but by making exhaustiveness checking do the work that polymorphism would otherwise require. The tradeoff is that the payload design carries the same weight as any public API surface. Unlabeled payloads, stringly-typed fields, and god-payload cases are the enum equivalents of vague method names and untyped parameters. They work today and create maintenance debt tomorrow.
The fixes are mechanical: add labels, replace strings with structured types, split bloated payloads into focused cases, and use indirect when memory size matters. The discipline is not mechanical. It requires treating each enum case as an API contract, not a convenient bag of values. The payoff is code that the compiler helps you maintain, rather than code that the compiler merely compiles.