Working with dates and Codable in Swift

Published on: February 29, 2024

When you’re decoding JSON, you’ll run into situations where you’ll have to decode dates every once in a while. Most commonly you’ll probably be dealing with dates that conform to the ISO-8601 standard but there’s also a good chance that you’ll have to deal with different date formats.

In this post, we’ll take a look at how you can leverage some of Swift’s built-in date formats for en- and decoding data as well as providing your own date format. We’ll look at some of the up- and downsides of how Swift decodes dates, and how we can possibly work around some of the downsides.

This post is part of a series I have on Swift’s codable so I highly recommend that you take a look at my other posts on this topic too.

If you prefer to learn about dates and Codable in a video format, you can watch the video here:

Exploring the default JSON en- and decoding behavior

When we don’t do anything, a JSONDecoder (and JSONEncoder) will expect dates in a JSON file to be formatted as a double. This double should represent the number of seconds that have passed since January 1st 2001 which is a pretty non-standard way to format a timestamp. The most common way to set up a timestamp would be to use the number of seconds passed since January 1st 1970.

However, this method of talking about dates isn’t very reliable when you take complexities like timezones into account.

Usually a system will use its own timezone as the timezone to apply the reference date to. So a given number of seconds since January 1st 2001 can be quite ambiguous because the timestamp doesn’t say in which timezone we should be adding the given timestamp to January 1st 2001. Different parts of the world have a different moment where January 1st 2001 starts so it’s not a stable date to compare against.

Of course, we have some best practices around this like most servers will use UTC as their timezone which means that timestamps that are returned by these servers should always be applied using the UTC timezone regardless of the client’s timezone.

When we receive a JSON file like the one shown below, the default behavior for our JSONDecoder will be to just decode the provided timestamps using the device’s current timezone.

var jsonData = """
[
    {
        "title": "Grocery shopping",
        "date": 730976400.0
    },
    {
        "title": "Dentist appointment",
        "date": 731341800.0
    },
    {
        "title": "Finish project report",
        "date": 731721600.0
    },
    {
        "title": "Call plumber",
        "date": 732178800.0
    },
    {
        "title": "Book vacation",
        "date": 732412800.0
    }
]
""".data(using: .utf8)!

struct ToDoItem: Codable {
  let title: String
  let date: Date
}

do {
  let decoder = JSONDecoder()
  let todos = try decoder.decode([ToDoItem].self, from: jsonData)
  print(todos)
} catch {
  print(error)
}

This might be fine in some cases but more often than not you’ll want to use something that’s more standardized, and more explicit about which timezone the date is in.

Before we look at what I think is the most sensible solution I want to show you how you can configure your JSON Decoder to use a more standard timestamp reference date which is January 1st 1970.

Setting a date decoding strategy

If you want to change how a JSONEncoder or JSONDecoder deals with your date, you should make sure that you set its date decoding strategy. You can do this by assigning an appropriate strategy to the object’s dateDecodingStrategy property (or dateEncodingStrategy for JSONEncoder. The default strategy is called deferredToDate and you’ve just seen how it works.

If we want to change the date decoding strategy so it decodes dates based on timestamps in seconds since January 1st 1970, we can do that as follows:

do {
  let decoder = JSONDecoder()
  decoder.dateDecodingStrategy = .secondsSince1970
  let todos = try decoder.decode([ToDoItem].self, from: jsonData)
  print(todos)
} catch {
  print(error)
}

Some servers work with timestamps in milliseconds since 1970. You can accommodate for that by using the .millisecondsSince1970 configuration instead of .secondsSince1970 and the system will handle the rest.

While this allows you to use a standardized timestamp format, you’re still going to run into timezone related issues. To work around that, we need to take a look at dates that use the ISO-8601 standard.

Working with dates that conform to ISO-8601

Because there are countless ways to represent dates as long as you have some consistency amongst the systems where these dates are used, a standard was created to represent dates as strings. This standard is called ISO-8601 and it describes several conventions around how we can represent dates as strings.

We can represent anything from just a year or a full date to a date with a time that includes information about which timezone that date exists in.

For example, a date that represents 5pm on Feb 15th 2024 in The Netherlands (UTC+1 during February) would represent 9am on Feb 15th 2024 in New York (UTC-5 in February).

It can be important for a system to represent a date in a user’s local timezone (for example when you’re publishing a sports event schedule) so that the user doesn’t have to do the timezone math for themselves. For that reason, ISO-8601 tells us how we can represent Feb 15th 2024 at 5pm in a standardized way. For example, we could use the following string:

2024-02-15T17:00:00+01:00

This system contains information about the date, the time, and timezone. This allows a client in New York to translate the provided time to a local time which in this case means that the time would be shown to a user as 9am instead of 5pm.

We can tell our JSONEncoder or JSONDecoder to discover which one of the several different date formats from ISO-8601 our JSON uses, and then decode our models using that format.

Let’s look at an example of how we can set this up:

var jsonData = """
[
    {
        "title": "Grocery shopping",
        "date": "2024-03-01T10:00:00+01:00"
    },
    {
        "title": "Dentist appointment",
        "date": "2024-03-05T14:30:00+01:00"
    },
    {
        "title": "Finish project report",
        "date": "2024-03-10T23:59:00+01:00"
    },
    {
        "title": "Call plumber",
        "date": "2024-03-15T08:00:00+01:00"
    },
    {
        "title": "Book vacation",
        "date": "2024-03-20T20:00:00+01:00"
    }
]
""".data(using: .utf8)!

struct ToDoItem: Codable {
  let title: String
  let date: Date
}

do {
  let decoder = JSONDecoder()
  decoder.dateDecodingStrategy = .iso8601
  let todos = try decoder.decode([ToDoItem].self, from: jsonData)
  print(todos)
} catch {
  print(error)
}

The JSON in the snippet above is slightly changed to make it use ISO-8601 date strings instead of timestamps.

The ToDoItem model is completely unchanged.

The decoder’s dateDecodingStrategy has been changed to .iso8601 which will allow us to not worry about the exact date format that’s used in our JSON as long as it conforms to .iso8601.

In some cases, you might have to take some more control over how your dates are decoded. You can do this by setting your dateDecodingStrategy to either .custom or .formatted.

Using a custom encoding and decoding strategy for dates

Sometimes, a server returns a date that technically conforms to the ISO-8601 standard yet Swift doesn’t decode your dates correctly. In this case, it might make sense to provide a custom date format that your encoder / decoder can use.

You can do this as follows:

do {
  let decoder = JSONDecoder()

  let formatter = DateFormatter()
  formatter.dateFormat = "yyyy-MM-dd"
  formatter.locale = Locale(identifier: "en_US_POSIX")
  formatter.timeZone = TimeZone(secondsFromGMT: 0)

  decoder.dateDecodingStrategy = .formatted(formatter)

  let todos = try decoder.decode([ToDoItem].self, from: jsonData)
  print(todos)
} catch {
  print(error)
}

Alternatively, you might need to have some more complex logic than you can encapsulate in a date formatter. If that’s the case, you can provide a closure to the custom configuration for your date decoding strategy as follows:

decoder.dateDecodingStrategy = .custom({ decoder in
  let container = try decoder.singleValueContainer()
  let dateString = try container.decode(String.self)

  if let date = ISO8601DateFormatter().date(from: dateString) {
    return date
  } else {
    throw DecodingError.dataCorruptedError(in: container, debugDescription: "Cannot decode date string \(dateString)")
  }
})

This example creates its own ISO-8601 date formatter so it’s not the most useful example (you can just use .iso8601 instead) but it shows how you should go about decoding and creating a date using custom logic.

In Summary

In this post, you saw several ways to work with dates and JSON.

You learned about the default approach to decoding dates from a JSON file which requires your dates to be represented as seconds from January 1st 2001. After that, you saw how you can configure your JSONEncoder or JSONDecoder to use the more standard January 1st 1970 reference date.

Next, we looked at how to use ISO-8601 date strings as that optionally include timezone information which greatly improves our situation.

Lastly, you learn how you can take more control over your JSON by using a custom date formatter or even having a closure that allows you to perform much more complex decoding (or encoding) logic by taking full control over the process.

I hope you enjoyed this post!

Categories

Codable

Subscribe to my newsletter