This blog post will present why I’m frustrated with the current state of JSONDecoder and JSONEncoder1, how I think dates should be formatted in a JSON API, and the code I write to handle JSON date decoding and encoding in my applications.

The Problem with Codable’s JSON Support

Swift has 1st-party support for JavaScript script execution. Let’s create a simple JSON payload including a date field in Swift:

let javaScriptContext = JSContext()!
let jsonPayload = javaScriptContext.evaluateScript("""
const payload = { message: '👋', creationDate: new Date() };
JSON.stringify(payload)
""")
// => {"message":"👋","creationDate":"2021-03-28T13:10:35.656Z"}

Now let’s decode this JSON string with Swift:

struct PayloadStruct: Codable {
    let message: String
    let creationDate: Date
}

do {
    _ = try jsonPayload?.toString()?.data(using: .utf8).map {
        try JSONDecoder().decode(PayloadStruct.self, from: $0)
    }
} catch let DecodingError.typeMismatch(type, context) {
    dump(type)
    dump(context) // creationDate: Expected to decode Double but found a string/data instead.
}

This code throws a DecodingError.typeMismatch error with the following description: Expected to decode Double but found a string/data instead.

This is because, by default, a Date type is expected to be a Double specifying the number of seconds since 00:00:00 UTC on 1 January 2001. But the date in our JSON string is formatted as ISO 8601.

Let’s use JSONDecoder’s built-in .iso8601 configuration of DateDecodingStrategy.

do {
    let jsonDecoder = JSONDecoder()
    jsonDecoder.dateDecodingStrategy = .iso8601

    _ = try jsonPayload?.toString()?.data(using: .utf8).map {
        try jsonDecoder.decode(PayloadStruct.self, from: $0)
    }
} catch let DecodingError.dataCorrupted(context) {
    dump(context)
}

Another error? This time, the code throws a DecodingError.dataCorrupted error with the following description: Expected date string to be ISO8601-formatted. What is going on? 🤔

There is no date in JSON, but there is in JS

True, JSON does not specify a format for dates. And if projects such as JSON API do recommend using ISO 8601, because the W3C also thinks that this format makes the most sense, they are not clear about what exact flavor of ISO 8601 should be used.

But let’s remember that JSON stands for JavaScript Object Notation and that JavaScript does specify how to format its date type in JSON.

If you want to refer to March 23rd 2012, at 6:25:43PM UTC timezone, at millisecond 511, then, whatever your own timezone, you should use:

2012-04-23T18:25:43.511Z

Why?2

  • It is readable for humans;
  • It sorts correctly;
  • It includes fractional seconds, which can help re-establish chronology.

In Swift, this format is achieved by using a ISO8601DateFormatter — beware, not a DateFormatter — set up with the formatting options .withInternetDateTime (which is the default), and .withFractionalSeconds.

A Swift class and convenient extensions to manage dates in JSON API

As of today, here are the options for the enum JSONDecoder.DateDecodingStrategy that JSONDecoder can use:

  1. deferredToDate: the default using a TimeInterval (ie an alias to Double) that is not human-readable;
  2. iso8601: this option is using ISO 8601 without fractional seconds, which is against the usage and JavaScript’s spec;
  3. formatted(DateFormatter): never use this since you might — for instance — break for users with a 12-hour AM/PM time formatting, which is a lesson I learned the hard way3;
  4. custom((Decoder) -> Date): 🎉 this is the option we want and here is the version I suggest 👇.
class JavaScriptISO8601DateFormatter {
    static let fractionalSecondsFormatter: ISO8601DateFormatter = {
        let res = ISO8601DateFormatter()
        // The default format options is .withInternetDateTime.
        // We need to add .withFractionalSeconds to parse dates with milliseconds.
        res.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
        return res
    }()

    static let defaultFormatter = ISO8601DateFormatter()

    static func decodedDate(_ decoder: Decoder) throws -> Date {
        let container = try decoder.singleValueContainer()
        let dateAsString = try container.decode(String.self)

        // See warning below.
        for formatter in [fractionalSecondsFormatter, defaultFormatter] {
            if let res = formatter.date(from: dateAsString) {
                return res
            }
        }

        throw DecodingError.dataCorrupted(DecodingError.Context(
            codingPath: decoder.codingPath,
            debugDescription: "Expected date string to be JavaScript-ISO8601-formatted."
        ))
    }

    static func encodeDate(date: Date, encoder: Encoder) throws {
        var container = encoder.singleValueContainer()
        try container.encode(fractionalSecondsFormatter.string(from: date))
    }

    private init() {}
}

extension JSONDecoder.DateDecodingStrategy {
    static func javaScriptISO8601() -> JSONDecoder.DateDecodingStrategy {
        .custom(JavaScriptISO8601DateFormatter.decodedDate)
    }
}

extension JSONDecoder {
    static func javaScriptISO8601() -> JSONDecoder {
        let res = JSONDecoder()
        res.dateDecodingStrategy = .javaScriptISO8601()
        return res
    }
}

extension JSONEncoder.DateEncodingStrategy {
    static func javaScriptISO8601() -> JSONEncoder.DateEncodingStrategy {
        .custom(JavaScriptISO8601DateFormatter.encodeDate)
    }
}

extension JSONEncoder {
    static func javaScriptISO8601() -> JSONEncoder {
        let res = JSONEncoder()
        res.dateEncodingStrategy = .javaScriptISO8601()
        return res
    }
}

⚠️ In this code, I attempt decoding dates with 2 formatters: one that supports fractional seconds and one that does not. The reason is that the JSON of the API my app was consuming was coming from a Kotlin backend that used Java’s DateTimeFormatter that stipulates:

If the nano-of-second is zero or not available then the format is complete.

Statistically speaking, 99,9% of the API would be formatted with fractional seconds — that’s why we attempt decoding dates with this formatter first —, and 0,1% without. 🤷

Usage

let payload = try jsonPayload?.toString()?.data(using: .utf8).map {
    try JSONDecoder.javaScriptISO8601().decode(PayloadStruct.self, from: $0)
}

let reencodedJSONPayload = (try? JSONEncoder.javaScriptISO8601().encode(payload)).flatMap {
    String(data: $0, encoding: .utf8)
}

A playground with the code from this post is available here.

  1. The good news being that the community is currently collecting feedbacks to iterate over Swift’s serialization features.

  2. Please refer to this StackOverflow thread for a more elaborate conversation about this issue.

  3. And knowing that I am not the only one does make me feel better.