Codable Decode Issues Explained|Master Decodable for Real-World iOS Challenges
iOS developers facing Codable decode errors can resolve complex scenarios with practical solutions from basic to advanced Decodable techniques, ensuring robust data parsing and app stability.
点击这里查看本文章简体中文版本。
點擊這裡查看本文章正體中文版本。
This post was translated with AI assistance — let me know if anything sounds off!
Real-World Codable Decoding Issues Overview (Part 1)
From Basics to Advanced: Deep Dive into Using Decodable to Handle All Possible Problem Scenarios
Photo by Gustas Brazaitis
Introduction
Due to the backend API upgrade, we needed to adjust the API handling architecture. Recently, we took this opportunity to update the original network handling framework, which was written in Objective-C, to Swift. Because of the language difference, it was no longer suitable to use the original Restkit for managing the network layer. However, Restkit was incredibly powerful with many features and was used effectively in the project with hardly any major issues. On the downside, it was very heavy, almost no longer maintained, and purely Objective-C; it will inevitably need to be replaced in the future.
Restkit almost handles all the features needed for network requests, from basic network handling, API calls, network processing, to response handling like converting JSON strings to objects, and even saving objects into Core Data. It’s truly a framework that can do the work of ten.
With the evolution of time, current frameworks no longer focus on all-in-one solutions. Instead, they emphasize flexibility, lightweight design, and modularity to increase adaptability and create more variations. Therefore, when switching to Swift, we chose Moya as the network handling library and combined other functionalities using different methods as needed.
Main Topic
Regarding the JSON String to Object Mapping section, we use Swift’s built-in Codable (Decodable) protocol and JSONDecoder for processing; we also separate Entity/Model to enhance responsibility division, operation, and readability. Additionally, the codebase mixes Objective-C and Swift, which must be taken into account.
* The Encodable part is omitted; examples only show Decodable implementation. They are similar—if you can decode, you can also encode.
Start
Assuming our initial API Response JSON String is as follows:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"id": 123456,
"comment": "It is '告五人', not '五告人'!",
"target_object": {
"type": "song",
"id": 99,
"name": "Thinking of You Under the Stars and Moon"
},
"commenter": {
"type": "user",
"id": 1,
"name": "zhgchgli",
"email": "zhgchgli@gmail.com"
}
}
From the above example, we can split into three Entities & Models: User, Song, and Comment, allowing us to reuse components. For convenience, we first write the Entities/Models in the same file for demonstration.
Please provide the Markdown paragraphs you want me to translate into English.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Entity:
struct UserEntity: Decodable {
var id: Int
var name: String
var email: String
}
// Model:
class UserModel: NSObject {
init(_ entity: UserEntity) {
self.id = entity.id
self.name = entity.name
self.email = entity.email
}
var id: Int
var name: String
var email: String
}
Song:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Entity:
struct SongEntity: Decodable {
var id: Int
var name: String
}
// Model:
class SongModel: NSObject {
init(_ entity: SongEntity) {
self.id = entity.id
self.name = entity.name
}
var id: Int
var name: String
}
Comment:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// Entity:
struct CommentEntity: Decodable {
enum CodingKeys: String, CodingKey {
case id
case comment
case targetObject = "target_object"
case commenter
}
var id: Int
var comment: String
var targetObject: SongEntity
var commenter: UserEntity
}
//Model:
class CommentModel: NSObject {
init(_ entity: CommentEntity) {
self.id = entity.id
self.comment = entity.comment
self.targetObject = SongModel(entity.targetObject)
self.commenter = UserModel(entity.commenter)
}
var id: Int
var comment: String
var targetObject: SongModel
var commenter: UserModel
}
JSONDecoder:
1
2
3
4
5
6
7
let jsonString = "{ \"id\": 123456, \"comment\": \"It's '告五人', not '五告人'!\", \"target_object\": { \"type\": \"song\", \"id\": 99, \"name\": \"披星戴月的想你\" }, \"commenter\": { \"type\": \"user\", \"id\": 1, \"name\": \"zhgchgli\", \"email\": \"zhgchgli@gmail.com\" } }"
let jsonDecoder = JSONDecoder()
do {
let result = try jsonDecoder.decode(CommentEntity.self, from: jsonString.data(using: .utf8)!)
} catch {
print(error)
}
CodingKeys Enum?
When our JSON string key names do not match the entity object’s property names, we can add an internal CodingKeys enum for mapping. After all, we cannot control the naming conventions of the backend data source.
1
2
case PropertyKeyName = "Backend Field Name"
case PropertyKeyName // If not specified, PropertyKeyName is used as the default backend field name
Once you add the CodingKeys enum, you must list all non-Optional fields; you cannot list only the keys you want to customize.
Another way is to set the JSONDecoder’s keyDecodingStrategy. If the response data fields and property names differ only by snake_case
<-> camelCase
, you can directly set .keyDecodingStrategy
= .convertFromSnakeCase
to automatically match the mapping.
1
2
3
let jsonDecoder = JSONDecoder()
jsonDecoder.keyDecodingStrategy = .convertFromSnakeCase
try jsonDecoder.decode(CommentEntity.self, from: jsonString.data(using: .utf8)!)
When the returned data is an array:
1
2
3
struct SongListEntity: Decodable {
var songs: [SongEntity]
}
Adding Constraints to String:
1
2
3
4
5
6
7
8
9
10
11
struct SongEntity: Decodable {
var id: Int
var name: String
var type: SongType
enum SongType {
case rock
case pop
case country
}
}
Applicable to limited string types, written as Enum for easy passing and usage; if a value outside the enum appears, decoding will fail!
Use Generics to Wrap Fixed Structures:
Assuming multiple returned JSON strings have a fixed format:
1
2
3
4
5
6
7
8
9
10
11
12
{
"count": 10,
"offset": 0,
"limit": 0,
"results": [
{
"type": "song",
"id": 1,
"name": "1"
}
]
}
It can be wrapped using a generic approach:
1
2
3
4
5
6
struct PageEntity<E: Decodable>: Decodable {
var count: Int
var offset: Int
var limit: Int
var results: [E]
}
Use: PageEntity<Song>.self
Date/Timestamp Auto Decode:
Setting JSONDecoder
’s dateDecodingStrategy
.secondsSince1970/.millisecondsSince1970
: unix timestamp.deferredToDate
: Apple’s timestamp, rarely used, different from unix timestamp, counted from 2001/01/01.iso8601
: ISO 8601 date format.formatted(DateFormatter)
: Decode Date according to the passed DateFormatter.custom
: Custom Date Decode logic
.custom Example: Assume the API returns both YYYY/MM/DD and ISO 8601 formats, and both need to be decoded:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
var dateFormatter = DateFormatter()
var iso8601DateFormatter = ISO8601DateFormatter()
let decoder: JSONDecoder = JSONDecoder()
decoder.dateDecodingStrategy = .custom({ (decoder) -> Date in
let container = try decoder.singleValueContainer()
let dateString = try container.decode(String.self)
// ISO8601:
if let date = iso8601DateFormatter.date(from: dateString) {
return date
}
// YYYY-MM-DD:
dateFormatter.dateFormat = "yyyy-MM-dd"
if let date = dateFormatter.date(from: dateString) {
return date
}
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Cannot decode date string \(dateString)")
})
let result = try jsonDecoder.decode(CommentEntity.self, from: jsonString.data(using: .utf8)!)
*DateFormatter is very performance-intensive during initialization; reuse it as much as possible.
Basic Decode Knowledge:
The field types (struct/class/enum) within the Decodable Protocol must all conform to the Decodable Protocol; alternatively, values can be assigned during the init decoder.
Decode fails when the field types do not match.
If a field in a Decodable Object is set as Optional, it means it is optional; if provided, it will be decoded.
Optional fields can accept: JSON String with no fields, or given but set to nil.
Blank and 0 are not equal to nil; nil is nil. Be careful with weakly typed backend APIs!
By default, if a Decodable Object has an enum field that is non-Optional, decoding will fail if the JSON string does not provide a value (handling this will be explained later).
By default, a decode failure will immediately stop the process, making it impossible to simply skip the erroneous data (handling will be explained later).
Advanced Usage
So far, the basic usage is complete, but the real world is not that simple. Below are some advanced scenarios and Codable-based solutions. From this point on, we can no longer rely on the default Decode to handle mapping for us; we need to implement init(from decoder: Decoder)
for custom decoding.
*Here, only the Entity part is shown temporarily; the Model is not needed yet.
init(from decoder: Decoder)
init decoder must assign initial values to all non-Optional fields (that’s what init is for!).
When customizing the Decode operation, we need to get the container
from the decoder
to extract values. There are three types of content retrieval for the container
.
The first type container(keyedBy: CodingKeys.self) Operates according to CodingKeys:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct SongEntity: Decodable {
var id: Int
var name: String
enum CodingKeys: String, CodingKey {
case id
case name
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.id = try container.decode(Int.self, forKey: .id)
// Parameter 1 accepts support: a class implementing Decodable
// Parameter 2 CodingKeys
self.name = try container.decode(String.self, forKey: .name)
}
}
The second singleValueContainer Extract the entire package for operation (single value):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
enum HandsomeLevel: Decodable {
case handsome(String)
case normal(String)
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
let name = try container.decode(String.self)
if name == "zhgchgli" {
self = .handsome(name)
} else {
self = .normal(name)
}
}
}
struct UserEntity: Decodable {
var id: Int
var name: HandsomeLevel
var email: String
enum CodingKeys: String, CodingKey {
case id
case name
case email
}
}
Applicable to Associated Value Enum field types, such as name which also includes a coolness level!
The third type: unkeyedContainer Treat the entire package as an array:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
struct ListEntity: Decodable {
var items:[Decodable]
init(from decoder: Decoder) throws {
var unkeyedContainer = try decoder.unkeyedContainer()
self.items = []
while !unkeyedContainer.isAtEnd {
// The internal pointer of unkeyedContainer automatically moves to the next object after decode operation
// When it reaches the end, it means the iteration is complete
if let id = try? unkeyedContainer.decode(Int.self) {
items.append(id)
} else if let name = try? unkeyedContainer.decode(String.self) {
items.append(name)
}
}
}
}
let jsonString = "[\"test\",1234,5566]"
let jsonDecoder = JSONDecoder()
let result = try jsonDecoder.decode(ListEntity.self, from: jsonString.data(using: .utf8)!)
print(result)
Applicable to array fields with variable types.
Under a Container, we can also use nestedContainer / nestedUnkeyedContainer to operate on specific fields:
*Flatten Data Fields (similar to flatMap)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
struct ListEntity: Decodable {
enum CodingKeys: String, CodingKey {
case items
case date
case name
case target
}
enum PredictKey: String, CodingKey {
case type
}
var date: Date
var name: String
var items: [Decodable]
var target: Decodable
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.date = try container.decode(Date.self, forKey: .date)
self.name = try container.decode(String.self, forKey: .name)
let nestedContainer = try container.nestedContainer(keyedBy: PredictKey.self, forKey: .target)
let type = try nestedContainer.decode(String.self, forKey: .type)
if type == "song" {
self.target = try container.decode(SongEntity.self, forKey: .target)
} else {
self.target = try container.decode(UserEntity.self, forKey: .target)
}
var unkeyedContainer = try container.nestedUnkeyedContainer(forKey: .items)
self.items = []
while !unkeyedContainer.isAtEnd {
if let song = try? unkeyedContainer.decode(SongEntity.self) {
items.append(song)
} else if let user = try? unkeyedContainer.decode(UserEntity.self) {
items.append(user)
}
}
}
}
Accessing and decoding objects at different levels, the example demonstrates using nestedContainer and flat to output the type, then decoding accordingly based on the type.
Decode & DecodeIfPresent
DecodeIfPresent: Decode only if the response contains the data field (when the Codable property is Optional)
Decode: Perform the decode operation. If the response lacks a data field, an error will be thrown.
*The above is just a brief introduction to the init decoder and container methods and functions. If you don’t understand, it’s okay. Let’s move directly to real-world scenarios and experience how they work together in the examples.
Real-World Scenario
Back to the original example JSON String.
Scenario 1. Suppose a comment could be directed at either a song or a person. The targetObject
field could be either User
or Song
. How should this be handled?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
{
"results": [
{
"id": 123456,
"comment": "It's '告五人', not '五告人'!",
"target_object": {
"type": "song",
"id": 99,
"name": "Thinking of You Under the Stars and Moon"
},
"commenter": {
"type": "user",
"id": 1,
"name": "zhgchgli",
"email": "zhgchgli@gmail.com"
}
},
{
"id": 55,
"comment": "66666!",
"target_object": {
"type": "user",
"id": 1,
"name": "zhgchgli"
},
"commenter": {
"type": "user",
"id": 2,
"name": "aaaa",
"email": "aaaa@gmail.com"
}
}
]
}
Method a.
Using Enum as a Container for Decoding.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
struct CommentEntity: Decodable {
enum CodingKeys: String, CodingKey {
case id
case comment
case targetObject = "target_object"
case commenter
}
var id: Int
var comment: String
var targetObject: TargetObject
var commenter: UserEntity
enum TargetObject: Decodable {
case song(SongEntity)
case user(UserEntity)
enum PredictKey: String, CodingKey {
case type
}
enum TargetObjectType: String, Decodable {
case song
case user
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: PredictKey.self)
let singleValueContainer = try decoder.singleValueContainer()
let targetObjectType = try container.decode(TargetObjectType.self, forKey: .type)
switch targetObjectType {
case .song:
let song = try singleValueContainer.decode(SongEntity.self)
self = .song(song)
case .user:
let user = try singleValueContainer.decode(UserEntity.self)
self = .user(user)
}
}
}
}
We changed the property of targetObject
to an Associated Value Enum, deciding what content to put inside the Enum only during decoding.
The core practice is to create an Enum conforming to Decodable as a container. During decoding, first extract the key field (the type
field in the example JSON string) to determine the type. If it is Song
, use singleValueContainer to decode the entire package into a SongEntity
; similarly for User
.
Only extract from the Enum when needed:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
//if case let
if case let CommentEntity.TargetObject.user(user) = result.targetObject {
print(user)
} else if case let CommentEntity.TargetObject.song(song) = result.targetObject {
print(song)
}
//switch case let
switch result.targetObject {
case .song(let song):
print(song)
case .user(let user):
print(user)
}
Method b.
Change the declaration of the field attribute to Base Class.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
struct CommentEntity: Decodable {
enum CodingKeys: String, CodingKey {
case id
case comment
case targetObject = "target_object"
case commenter
}
enum PredictKey: String, CodingKey {
case type
}
var id: Int
var comment: String
var targetObject: Decodable
var commenter: UserEntity
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.id = try container.decode(Int.self, forKey: .id)
self.comment = try container.decode(String.self, forKey: .comment)
self.commenter = try container.decode(UserEntity.self, forKey: .commenter)
//
let targetObjectContainer = try container.nestedContainer(keyedBy: PredictKey.self, forKey: .targetObject)
let targetObjectType = try targetObjectContainer.decode(String.self, forKey: .type)
if targetObjectType == "user" {
self.targetObject = try container.decode(UserEntity.self, forKey: .targetObject)
} else {
self.targetObject = try container.decode(SongEntity.self, forKey: .targetObject)
}
}
}
The principle is similar, but here we first use nestedContainer
to access targetObject
and get type
for judgment, then decide what type targetObject
should be decoded into.
Cast only when needed:
1
2
3
4
5
if let song = result.targetObject as? Song {
print(song)
} else if let user = result.targetObject as? User {
print(user)
}
Scenario 2. How to Decode When the Data Array Contains Multiple Data Types?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"results": [
{
"type": "song",
"id": 99,
"name": "Missing You Under the Stars"
},
{
"type": "user",
"id": 1,
"name": "zhgchgli",
"email": "zhgchgli@gmail.com"
}
]
}
Combine the mentioned nestedUnkeyedContainer
with the solution from Scenario 1; here, you can also use the a. solution from Scenario 1 to access values with an Associated Value Enum.
Scenario 3. Decode Only When JSON String Field Has Value
1
2
3
4
5
6
7
8
9
10
11
[
{
"type": "song",
"id": 99,
"name": "Missing You Under the Stars"
},
{
"type": "song",
"id": 11
}
]
Use decodeIfPresent for decoding.
Scenario 4. Skip Data with Decode Errors in Array Data
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"results": [
{
"type": "song",
"id": 99,
"name": "Thinking of You Under the Stars and Moon"
},
{
"error": "errro"
},
{
"type": "song",
"id": 19,
"name": "Take Me to Find Nightlife"
}
]
}
As mentioned earlier, Decodable by default requires all data to be correctly parsed to output the mapping; sometimes the backend provides unstable data, giving a long array where a few entries have missing fields or mismatched field types, causing Decode to fail; resulting in the entire batch failing and returning nil.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
struct ResultsEntity: Decodable {
enum CodingKeys: String, CodingKey {
case results
}
var results: [SongEntity]
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
var nestedUnkeyedContainer = try container.nestedUnkeyedContainer(forKey: .results)
self.results = []
while !nestedUnkeyedContainer.isAtEnd {
if let song = try? nestedUnkeyedContainer.decode(SongEntity.self) {
self.results.append(song)
} else {
let _ = try nestedUnkeyedContainer.decode(EmptyEntity.self)
}
}
}
}
struct EmptyEntity: Decodable { }
struct SongEntity: Decodable {
var type: String
var id: Int
var name: String
}
let jsonString = "{ \"results\": [ { \"type\": \"song\", \"id\": 99, \"name\": \"Under the Stars Missing You\" }, { \"error\": \"errro\" }, { \"type\": \"song\", \"id\": 19, \"name\": \"Take Me to Find Nightlife\" } ] }"
let jsonDecoder = JSONDecoder()
let result = try jsonDecoder.decode(ResultsEntity.self, from: jsonString.data(using: .utf8)!)
print(result)
The solution is similar to Scenario 2’s solution; use nestedUnkeyedContainer
to iterate through each item and try? Decode. If decoding fails, use an Empty Decode to allow the internal pointer of nestedUnkeyedContainer
to continue.
*This method is a bit of a workaround because we cannot skip the
nestedUnkeyedContainer
command, andnestedUnkeyedContainer
must successfully decode to continue execution; that’s why it is done this way. Some in the Swift community have proposed adding moveNext(), but it is not yet implemented in the current version.
Scenario 5. Some fields are for internal program use and do not need to be decoded
Method a. Entity/Model
Here, we need to mention the purpose of splitting Entity and Model as stated earlier; Entity is solely responsible for JSON String to Entity (Decodable) mapping; Model initializes with Entity, and all actual program passing, operations, and business logic use Model.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
struct SongEntity: Decodable {
var type: String
var id: Int
var name: String
}
class SongModel: NSObject {
init(_ entity: SongEntity) {
self.type = entity.type
self.id = entity.id
self.name = entity.name
}
var type: String
var id: Int
var name: String
var isSave: Bool = false // business logic
}
Benefits of Splitting Entity/Model:
Clear Responsibilities, Entity: JSON String to Decodable, Model: Business Logic
A clear mapping of which fields to view in the Entity at a glance
Avoid all fields clustering together when there are many columns
Objective-C is also usable (because the Model is just NSObject, struct/Decodable is not visible to Objective-C)
Place the business logic and fields for internal use in the Model only.
Method b. init handling
List the CodingKeys and exclude internally used fields. Providing default values during init, having default values for fields, or setting them as Optional are not good practices; they just allow the code to run.
[2020/06/26 Update] — Part 2 Scenario 6. How to Decode API Response Using 0/1 to Represent Bool?
[2020/06/26 Update] — Next: Scenario 7. Avoid rewriting init decoder every time
[2020/06/26 Update] — Next: Scenario 8. Proper Handling of Response Null Field Data
Comprehensive Scenario Example
A complete example combining both basic and advanced usage:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
{
"count": 5,
"offset": 0,
"limit": 10,
"results": [
{
"id": 123456,
"comment": "It's '告五人', not '五告人'!",
"target_object": {
"type": "song",
"id": 99,
"name": "Thinking of You Under the Stars",
"create_date": "2020-06-13T15:21:42+0800"
},
"commenter": {
"type": "user",
"id": 1,
"name": "zhgchgli",
"email": "zhgchgli@gmail.com",
"birthday": "1994/07/18"
}
},
{
"error": "not found"
},
{
"error": "not found"
},
{
"id": 2,
"comment": "Haha, me too!",
"target_object": {
"type": "user",
"id": 1,
"name": "zhgchgli",
"email": "zhgchgli@gmail.com",
"birthday": "1994/07/18"
},
"commenter": {
"type": "user",
"id": 1,
"name": "RandomUserA",
"email": "man@gmail.com",
"birthday": "2000/01/12"
}
}
]
}
Output:
1
zhgchgli: It's "告五人," not "五告人!"
The complete example demonstration is as above!
Part (2) & Other Scenarios Updated:
Summary
The benefits of choosing Codable are, first of all, that it is native, so you don’t have to worry about lack of maintenance later, and it also results in clean code; however, it has stricter limitations and is less flexible in parsing JSON strings. Otherwise, you need to do more work as shown in this article to complete it. Additionally, its performance is not necessarily better than other mapping libraries (Decodable still uses the Objective-C era NSJSONSerialization for parsing). But I believe Apple may optimize this in future updates, so we won’t have to change the code then.
The scenarios and examples in the article may be extreme, but sometimes you just have to deal with them; of course, we hope that in most cases, simple Codable will meet our needs. However, with the techniques above, there should be no problem that can’t be solved!
Thanks to @saiday for the technical support.
Further Reading
Deep Dive into Decodable — Writing a JSON Parser Beyond the Native One
Full of content, a thorough understanding of Decoder/JSONDecoder.Different Perspectives on Problems — From Codable to Swift Metaprogramming
Why Model Objects Shouldn’t Implement Swift’s Decodable or Encodable Protocols
If you have any questions or suggestions, feel free to contact me.
This post was originally published on Medium (View original post), and automatically converted and synced by ZMediumToMarkdown.