If you've done any Web, mobile, or desktop programming in the past decade (and would you even be here if you hadn't?), you've used JSON--the lingua franca for serializing data across devices and platforms. JSON's popularity lies in its simplicity: it supports only a handful of data types, like strings, numerics, Booleans, arrays, and dictionaries, and is generally easy for humans to read. But for strongly-typed languages like Swift, this simplicity could lead to suboptimal parsing strategies. Xcode 9's Swift SDK introduced an elegant way to handle parsing, and that was with the Codable
protocol. When you declare a class
, struct
, or enum
as Codable
, the Swift compiler implicitly generates three things: init(from: Decoder)
, encode(to: Encoder)
, and a CodingKey
enumeration, which maps JSON key values to the names of your Codable
's properties. To make your data type Codable
, you simply declare that it implements Codable
, and ensure that all of its properties are themselves Codable
or are one of the supported types:
Boolean
String
Int
Float
Date
Data
URL
- arrays of
Codable
types - dictionaries of
Codable
types - optionals of all of the above
By default, the JSONDecoder
can parse Date
s only in their raw form--a TimeInterval
(=Double
) of their milliseconds since the reference date: 12:00 a.m. UTC2 on January 1st, 2000. This article's focus is on supporting custom Date
formats, and the journey I took from naïve implementation to full understanding.
Sunrise, Sunset
"Sunrise, Sunset" from The Fiddler on the Roof (1971)
I've recently been working with some REST APIs for looking up sunrise and sunset times for a given latitude and longitude. One of them, Sunrise-Sunset.org, returns the following JSON when called with https://api.sunrise-sunset.org/json?lat=41.948437&lng=-87.655334&date=2019-02-14
3 (omitting some lines for clarity):
{
"results":
{
"sunrise":"12:59:22 PM",
"sunset":"11:09:45 PM"
}
}
I created the following Codable
structures, which I'll call
Approach #1: The Naïve Approach, aka How It Should Work
public struct SunriseSunsetResponse: Codable {
public var results: SunriseSunset
}
public struct SunriseSunset: Codable {
public var sunrise: Date
public var sunset: Date
}
SunriseSunset
is a nested element that represents the value of the "results"
element, and both it and the SunriseSunsetResponse
have to implement Codable
. Now I'll create an instance of SunriseSunsetResponse
by calling the API and parsing it:
if let url = URL(string: "https://api.sunrise-sunset.org/json?lat=41.948437&lng=-87.655334&date=2019-02-04") {
do {
let responseData = try Data(contentsOf: url)
let response = try JSONDecoder().decode(SunriseSunsetResponse.self, from: responseData)
let sunriseSunset = response.results
print("Daylight duration (in seconds): \(sunriseSunset.sunset.timeIntervalSince(sunriseSunset.sunrise))")
} catch {
print(error)
}
}
And...it fails. The error tells says
typeMismatch(Swift.Double, Swift.DecodingError.Context(
codingPath: [CodingKeys(stringValue: "results", intValue: nil),
CodingKeys(stringValue: "sunrise", intValue: nil)],
debugDescription: "Expected to decode Double but found a string/data instead.",
underlyingError: nil))
Did you spot the problem? sunrise
and sunset
properties are Date
s, but the parser found the strings "12:59:22 PM"
and "11:09:45 PM"
instead. How can I fix this, without manipulating the JSON data in some way? Here's what I did:
Approach #2: Storing the dates as String
s
Let's refactor SunriseSunset
to expect date strings, like
public struct SunriseSunset: Codable {
public var sunrise: String
public var sunset: String
}
This shifts the responsibility for parsing the strings into Date
s to the caller. The downside is that if the SunriseSunset
object is used in multiple places, you may wind up with many identical parsing calls. Even if you create a single DateFormatter
instance and used it in multiple places, you'd still wind up violating the dreaded DRY (Don't Repeat Yourself) principle. There must be a better way. Let's try
Approach #3: Keep the String
properties, and add corresponding computed Date
properties
I want to simplify how my SunriseSunset
gets used, so why not make it responsible for parsing the dates itself? I'll add a DateFormatter
property, plus computed Date
properties. This sounds better than the naïve, String
-based approach, even though the Date
versions should be Optional
, because that's what DateFormatter.date(from:)
returns:
public struct SunriseSunset: Codable {
private var dateFormatter: DateFormatter
public var sunrise: String
public var sunriseDate: Date? {
return dateFormatter.date(from: sunrise)
}
public var sunset: String
public var sunsetDate: Date? {
return dateFormatter.date(from: sunset)
}
}
This is starting to look kind of ugly, but it should suit my purposes. But now it won't compile! Remember that every property of a Codable
type that you want to convert to & from JSON must be a type that itself implements Codable
, and DateFormatter
does not. There is a workaround for this, which we'll call
Approach #4: Using custom CodingKeys
This way is to define a enumeration of CodingKeys
for your Codable
type. The CodingKeys
enumeration must having a raw type of String
and CodingKey
(note the singular), in that order. The Swift compiler generates this for you from your properties' names for free, but if your property names don't exactly match the JSON data, or, as in this case, you don't want all of your properties to be parsed from JSON data, you must add your own. So now we'll try:
public struct SunriseSunset: Codable {
private var dateFormatter: DateFormatter
public var sunrise: String
public var sunriseDate: Date? {
return dateFormatter.date(from: sunrise)
}
public var sunset: String
public var sunsetDate: Date? {
return dateFormatter.date(from: sunset)
}
private enum CodingKeys: String, CodingKey {
case sunrise
case sunriseDate
case sunset
case sunsetDate
}
}
Note that I've omitted dateFormatter
from the custom keys. But again, this won't compile. The compiler barfs numerous errors, the most important of which are:
error: type 'SunriseSunset4' does not conform to protocol 'Decodable'
public struct SunriseSunset4: Codable {
note: protocol requires initializer 'init(from:)' with type 'Decodable'
public init(from decoder: Decoder) throws
error: type 'SunriseSunset4' does not conform to protocol 'Encodable'
public struct SunriseSunset4: Codable {
note: protocol requires function 'encode(to:)' with type 'Encodable'
public func encode(to encoder: Encoder) throws
What these are telling you (not very clearly, IMHO) is that if you have properties that should not be handled by the JSONDecoder
/JSONEncoder
, then you have to supply a custom initializer and encoding function. Apple's documentation really doesn't help much. It says,
> Omit properties from the `CodingKeys` enumeration if they won't be present
> when decoding instances, or if certain properties shouldn't be included in an
> encoded representation. A property omitted from `CodingKeys` needs a default
> value in order for its containing type to receive automatic conformance to
> `Decodable` or `Codable`.
This sounds like you should be able to assign dateFormatter
a DateFormatter
instance when it's declared, like
> let dateFormatter = DateFormatter()
but you can't. The only way is to implement the initializer and encoding function. If you're thinking to yourself that this really defeats the purpose of using Codable
in the first place, which is to let the Swift compiler generate the CodingKeys
, initializer, and encoding functions, then you're completely correct. "There must be a better way!" I said. And there is! It's
Approach #5: dateDecodingStrategy
with a custom DateFormatter
JSONDecoder
has a property called dateDecodingStrategy
, of type JSONDecoder.DateDecodingStrategy
, which allows you to change how dates are parsed. This an enum
with 6 cases:
-
deferredToDate
(default): This treatsDate
s asDouble
values that indicate the date's number of milliseconds since the reference date (see above) -
iso8601
: The best way to format dates, e.g."2019-02-04T12:59:22+00:00"
-
formatted(DateFormatter)
: Allows you to use customDateFormatter
instance -
custom(@escaping (Decoder) -> Date)
: Allows you to specify a custom block for parsing -
millisecondsSince1970
: Like the defaultdeferredToDate
option, but calculates dates from the beginning of the Unix epoch (i.e. January 1st, 1970) -
secondsSince1970
: LikemillisecondsSince1970
, but in seconds, not milliseconds
Thankfully, "12:59:22 PM"
happens to be an exact match for DateFormatter.TimeStyle.medium
, so I'll configure my decoder accordingly:
let decoder = JSONDecoder()
let dateFormatter = DateFormatter()
dateFormatter.timeStyle = .medium
decoder.dateDecodingStrategy = .formatted(dateFormatter)
Now it prints the answer I expected:
Daylight duration (in seconds): 36623.0
I'm done, right? Not quite. Examining the returned date further, I find that although the time is what I expected, the date is January 1st, 2000! That doesn't do me much good if I want to calculate how many seconds have elapsed since sunrise today! Now I have to normalize the returned times to today's date, and that's a little trickier. One way is to get the interval from the reference date to midnight today, and add that to the parsed time.
let midnightThen = Calendar.current.startOfDay(for: sunriseSunset.sunrise)
let millisecondsToSunrise = sunriseSunset.sunrise.timeIntervalSince(midnightThen)
let midnightToday = Calendar.current.startOfDay(for: Date())
let normalizedSunrise = midnightToday.addingTimeInterval(millisecondsToSunrise)
However, there's no way to do this kind of transformation simply by using a custom DateFormatter
instance, so we're back to the original problem of duplicating time-normalization calls throughout my app. Well, it turns out that there is a dateDecodingStrategy
that can do this, and that's
Approach #6: Using a custom dateDecodingStrategy
block
One of the JSONDecoder.DateDecodingStrategy
enum cases is custom
, which takes an associated block that gets a JSONDecoder
instance and returns a Date
. So let's put the previous date-manipulation code into that block, like
dateDecodingStrategy = .custom({ (decoder) -> Date in
// Parse the date using a custom `DateFormatter`
let container = try decoder.singleValueContainer()
let dateString = try container.decode(String.self)
let date = self.dateFormatter.date(from: dateString)
let midnightThen = Calendar.current.startOfDay(for: date)
let millisecondsFromMidnight = date.timeIntervalSince(midnightThen)
let midnightToday = Calendar.current.startOfDay(for: Date())
let normalizedDate = midnightToday.addingTimeInterval(millisecondsFromMidnight)
return normalizedDate
})
Note the first three statements in the block. The first two show how to get a single String
value from decoder
. But what is this decoder
instance? It's of type Decoder
(not JSONDecoder
!), and it holds a single element--in this case, a JSON
value string. (If your JSON contains an array or dictionary of Date
s that need to be manipulated, you would change the container and decoded types accordingly.)
Is that it? Are we done? Not quite. Note that this custom decoding strategy still needs a DateFormatter
instance. DateFormatter
instances are expensive to create, so I'll create one and assign it to a property of the class that sets up this dateDecodingStrategy
. To keep things relatively self-contained, I subclassed JSONDecoder
, like so:
class NormalizingDecoder: JSONDecoder {
/// The formatter for date strings returned by `sunrise-sunset.org`.
/// These are in the `.medium` time style, like `"7:27:02 AM"` and
/// `"12:16:28 PM"`.
let dateFormatter: DateFormatter
let calendar = Calendar.current
override init() {
super.init()
dateFormatter = DateFormatter()
dateFormatter.timeStyle = .medium
keyDecodingStrategy = .convertFromSnakeCase
dateDecodingStrategy = .custom { (decoder) -> Date in
let container = try decoder.singleValueContainer()
let dateString = try container.decode(String.self)
let date = self.dateFormatter.date(from: dateString)
if let date = date {
let midnightThen = calendar.startOfDay(for: date)
let millisecondsFromMidnight = date.timeIntervalSince(midnightThen)
let today = Date()
let midnightToday = calendar.startOfDay(for: today)
let normalizedDate = midnightToday.addingTimeInterval(millisecondsFromMidnight)
return normalizedDate
} else {
throw DecodingError.dataCorruptedError(in: container,
debugDescription:
"Date values must be formatted like \"7:27:02 AM\" " +
"or \"12:16:28 PM\".")
}
}
}
}
Using this custom JSONDecoder
, our Codable
can once again look like what we wanted in Approach #1, namely
public struct SunriseSunsetResponse: Codable {
public var results: SunriseSunset
}
public struct SunriseSunset: Codable {
public var sunrise: Date
public var sunset: Date
}
Dilbert achieves Nerdvana
With this approach, you can do even more:
- Adjust times for time zone offsets
- Handle dates that may be in one of several acceptable formats
- Handle arrays and dictionaries of formatted
Date
s
If you've made it this far, thank you for reading! This is my first public technical writeup, despite being a professional developer since 1996.
Footnotes
1 Technically speaking, Codable
is a typealias
of Encodable
and Decodable
.
2 Coordinated Universal Time, better known as Greenwich Mean Time (GMT).
3 If you're a Blues Brothers fan, you may recognize these as the coordinates for Elwood Blues's address.
Top comments (0)