Codable - Applying Data Transformations

Codable may not just all be blindly transforming JSON data into model objects, but you can also apply formatting to it. Examples of formatters include, but not limited to date formatters, URLs, and fractional data types.

In this post we're going to take a look at some of those formatters and see how to use them.

  1. URL formatters

Codable allows for automatic String to URL transformation. The way it is set up, we don't even have to apply any manual logic to do it. Let's take a look at an example. Let's assume this is how our input JSON looks like,

let json = """
            {"name": "Apple", "url": "www.apple.com"}
            """

This is a very simple JSON, so our tiny model looks like this,

struct Company: Decodable {
    let name: String
    let url: URL
}

Now as this bullet point indicates, Codable will take care of automatic conversion from String in JSON to the url property of type URL on Company model . All we need to do is to ask Codable to perform that transform.

do {
    let company = try JSONDecoder().decode(Company.self, from: json.data(using: .utf8)!)
    
    // Prints
    // Company(name: "Apple", url: www.apple.com)
    //    
    print(company)
    
    // po company.url prints  
    // www.apple.com
    //     - _url : www.apple.com
} catch {
    print(error.localizedDescription)
}

2. Date formatter

In this section we are going to take a look at how we can use custom date formatters to automatically convert incoming date string in JSON to internal iOS Date object.

Let's assume that this is how our incoming JSON looks like,

let json = """
            {"name": "Apple", "url": "www.apple.com", "foundationDate": "04/01/1975"}
            """

We will reuse, but extend the earlier used Company struct to include one more parameter, foundationDate

struct Company: Decodable {
    let name: String
    let url: URL
    let foundationDate: Date
}

Since this change involves String to Date conversion using custom DateFormatters, we will also declare our date formatter with known date format on the top.

// You can change DateFormatter as per requirements. More date formatters can be found at
// https://developer.apple.com/library/content/documentation/Cocoa/Conceptual/DataFormatting

lazy var dateFormatter: DateFormatter = {
    let dateFormatter = DateFormatter()
    dateFormatter.dateFormat = "MM/dd/yyyy"
    return dateFormatter
}()
Please note that a dateFormatter is declare as lazy. Meaning it gets initialized only on the first access and for subsequent uses, we use the previously initialized value. This is done for the performance reasons, since creating DateFormatter is slow and causes a significant performance hit

And we will use this dateFormatter when it comes to deciding dateDecodingStrategy for our decoder object.

do {
    let decoder = JSONDecoder()
    
    // This is the crucial step to assign date formatter to our decoder
    decoder.dateDecodingStrategy = .formatted(dateFormatter)
    let decodedDictionary = try decoder.decode(Company.self, from: json.data(using: .utf8)!)
    
    let calendar = Calendar.current
    
    // Prints 4
    print("Month \(calendar.component(.month, from: decodedDictionary.foundationDate))")
    
    // Prints 1
    print("Day \(calendar.component(.day, from: decodedDictionary.foundationDate))")
    
    // Prints 1975
    print("Year \(calendar.component(.year, from: decodedDictionary.foundationDate))")
    
} catch {
    print(error.localizedDescription)
}

3. iso Date Formatter

Earlier we took a look at plane date strings. Now is the time to do some more action dealing with iso formatted dates.

let json = """
            {"name": "Apple", "url": "www.apple.com", "isoFormattedDate": "1975-01-24T21:30:31Z"}
            """

We will slightly alter our Company model for this purpose,

struct Company: Decodable {
    let name: String
    let url: URL
    let isoFormattedDate: Date
}

Since this is a different date format, we will go ahead and introduce newer date formatter conforming to new date that we're getting back from the endpoint,

lazy var iso8601DateFormatter: DateFormatter = {
    let dateFormatter = DateFormatter()
    dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss'Z'"
    return dateFormatter
}()

Once we have everything - Model, JSON and custom date formatter now is the time to go ahead and start decoding incoming JSON

do {
    let decoder = JSONDecoder()
    decoder.dateDecodingStrategy = .formatted(iso8601DateFormatter)
    let decodedDictionary = try decoder.decode(Company.self, from: json.data(using: .utf8)!)

    let calendar = Calendar.current
    
    // Prints Month 1
    print("Month \(calendar.component(.month, from: decodedDictionary.isoFormattedDate))")
    
    // Prints Day 24
    print("Day \(calendar.component(.day, from: decodedDictionary.isoFormattedDate))")
    
    // Prints Year 1975
    print("Year \(calendar.component(.year, from: decodedDictionary.isoFormattedDate))")

    // Prints Hour 21
    print("Hour \(calendar.component(.hour, from: decodedDictionary.isoFormattedDate))")
    
    // Prints Minute 30
    print("Minute \(calendar.component(.minute, from: decodedDictionary.isoFormattedDate))")
    
    // Prints second 31
    print("Second \(calendar.component(.second, from: decodedDictionary.isoFormattedDate))")

} catch {
    print(error.localizedDescription)
}

4. Building your custom decoder

Codable also makes it easy to build your custom formatter if you want to apply additional mapping to values you get back from server.

let json = """
        {"firstName": "Apple", "lastName": "Corporation", "marketValue": "100000", "numberOfEmployees": "6000"}
        """

Unfortunately, this is not the data we directly want to use in our app. To mitigate this issue, we will implement our custom decoder. Below are the things we want to achieve by building custom formatter,

  1. To convert first and last name into full name
  2. Convert market value in localized currency string format
  3. Convert number of employees from string to int format

Here's the full implementation of struct company to be able to apply all these custom conversions,

struct Company: Decodable {
    let fullName: String
    let formattedMarketCap: String?
    let numberOfEmployees: Int?

    static let currencyFormatter: NumberFormatter = {
        let formatter = NumberFormatter()
        formatter.numberStyle = .currency
        formatter.locale = Locale.current
        return formatter
    }()

    enum CodingKeys: String, CodingKey {
        case firstName
        case lastName
        case marketValue
        case numberOfEmployees
    }

    init(from decoder: Decoder) throws {
        let values = try decoder.container(keyedBy: CodingKeys.self)
        let firstName = try values.decode(String.self, forKey: .firstName)
        let lastName = try values.decode(String.self, forKey: .lastName)
        self.fullName = "\(firstName) \(lastName)"

        let marketValue = try values.decode(Double.self, forKey: .marketValue)

        if let marketCapString = Company.currencyFormatter.string(from: NSNumber(value: marketValue)) {
            self.formattedMarketCap = marketCapString
        } else {
            self.formattedMarketCap = nil
        }

        let numberOfEmployees = try values.decode(String.self, forKey: .numberOfEmployees)
        self.numberOfEmployees = Int(numberOfEmployees)
    }
}
This is a very simple example as long as these mappings are concerned, but it explains how you can utilize the power of custom decoders to apply any transformations you want

Once we have our data model and applicable transformation rules, now is the time to go ahead and convert it into respective Codable object

let companyDetails = """
{"firstName": "Apple", "lastName": "Corporation", "marketValue": 100000.11, "numberOfEmployees": "6000"}
"""

do {
    let decoder = JSONDecoder()
    let company = try decoder.decode(Company.self, from: companyDetails.data(using: .utf8)!)

    // Prints Full name Apple Corporation 
    print("Full name \(company.fullName)")
    
    // Prints Formatted Market Cap $100,000.11
    print("Formatted Market Cap \(company.formattedMarketCap ?? "")")
    
    // Prints Number of Employees 6000
    print("Number of Employees \(company.numberOfEmployees ?? 0)")
} catch {
    print(error.localizedDescription)
}

This is all for now. If you come across additional date formatters or want me to write about some more date formatters that I wasn't aware, feel free to leave comments.

Thanks for reading, and I hope you all have a great Thanksgiving!

References: