Codable Macros Make Swift Serialization So Simple!
Hello everyone! As Swift developers, we deal with data every day, and the conversion between JSON and models is undoubtedly a daily task. Apple provided us with the Codable
protocol, which performs well in many situations, but as business logic becomes more complex, we often find ourselves stuck writing a lot of boilerplate code: manually defining CodingKeys
, implementing init(from:)
and encode(to:)
, handling nested structures, dealing with different naming styles, parsing various date formats... These tedious tasks are not only time-consuming but also error-prone.
Is there a more elegant and efficient way to handle Codable in Swift?
The answer is definitely yes! With Swift Macros introduced in Swift 5.9+, the possibilities for code generation have been greatly expanded. Today, I'm introducing a framework built on Swift Macros called ReerCodable!
ReerCodable (https://github.com/reers/ReerCodable) aims to completely simplify the Codable
experience through declarative annotations, allowing you to say goodbye to tedious boilerplate code and focus on business logic itself.
Practical Application Example
Let's look at a practical example to see how ReerCodable simplifies development work. Suppose we have a complex API response:
{ "code": 0, "data": { "user_info": { "user_name": "phoenix", "birth_date": "1990-01-01T00:00:00Z", "location": { "city": "Beijing", "country": "China" }, "height_in_meters": 1.85, "is_vip": true, "tags": ["tech", null, "swift"], "last_login": 1731585275944 } } }
Using ReerCodable, we can define our model like this:
@Codable struct ApiResponse { var code: Int @CodingKey("data.user_info") var userInfo: UserInfo } @Codable @SnakeCase struct UserInfo { var userName: String @DateCoding(.iso8601) var birthDate: Date @CodingKey("location.city") var city: String @CodingKey("location.country") var country: String @CustomCoding<Double>( decode: { return try $0.value(forKeys: "height_in_meters") * 100.0 }, encode: { try $0.set($1 / 100.0, forKey: "height_in_meters") } ) var heightInCentimeters: Double var isVip: Bool @CompactDecoding var tags: [String] @DateCoding(.millisecondsSince1970) var lastLogin: Date } // Usage do { // The original way remains unchanged let resp = try JSONDecoder().decode(ApiResponse.self, from: jsonString.data(using: .utf8)!) // Convenient method provided by ReerCodable let response = try ApiResponse.decode(from: jsonString) print("Username: \(response.userInfo.userName)") print("Birth date: \(response.userInfo.birthDate)") print("Height(cm): \(response.userInfo.heightInCentimeters)") } catch { print("Parsing failed: \(error)") }
The "Pain" of Native Codable
Before we dive into the magic of ReerCodable, let's review the common pain points when using native Codable
:
- Manual
CodingKeys
: When JSON keys don't match property names, you need to manually write theCodingKeys
enum. Even if you only modify one property, you have to write all other properties as well. This might be manageable with few properties, but becomes a nightmare once you have many. - Nested Keys: When dealing with deeply nested JSON data, you need to define multiple intermediate structures or manually write decoding logic.
- Naming Style Conversion: Backend returns snake_case or kebab-case, while Swift recommends camelCase, requiring manual mapping.
- Complex Decoding Logic: If you need custom decoding (type conversion, data fixing, etc.), you have to implement
init(from:)
. - Default Value Handling: For non-Optional properties missing in JSON, an exception will be thrown even if a default value exists. Even Optional enums can fail decoding.
- Ignoring Properties: Some properties don't need to participate in encoding/decoding, requiring manual handling in
CodingKeys
or implementations. - Various Date Formats: Timestamps, ISO8601, custom formats... require configuring different
dateDecodingStrategy
forJSONDecoder
or manual handling. -
null
in Collections: When arrays or dictionaries containnull
values, decoding fails if the corresponding type is non-Optional. - Inheritance: Parent class properties can't be automatically handled in the child class's
Codable
implementation. - Enum Handling: Enums with associated values or those needing to match multiple raw values have limited support in native
Codable
.
Community Status
To solve JSON serialization problems, the Swift community has produced many excellent third-party frameworks. Understanding their design philosophies and pros/cons helps us better understand why Swift Macros-based solutions are a better choice today.
1. Frameworks Based on Custom Protocols
ObjectMapper
ObjectMapper is one of the earliest Swift JSON parsing libraries, based on a custom Mappable
protocol:
class User: Mappable { var name: String? var age: Int? required init?(map: Map) {} func mapping(map: Map) { name <- map["user_name"] age <- map["user_age"] } }
Features:
- Not dependent on Swift's native Codable
- Not dependent on reflection mechanisms
- Custom operator
<-
makes mapping code concise - Requires manually writing mapping relationships
- Supports nested mapping and custom conversions
ObjectMapper's advantage is relatively concise code that doesn't depend on Swift's internal implementation details, but its disadvantage is requiring manual mapping code and being incompatible with Swift's native serialization mechanism.
2. Frameworks Based on Runtime Reflection
HandyJSON and KakaJSON
These frameworks adopt similar implementation principles, both obtaining type information through runtime reflection:
struct User: HandyJSON { var name: String? var age: Int? } // Usage let user = User.deserialize(from: jsonString)
Features:
- Obtain type metadata through low-level runtime reflection
- Directly manipulate memory for property assignment
- Almost no additional code required
- Relatively high performance
The main problem with these frameworks is their strong dependence on Swift's internal implementation details and metadata structure, making them prone to incompatibility issues or crashes as Swift versions upgrade. They achieve the ideal of "zero code" but sacrifice stability and safety.
3. Frameworks Based on Property Wrappers
ExCodable and BetterCodable
These frameworks leverage property wrappers introduced in Swift 5.1 to extend Codable:
struct User: Codable { @CustomKey("user_name") var name: String @DefaultValue(33) var age: Int }
Features:
- Based on Swift's native Codable
- Use property wrappers to simplify common encoding/decoding tasks
- No need to manually write CodingKeys and Codable implementations
- Type-safe, compile-time checking
Property wrapper solutions have obvious advantages over the previous two categories: they maintain compatibility with Swift's native Codable while simplifying code writing. However, PropertyWrappers have limited capabilities, and some complex encapsulation designs can't be achieved.
4. Frameworks Based on Macros
CodableWrapper, CodableWrappers, MetaCodable, and this article's ReerCodable
These frameworks leverage macro features introduced in Swift 5.9 to generate Codable implementation code at compile time:
@Codable struct User { @CodingKey("user_name") var name: String var age: Int = 33 }
Features:
- Based on Swift's native Codable
- Declarative syntax, intuitive and easy to understand
- Highly flexible, supporting complex encoding/decoding logic
- Can apply macros at the type level
The macro approach combines the advantages of all previous approaches while avoiding their disadvantages: it's based on native Codable, maintaining type safety; it supports declarative syntax, keeping code concise; it generates code at compile time without runtime performance loss; it can handle complex scenarios with strong adaptability.
Why are Macros the Most Elegant Solution?
Among all these frameworks, macro-based solutions (like ReerCodable) provide the most elegant solution for the following reasons:
- Seamless Integration with Native Codable: Generated code is identical to handwritten Codable implementations, working perfectly with other APIs using Codable. For modern third-party frameworks like Alamofire, GRDB, etc., they're all compatible with Codable.
- Support for Third-party Encoders/Decoders with Codable: If you don't want to use Foundation's decoder, you can use third-party libraries.
- Declarative Syntax: Declare serialization requirements through annotations, making code concise, intuitive, and with clear intentions.
- Type Safety: All operations undergo type checking at compile time, avoiding runtime errors.
- High Flexibility: Can handle various complex scenarios such as nested structures, custom conversions, conditional encoding/decoding, etc.
- Good Maintainability: Macro-generated code is predictable and doesn't depend on Swift's internal implementation details, avoiding compatibility issues with Swift version updates.
- Strong Debuggability: You can view the expanded code after macro execution, facilitating understanding and debugging.
- Extensibility: Different macros can be combined to build complex encoding/decoding logic.
ReerCodable: Magic that Simplifies Complexity
ReerCodable leverages the power of Swift Macros, allowing you to automatically generate efficient, robust Codable
implementations by simply adding annotations before types or properties. The core is the @Codable
macro, which works with other macros provided by ReerCodable to generate the final encoding/decoding logic. The framework supports both Cocoapods and SwiftPackageManager.
The code implementation references excellent projects like winddpan/CodableWrapper, GottaGetSwifty/CodableWrappers, and MetaCodable, but ReerCodable offers richer features or more concise usage compared to them.
Let's see how ReerCodable elegantly solves the pain points mentioned earlier:
1. Custom CodingKey
Use @CodingKey
to specify custom keys for properties without manually writing the CodingKeys
enum:
ReerCodable
@Codable struct User { @CodingKey("user_name") var name: String @CodingKey("user_age") var age: Int var height: Double }
Codable
struct User: Codable { var name: String var age: Int var height: Double enum CodingKeys: String, CodingKey { case name = "user_name" case age = "user_age" case height } }
2. Nested CodingKey
Support nested key paths through dot notation:
@Codable struct User { @CodingKey("other_info.weight") var weight: Double @CodingKey("location.city") var city: String }
3. Multiple Keys for Decoding
Multiple keys can be specified for decoding, the system will try them in order until successful:
@Codable struct User { @CodingKey("name", "username", "nick_name") var name: String }
4. Name Style Conversion
Support multiple naming style conversions, can be applied to types or individual properties:
@Codable @SnakeCase struct Person { var firstName: String // decoded from "first_name" or encoded to "first_name" @KebabCase var lastName: String // decoded from "last-name" or encoded to "last-name" }
5. Custom Coding Container
Use @CodingContainer
to customize the container path for encoding and decoding, typically used when dealing with heavily nested JSON structures while wanting the model declaration to directly match a sub-level structure:
ReerCodable
@Codable @CodingContainer("data.info") struct UserInfo { var name: String var age: Int }
JSON
{ "code": 0, "data": { "info": { "name": "phoenix", "age": 33 } } }
6. Encoding-Specific Key
Different key names can be specified for the encoding process. Since @CodingKey
may have multiple parameters and can use @SnakeCase
, KebabCase
, etc., decoding may use multiple keys, then encoding will use the first key, or @EncodingKey
can be used to specify the key:
@Codable struct User { @CodingKey("user_name") // decoding uses "user_name", "name" @EncodingKey("name") // encoding uses "name" var name: String }
7. Default Value Support
Default values can be used when decoding fails. Native Codable
throws an exception for non-Optional
properties when the correct value is not parsed, even if an initial value has been set, or even if it's an Optional
type enum:
@Codable struct User { var age: Int = 33 var name: String = "phoenix" // If the `gender` field in the JSON is neither `male` nor `female`, native Codable will throw an exception, whereas ReerCodable won't and instead set it to nil. For example, with `{"gender": "other"}`, this scenario might occur when the client has defined an enum but the server has added new fields in a business context. var gender: Gender? } @Codable enum Gender: String { case male, female }
8. Ignore Properties
Use @CodingIgnored
to ignore specific properties during encoding/decoding. During decoding, non-Optional
properties must have a default value to satisfy Swift initialization requirements. ReerCodable
automatically generates default values for basic data types and collection types. For other custom types, users need to provide default values:
@Codable struct User { var name: String @CodingIgnored var ignore: Set<String> }
9. Base64 Coding
Automatically handle conversion between base64 strings and Data
, [UInt8]
types:
@Codable struct User { @Base64Coding var avatar: Data @Base64Coding var voice: [UInt8] }
10. Collection Type Decoding Optimization
Use @CompactDecoding
to automatically filter null values when decoding arrays, same meaning as compactMap
:
@Codable struct User { @CompactDecoding var tags: [String] // ["a", null, "b"] will be decoded as ["a", "b"] }
At the same time, both Dictionary
and Set
also support the use of @CompactDecoding
for optimization.
11. Date Coding
Support various date format encoding/decoding:
@Codable class DateModel { @DateCoding(.timeIntervalSince2001) var date1: Date @DateCoding(.timeIntervalSince1970) var date2: Date @DateCoding(.secondsSince1970) var date3: Date @DateCoding(.millisecondsSince1970) var date4: Date @DateCoding(.iso8601) var date5: Date @DateCoding(.formatted(Self.formatter)) var date6: Date static let formatter: DateFormatter = { let dateFormatter = DateFormatter() dateFormatter.locale = Locale(identifier: "en_US_POSIX") dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS" dateFormatter.timeZone = TimeZone(secondsFromGMT: 0) return dateFormatter }() }
JSON
{ "date1": 1431585275, "date2": 1731585275.944, "date3": 1731585275, "date4": 1731585275944, "date5": "2024-12-10T00:00:00Z", "date6": "2024-12-10T00:00:00.000" }
12. Custom Encoding/Decoding Logic
Implement custom encoding/decoding logic through @CustomCoding
. There are two ways to customize encoding/decoding:
- Through closures, using
decoder: Decoder
,encoder: Encoder
as parameters to implement custom logic:
@Codable struct User { @CustomCoding<Double>( decode: { return try $0.value(forKeys: "height_in_meters") * 100.0 }, encode: { try $0.set($1 / 100.0, forKey: "height_in_meters") } ) var heightInCentimeters: Double }
- Through a custom type implementing the
CodingCustomizable
protocol to implement custom logic:
// 1st 2nd 3rd 4th 5th -> 1 2 3 4 5 struct RankTransformer: CodingCustomizable { typealias Value = UInt static func decode(by decoder: any Decoder, keys: [String]) throws -> UInt { var temp: String = try decoder.value(forKeys: keys) temp.removeLast(2) return UInt(temp) ?? 0 } static func encode(by encoder: Encoder, key: String, value: Value) throws { try encoder.set(value, forKey: key) } } @Codable struct HundredMeterRace { @CustomCoding(RankTransformer.self) var rank: UInt }
During custom implementation, the framework provides methods that can make encoding/decoding more convenient:
public extension Decoder { func value<Value: Decodable>(forKeys keys: String...) throws -> Value { let container = try container(keyedBy: AnyCodingKey.self) return try container.decode(type: Value.self, keys: keys) } } public extension Encoder { func set<Value: Encodable>(_ value: Value, forKey key: String, treatDotAsNested: Bool = true) throws { var container = container(keyedBy: AnyCodingKey.self) try container.encode(value: value, key: key, treatDotAsNested: treatDotAsNested) } }
13. Inheritance Support
Use @InheritedCodable
for better support of subclass encoding/decoding. Native Codable
cannot parse subclass properties, even if the value exists in JSON, requiring manual implementation of init(from decoder: Decoder) throws
:
@Codable class Animal { var name: String } @InheritedCodable class Cat: Animal { var color: String }
14. Enum Support
Provide rich encoding/decoding capabilities for enums:
- Support for basic enum types and RawValue enums:
@Codable struct User { let gender: Gender let rawInt: RawInt let rawDouble: RawDouble let rawDouble2: RawDouble2 let rawString: RawString } @Codable enum Gender { case male, female } @Codable enum RawInt: Int { case one = 1, two, three, other = 100 } @Codable enum RawDouble: Double { case one, two, three, other = 100.0 } @Codable enum RawDouble2: Double { case one = 1.1, two = 2.2, three = 3.3, other = 4.4 } @Codable enum RawString: String { case one, two, three, other = "helloworld" }
- Support using
CodingCase(match: ....)
to match multiple values or ranges:
@Codable enum Phone: Codable { @CodingCase(match: .bool(true), .int(10), .string("iphone"), .intRange(22...30)) case iPhone @CodingCase(match: .int(12), .string("MI"), .string("xiaomi"), .doubleRange(50...60)) case xiaomi @CodingCase(match: .bool(false), .string("oppo"), .stringRange("o"..."q")) case oppo }
-
For enums with associated values, support using
AssociatedValue
to match associated values, use.label()
to declare matching logic for labeled associated values, use.index()
to declare matching logic for unlabeled associated values.ReerCodable
supports two JSON formats for enum matching:- The first is also supported by native
Codable
, where the enum value and its associated values have a parent-child structure:
@Codable enum Video: Codable { /// { /// "YOUTUBE": { /// "id": "ujOc3a7Hav0", /// "_1": 44.5 /// } /// } @CodingCase(match: .string("youtube"), .string("YOUTUBE")) case youTube /// { /// "vimeo": { /// "ID": "234961067", /// "minutes": 999999 /// } /// } @CodingCase( match: .string("vimeo"), values: [.label("id", keys: "ID", "Id"), .index(2, keys: "minutes")] ) case vimeo(id: String, duration: TimeInterval = 33, Int) /// { /// "tiktok": { /// "url": "https://example.com/video.mp4", /// "tag": "Art" /// } /// } @CodingCase( match: .string("tiktok"), values: [.label("url", keys: "url")] ) case tiktok(url: URL, tag: String?) }
- The second is where enum values and their associated values are at the same level or have custom matching structures, using CaseMatcher with key path for custom path value matching:
@Codable enum Video1: Codable { /// { /// "type": { /// "middle": "youtube" /// } /// } @CodingCase(match: .string("youtube", at: "type.middle")) case youTube /// { /// "type": "vimeo", /// "ID": "234961067", /// "minutes": 999999 /// } @CodingCase( match: .string("vimeo", at: "type"), values: [.label("id", keys: "ID", "Id"), .index(2, keys: "minutes")] ) case vimeo(id: String, duration: TimeInterval = 33, Int) /// { /// "type": "tiktok", /// "media": "https://example.com/video.mp4", /// "tag": "Art" /// } @CodingCase( match: .string("tiktok", at: "type"), values: [.label("url", keys: "media")] ) case tiktok(url: URL, tag: String?) }
- The first is also supported by native
15. Lifecycle Callbacks
Support encoding/decoding lifecycle callbacks:
@Codable class User { var age: Int func didDecode(from decoder: any Decoder) throws { if age < 0 { throw ReerCodableError(text: "Invalid age") } } func willEncode(to encoder: any Encoder) throws { // Process before encoding } } @Codable struct Child: Equatable { var name: String mutating func didDecode(from decoder: any Decoder) throws { name = "reer" } func willEncode(to encoder: any Encoder) throws { print(name) } }
16. JSON Extension Support
Provide convenient JSON string and dictionary conversion methods:
let jsonString = "{\"name\": \"Tom\"}" let user = try User.decode(from: jsonString) let dict: [String: Any] = ["name": "Tom"] let user2 = try User.decode(from: dict)
17. Basic Type Conversion
Support automatic conversion between basic data types:
@Codable struct User { @CodingKey("is_vip") var isVIP: Bool // "1" or 1 can be decoded as true @CodingKey("score") var score: Double // "100" or 100 can be decoded as 100.0 }
18. AnyCodable Support
Implement encoding/decoding of Any
type through AnyCodable
:
@Codable struct Response { var data: AnyCodable // Can store data of any type var metadata: [String: AnyCodable] // Equivalent to [String: Any] type }
19. Generate Default Instance
@Codable @DefaultInstance struct ImageModel { var url: URL } @Codable @DefaultInstance struct User5 { let name: String var age: Int = 22 var uInt: UInt = 3 var data: Data var date: Date var decimal: Decimal = 8 var uuid: UUID var avatar: ImageModel var optional: String? = "123" var optional2: String? }
Will generate the following instance:
static let `default` = User5( name: "", age: 22, uInt: 3, data: Data(), date: Date(), decimal: 8, uuid: UUID(), avatar: ImageModel.default, optional: "123", optional2: nil )
⚠️ Note: Properties with generic types are NOT supported with @DefaultInstance
:
@Codable struct NetResponse<Element: Codable> { let data: Element? let msg: String private(set) var code: Int = 0 }
20. Generate Copy Method
Use Copyable
to generate copy
method for models:
@Codable @Copyable public struct Model6 { var name: String let id: Int var desc: String? } @Codable @Copyable class Model7<Element: Codable> { var name: String let id: Int var desc: String? var data: Element? }
Generates the following copy
methods. As you can see, besides default copy, you can also update specific properties:
public func copy( name: String? = nil, id: Int? = nil, desc: String? = nil ) -> Model6 { return .init( name: name ?? self.name, id: id ?? self.id, desc: desc ?? self.desc ) } func copy( name: String? = nil, id: Int? = nil, desc: String? = nil, data: Element? = nil ) -> Model7 { return .init( name: name ?? self.name, id: id ?? self.id, desc: desc ?? self.desc, data: data ?? self.data ) }
21. Use @Decodable
or @Encodable
alone
@Decodable struct Item: Equatable { let id: Int } @Encodable struct User3: Equatable { let name: String }
These examples demonstrate the main features of ReerCodable, which can help developers greatly simplify the encoding/decoding process, improving code readability and maintainability.
About Performance
ReerCodable theoretically has the same performance as native Codable, but early Foundation JSONDecoder performance wasn't good, so the community created the following frameworks:
- ZippyJSON (Implemented Decoder, Encoder in C++)
- Ananda (Based on yyjson)
- IkigaJSON (Implemented Decoder, Encoder in Swift)
From ZippyJSON's homepage description, it seems Apple optimized decoding performance in iOS17+ and surpassed ZippyJSON:
Note: JSONDecoder is faster than ZippyJSON for iOS 17+. The rest of this document describes the performance difference pre-iOS 17.
I used ReerCodable to annotate models and their properties, comparing the decoding performance of Foundation JSONDecoder, ZippyJSON, and IkigaJSON:
name time std iterations ----------------------------------------------------------------------------- JSON解码性能对比.创建 Foundation JSONDecoder 83.000 ns ± 95.19 % 1000000 JSON解码性能对比.创建 IkigaJSONDecoder 41.000 ns ± 96.06 % 1000000 JSON解码性能对比.创建 ZippyJSONDecoder 41.000 ns ± 77.25 % 1000000 JSON解码性能对比.Foundation JSONDecoder - 标准数据 313791.000 ns ± 3.63 % 4416 JSON解码性能对比.IkigaJSONDecoder - 标准数据 377583.000 ns ± 6.30 % 3692 JSON解码性能对比.ZippyJSONDecoder - 标准数据 310792.000 ns ± 3.62 % 4395 JSON解码性能对比.Foundation JSONDecoder - 小数据集 88334.000 ns ± 4.35 % 15706 JSON解码性能对比.IkigaJSONDecoder - 小数据集 98333.000 ns ± 4.96 % 14095 JSON解码性能对比.ZippyJSONDecoder - 小数据集 87625.000 ns ± 5.34 % 15747 JSON解码性能对比.Foundation JSONDecoder - 大数据集 5537916.500 ns ± 1.61 % 252 JSON解码性能对比.IkigaJSONDecoder - 大数据集 6445166.000 ns ± 2.30 % 217 JSON解码性能对比.ZippyJSONDecoder - 大数据集 5376375.000 ns ± 1.68 % 259 JSON解码性能对比.Foundation JSONDecoder - 嵌套结构 9167.000 ns ± 8.38 % 149385 JSON解码性能对比.IkigaJSONDecoder - 嵌套结构 10375.000 ns ± 13.73 % 131397 JSON解码性能对比.ZippyJSONDecoder - 嵌套结构 8458.000 ns ± 10.45 % 161606 JSON解码性能对比.Foundation JSONDecoder - 数组解析 2562250.000 ns ± 2.08 % 542 JSON解码性能对比.IkigaJSONDecoder - 数组解析 3620500.000 ns ± 1.63 % 385 JSON解码性能对比.ZippyJSONDecoder - 数组解析 2503709.000 ns ± 1.94 % 555
As seen above, ZippyJSONDecoder still has the best performance, with Foundation JSONDecoder very close behind, much better than older versions of JSONDecoder.
Additionally, native Foundation.JSONDecoder decoding performance still hasn't surpassed Ananda, according to AnandaBenchmark. The following benchmark data shows Ananda's performance is about twice that of Foundation.JSONDecoder, so if you have very high performance requirements, consider using Ananda. It also uses Swift macros for some convenient encapsulation, but generally speaking, native Codable no longer has performance issues:
name time std iterations ------------------------------------------------------------ Codable decoding 5125.000 ns ± 14.92 % 271764 Ananda decoding 2541.000 ns ± 38.26 % 541187 Ananda decoding with Macro 2541.000 ns ± 64.55 % 550339
Conclusion
ReerCodable greatly simplifies the use of Codable
through a series of carefully designed Swift Macros, significantly reducing boilerplate code and improving development efficiency and code readability. It not only covers most scenarios of native Codable
but also provides more powerful and flexible features such as multi-key decoding, name conversion, custom containers, robust default value handling, powerful enum support, and convenient auxiliary tools.
If you're still troubled by the tedious implementation of Codable
, try ReerCodable, and it will surprise you!
GitHub address: https://github.com/reers/ReerCodable
Welcome to try it out, star the repository, submit issues or pull requests! Let's write Swift code in a more modern and elegant way together!
This article was mainly generated by AI, please refer to the GitHub readme for specifics.
Top comments (0)