Swift’s Decodable and Encodable protocols provide convenient ways to map JSON data into native Swift objects. However, basic enum-based approaches can become problematic as projects evolve and APIs introduce new values.
The Problem with Standard Decodable Enums
When mapping API responses with enums, new undeclared values cause complete decoding failures. For instance, if a conference app maps event categories as a String-based enum:
enum Category: String, Codable {
case talk
case workshop
}
Adding a new “roundtable” category breaks the entire decoding process, failing even for valid JSON containing that new value.
Traditional Workaround Limitations
The common solution adds an unknown case:
enum Category: String, Codable {
case talk
case workshop
case unknown
init(from decoder: Decoder) throws {
let rawValue = try decoder.singleValueContainer().decode(String.self)
self = Category(rawValue: rawValue) ?? .unknown
}
}
This approach has three critical drawbacks:
- Data Loss: Original values are discarded, complicating debugging and server communication
- Code Duplication: Every enum requires custom decoding logic
- Scalability Issues: Enums cannot be extended across different modules in modular architectures
A Struct-Based Alternative
Instead of enums, use structs that preserve the original value:
extension Event {
public struct Category: Codable {
public let value: String
public init(from decoder: Decoder) throws {
value = try decoder.singleValueContainer().decode(String.self)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(value)
}
}
}
public extension Event.Category {
static var talk: Self { .init(value: "talk") }
static var workshop: Self { .init(value: "workshop") }
}
Building a Reusable Generic Solution
Create a generic ExtensibleIdentifier type:
public struct ExtensibleIdentifier<Value: Hashable & Sendable & Codable, Tag>:
Hashable, Sendable, Codable {
public var value: Value
public init(_ value: Value) {
self.value = value
}
public init(from decoder: Decoder) throws {
let value = try decoder.singleValueContainer().decode(Value.self)
self.init(value)
}
public func encode(to encoder: any Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(value)
}
public func hash(into hasher: inout Hasher) {
hasher.combine(value)
hasher.combine(ObjectIdentifier(Tag.self))
}
public static func == (lhs: Self, rhs: Self) -> Bool {
lhs.value == rhs.value
}
}
The Tag generic parameter ensures type safety across different contexts without storing values.
Using Typealiases for Convenience
Simplify declarations:
public typealias StringIdentifier<Tag> = ExtensibleIdentifier<String, Tag>
struct Event: Codable {
typealias Category = StringIdentifier<Self>
let title: String
let category: Category
}
Adding Syntactic Sugar
Enable literal expressions through protocol conformances:
extension ExtensibleIdentifier: ExpressibleByStringLiteral where Value == String {
public init(stringLiteral value: String) {
self.init(value)
}
}
public extension Event.Category {
static var talk: Self { "talk" }
static var workshop: Self { "workshop" }
}
Values can now be extended across modules:
public extension Event.Category {
static var roundtable: Self { "roundtable" }
}
Advantages Over Enums
- Preserves Original Data: Maintains raw values for debugging and server communication
- Modular Extensibility: Cases can be defined across different frameworks
- No Decoding Failures: Unknown values don’t break the entire process
- Type Safety: Different identifiers with identical values remain distinct types
- Reusability: Generic implementation eliminates code duplication
Trade-offs
- Loss of
CaseIterableprotocol support - Switch statements require
defaultcases - Less compile-time guarantees about possible values
When to Use Enums Instead
Enums remain appropriate for universally stable values: theme modes (light/dark/system), time formats (AM/PM), or speed units (KMH/MPH) that are unlikely to expand.
Conclusion
This struct-based approach provides superior flexibility for API integration in growing applications. By leveraging Swift’s advanced type system, developers can build safer, more maintainable solutions that scale across modular architectures. The implementation is available in the CodableKitten library currently under development.
