A scalable alternative to Decodable enums
Author
Stefano Mondino
Date Published

Swift Decodable
and Encodable
protocols offer a super convenient way to quickly map raw external data, usually JSON, into Swift native objects.
While being super-convenient and easy to use, sometimes a basic usage is either not enough or may even hide some potential pitfalls as the entire project evolves, and you might experience them the hard way when usually is too late.
In this article we'll explore an alternative technique to map and handle values usually mapped into enums, using a safer and futureproof approach with some less-known tricks in the Swift type system.
Introduction
When building apps and consuming REST APIs, data is usually provided by “third parties“, being them external teams developing another piece of software - the backend - written using another technology stack and best practices. The output of their entire process is a JSON response that is structured following a "contract" - a set of rules that all the involved parties must agree upon and follow. Things like "this field is a nullable String" or "this field is an array of objects with this structure".
Swift developers are accustomed creating struct
data types conforming to Decodable
protocol to read from JSON, Encodable
to encode to JSON and a convenience protocol Codable
combining the two of them to keep declarations short.
Most of the times simply ”listing” required keys as properties matching the "contract" types will be more than enough to have a perfectly working result, and Swift will automatically synthesize the entire mapping between the original JSON key and the property name (if all of them are the same).
A basic example - An app for a Swift conference
Let’s take this simple JSON as example - imagine a scenario involving a Swift conference with a schedule of events. Each event can be categorized as talk
or workshop
. The app will use this information to display some kind of tag with different colors right below the title.
1{2 "title": "The most amazing talk you'll ever see",3 "category": "talk"4}
The easiest thing to do is to map both title
and category
properties as String
inside a Event
structure, and we assume that both will never be null as part of the API contract.
1struct Event: Codable {2 let title: String3 let category: String4}
While it’s perfectly fine for the title
(it's what I call a "free text", it can assume any possible value with no constraint), the category
comes from a set of defined values and it should be mapped to something that can be used in our code with a direct reference that doesn’t require the developer to remember the underlying raw value. It's a best practice to avoid hardcoding strings everywhere in the code when such Strings are "known values" - Swift is so good with types and we want to use them as much as possible. And we want to be resilient to future changes (if project will decide to have all the category values uppercased because they feel it's best for them, we'd want to avoid changing them in more than one place in our codebase).
In other words, we are probably going to use a Decodable
enum with String
raw value, ending up with something like this
1struct Event: Codable {2 let title: String3 let category: Category4}5extension Event {6 enum Category: String, Codable {7 case talk8 case workshop9 }10}
Namespacing - nesting types inside other types - help us giving context to our structures without resorting to long names. It's a practice with pro and cons, but for the sake of this simple example we'll use them.
If you are unfamiliar with the c, just know that Category
will be enough within the Event
structure scope; on the outside scope we are going to reference it as Event.Category
As time passes and the product evolves, the backend team might decide to introduce a new category to the list - “roundtable” - to identify a new type of conference event.
This is not going to break the contract with the JSON: the value is still a not-nullable string as before with just a new value to handle and should not be treated as a breaking change.
The problem is that your decoding phase is going to fail every time this new value is present in the results; if your API is a list (array) of values, the entire list will fail when a single “roundtable” value is present.
There are many workarounds for this issue. The most obvious one is to drop enums and go back using raw String
directly, but it’s not a wise choice if you use those properties all over your codebase.
You might think to declare the category property as optional, but that wouldn’t work either - the value is there, it’s just not matching the values in your enum and it will fail the decoding phase.
If you want to stick with enums, you will have to implement the Decodable
initializer yourself and drop the auto-synthesized one, like this:
1 extension Event {2 enum Category: String, Codable {3 case talk4 case workshop5 case unknown67 init(from decoder: Decoder) throws {8 let rawValue = try decoder.singleValueContainer().decode(String.self)9 self = Category(rawValue: rawValue) ?? .unknown10 }11 }12 }
This is a perfectly working solution - you now have an “unknown“ value you can use every time you receive a value you didn’t foresee and the app won’t break anymore in such cases.
We can use it in code like this to declare which color we want for our categories:
1let color: UIColor = switch category {2 case .talk: .blue3 case .workshop: .yellow4 default: .clear5}
While this is perfectly fine and will lead to a safe and robust solution, there are some hidden drawbacks.
The first one is that this approach is lossy - the original information ("roundtable") is lost because the app doesn't need it. This might be good for the code and the architecture, but the moment you'll have to read some logs to debug some issue between your app and the backend, you will see a lot of "unknown" values in the console. For the same property, you'll be speaking about two different values with the backend team and this might complicate communication.
It might also introduce some bugs / unexpected behavior if the Category
object needs to be sent back to the server for some reason (example: a POST used set a favorite category on user profile). Once unknown
is mapped, it's going to be used and sent to the server, leading to errors and unpredictable behaviors.
The second one is that you will have to replicate this approach for every new enum you'll create in your project, leading to lots of code duplication just to handle unknown values.
The third one is that enums does not scale with your project. They can't be extended with new values, meaning that you cannot split them in different files and - when it comes to modular architectures - in different frameworks of your app.
A struct-based alternative
Let's focus on the first issue: losing the original value. Enums don't allow to store anything (unless we use associated values - and we won't), so we need something else.
Let's try to implement a struct-based alternative. We'll use the same approach as we did before with a custom Decodable
initializer, but now we get to store such value into a local variable.
1extension Event {2 public struct Category: Codable {3 public let value: String45 public init(from decoder: Decoder) throws {6 value = try decoder.singleValueContainer().decode(String.self)7 }89 public func encode(to encoder: Encoder) throws {10 var container = encoder.singleValueContainer()11 try container.encode(value)12 }13 }14}
Using singleValueContainer()
to decode objects is once again the "trick" to tell the decoding system that the current value we are trying to decode is not key-valued, but just a "raw" one, and we want to map it into a temporary String
We still don't have our "cases" - talk
and workshop
.
Let's add them back, this time using an extension.
1public extension Event.Category {2 static var talk: Self { .init(value: "talk") }3 static var workshop: Self { .init(value: "workshop") }4}
While it can be hard to read at first, we ended up in the exact same situation as before - a custom object (no raw String
) that can be used with in conditional code with type-safe syntax:
1let category: Event.Category2let color: UIColor = switch category {3 case .talk: .blue4 case .workshop: .yellow5 default: .clear6}
This time we gained a couple of advantages:
- We are not tied anymore to a finite set of possible values and the backend is free to update them - we just need to handle the default value for unknown scenarios
- We are retaining the original value in our structure and we can either debug it or send it back to the server
- We don't need the "unknown" fake value in the enum to handle extra cases.
This will also come in handy in modular contexts, where we might have a shared framework handling the mapping for Category
, and multiple feature frameworks where every single value is handled and used. In this way, the low-level framework just know how to map a string, wrapping it in a custom "agnostic" object, and feature frameworks can do their customization in a completely "additive" way. Combining this technique with advanced Dependency Injection will do the trick.
Making the code reusable
The next step into this journey is to make this approach as much reusable as possible - as convenient and useful as it might be, it's not a great deal if the initializers must be declared every time a new property is needed - at some point, someone is going to copy-paste it wrong.
We can simply create a Codable conforming struct wrapping the shared logic, like:
1public struct ExtensibleIdentifier: Codable {2 public let value: String3 public init(from decoder: Decoder) throws {4 value = try decoder.singleValueContainer().decode(String.self)5 }6 public func encode(to encoder: Encoder) throws {7 var container = encoder.singleValueContainer()8 try container.encode(value)9 }10}
Two issues here: we are forced to map String values as underlying values and, most important, the object is not constrained to a context, it's just a generic global object. If we were to extend it with multiple values, we'd end up having all of them cluttered and unable to understand where they belong.
1extension Event {2 typealias Category = ExtensibleIdentifier3}45extension SomethingElse {6 typealias Something = ExtensibleIdentifier7}89extension SomethingElse {10 static var something: Self { .init(value: "something") }11}1213let newCategory = Event.Category.something // this will compile
To tackle the first issue, we just have to add a constraint to a generic Value
that also needs to be Codable
, quite "routine" if you worked with generics at least once.
The second one is a little bit trickier: in order to be able to identify two different structs with identical implementation, we need to add a second generic to it, a Tag
, that will never hold a specific internal value, but will be used as context to define a completely new value type.
We also want the structure to be Equatable
and Hashable
to be used in dictionaries and conditionals, and since we are in the Swift 6 era already we also want to add Sendable
conformance, as it comes for free in this use case.
Something like this:
1public struct ExtensibleIdentifier<Value: Hashable & Sendable & Codable, Tag>: Hashable, Sendable, Codable {23 public var value: Value45 public init(_ value: Value) {6 self.value = value7 }89 public init(from decoder: Decoder) throws {10 let value = try decoder.singleValueContainer().decode(Value.self)11 self.init(value)12 }1314 public func encode(to encoder: any Encoder) throws {15 var container = encoder.singleValueContainer()16 try container.encode(value)17 }1819 public func hash(into hasher: inout Hasher) {20 hasher.combine(value)21 // Prevents two objects with same value and different tags from having the same hash22 hasher.combine(ObjectIdentifier(Tag.self))23 }2425 public static func == (lhs: Self, rhs: Self) -> Bool {26 lhs.value == rhs.value27 }28}2930
This is way more complex than before, but finally allows us to express our identifier in a more secure way. If we want to avoid the double generic all over the code, we can simply use a typealias like this:
1struct Event: Codable {2 typealias Category = ExtensibleIdentifier<String, Self>3 let title: String4 let category: Category5}67extension Event.Category {8 static var talk: Self { .init("talk") }9 static var workshop: Self { .init("workshop") }10}
And we are also sure that any other "talk" or "workshop" property of any other ExpressibleIdentifier object in our codebase will be treated as a different object if their Tag is different from Event.
This is the previous code updated:
1extension Event {2 typealias Category = ExtensibleIdentifier<String, Self>3}45extension SomethingElse {6 typealias Something = ExtensibleIdentifier<String, Self>7}89extension SomethingElse {10 static var something: Self { .init("something") }11}1213let newCategory = Event.Category.something // this will not compile, "something" is not part of Event.Category
But we can even bring it to the next level
Adding syntactic sugar
Let's add back printable descriptions to our ExtensibleIdentifier: if the underlying value is a String or anything that can be converted to a String, we can automatically "bridge" that description to the ExtensibleIdentifier by conforming to CustomStringConvertible
and CustomDebugStringConvertible
We can also add conformance to RawRepresentable
protocol - we basically re-implemented a similar thing, but conform to this protocol should automatically allow automatic comparison between our identifiers when raw value represents something...comparable (like numbers).
1extension ExtensibleIdentifier where Value: CustomStringConvertible {2 public var description: String { value.description }3}4extension ExtensibleIdentifier where Value: CustomDebugStringConvertible {5 public var debugDescription: String { value.debugDescription }6}78extension ExtensibleIdentifier: RawRepresentable {9 public var rawValue: Value { value }10 public init?(rawValue: Value) {11 value = rawValue12 }13}14
This will allow us to use concrete values in string interpolations without having to expand the inner value property.
We can also get rid of the double generic by declaring typealiases for commonly used wrapped types, like String, Int, etc...
1public typealias StringIdentifier<Tag> = ExtensibleIdentifier<String, Tag>2public typealias IntIdentifier<Tag> = ExtensibleIdentifier<Int, Tag>3public typealias BoolIdentifier<Tag> = ExtensibleIdentifier<Bool, Tag>4public typealias FloatIdentifier<Tag> = ExtensibleIdentifier<Float, Tag>
The Boolean one is probably the most useless thing ever created, but it's there to prove the versatility of the Swift type system. Using those typealias
may or may not simplify the core concepts for less experienced members of your team, if dealing with multiple generics is troublesome.
Finally, let's explore a better expression of these elements when it comes to declaring the static vars in place of the good old enumcase
Swift provides a powerful literals system that can be used not only by classic strings, integers and array, but also by custom objects when conforming to their literal counterparts.
In our case, when an ExtensibleIdentifier
is wrapping a String
value, we can conform to the ExpressibleByStringLiteral
family to tell the compiler that we want to automatically initialize our object instead of a plain String
when the current context can infer it. And same goes for other basic types. This is the implementation:
1extension ExtensibleIdentifier: ExpressibleByUnicodeScalarLiteral where Value == String {}2extension ExtensibleIdentifier: ExpressibleByExtendedGraphemeClusterLiteral where Value == String {}3extension ExtensibleIdentifier: ExpressibleByStringLiteral where Value == String {}4extension ExtensibleIdentifier: ExpressibleByStringInterpolation where Value == String {5 public init(stringLiteral value: String) {6 self.init(value)7 }8}910extension ExtensibleIdentifier: ExpressibleByIntegerLiteral where Value == Int {11 public init(integerLiteral value: IntegerLiteralType) {12 self.value = value13 }14}1516extension ExtensibleIdentifier: ExpressibleByBooleanLiteral where Value == Bool {17 public init(booleanLiteral value: Bool) {18 self.value = value19 }20}2122extension ExtensibleIdentifier: ExpressibleByFloatLiteral where Value == Float {23 public init(floatLiteral value: Float) {24 self.value = value25 }26}2728
With this final result:
1public struct Event: Codable {2 public typealias Category = StringIdentifier<Self> // or ExpressibleIdentifier<String, Self>3 public let title: String4 public let category: Category5}67public extension Event.Category {8 static var talk: Self { "talk" } // the .init is gone!9 static var workshop: Self { "workshop" }10}1112// In a completely different framework13public extension Event.Category {14 static var roundtable: Self { "roundtable" }15}
Or we can declare a custom property wrapper and get even closer to the enum syntax
1extension ExtensibleIdentifier {2 @propertyWrapper public struct Case {3 let key: Value4 public init(_ key: Value) {5 self.key = key6 }7 public var wrappedValue: ExtensibleIdentifier<Value, Tag> {8 return .init(key)9 }10 }11}1213
and use it like this (notice you don't have to use Self
as return value, because the property wrapper automatically infers it):
1public extension Event.Category {2 //static var talk: Self { "talk" }3 @Case("talk") static var talk4 @Case("workshop") static var workshop5}
There could also be an even more compact way using Swift Macros but I didn't explore that path yet. And honestly I don't think if we should, because the architecture can become hard to read and to teach to other developers if we start building too many DSLs. Also, custom macros add compile time to the entire process and could be an issue for some CICD systems. This is what the final could like:
1// This is probably doable with a custom Swift Macro, but I honestly don't know if it's worth the effort.2public extension Event.Category {3 @Case("talk")4}5
Pros and Cons
It's not all good here - there are for sure places where enums work way better that this struct-based strategy.
The first thing you lose is automatic implementation to CaseIterable
protocol - a way to auto synthesize a static var allCases: [Self]
variable with all the available cases in the enum, sorted by their position in the list. However, if you are planning to modularize your app and split your cases across your modules, you won't be able to use this approach anyway, since enums are not extensible.
Partially related to that, you also lose the finite (and compile-time known) possible values you might end up with: every switch
in your code will always need to handle a default
branch. But again, in modular architectures you'll hardly use switches (if you have scattered your values across the modules of your app, you'd need to switch over those values in a place that knows them all - usually the app target).
In my opinion, when dealing with remote values and their mapping, we should always assume the worst. Having a global mapping failure over complex JSONs is quite hard to understand and will lead to meaningless error messages like "ooops some error occured" that will only make your users angry. It's hard to debug when JSONs are big and comples. And they are hard to unit-test, because you have to remember that you need to test that case.
The only valid cases where the enum approach might be followed is when the closed set of values is related to something so well-known in real life that is extremely hard to be expanded. Something like theming ("system", "dark", "light" when passed via JSON - every system has those values), or maybe "AM" / "PM" when dealing with times, or "KMH" / "MPH" when dealing with speed values - something so universal that is beyond any reasonable doubt.
Otherwise why should we take the risk? What kind of benefit do we gain?
Conclusion
Using structs instead of enums to map set of values from a JSON api is probably unconventional, but has some great advantages for most (if not all) possible use cases.
Once you start understanding advanced Codable features, you'll unlock a new way to map data from your APIs without having to build many model layers wrapping the same objects multiple times.
In the next posts I'll dig a little more into how modular architectures can benefit from similar approach, and how this "fake enums" can be split across different modules.
Stay tuned!