The overarching theme of what I write about on this website is things that I used to once hate, then understood, and now continue to hate but for different reasons. And there’s no topic that fits the bill better than this.

Date Is Not Really A Date

I’m a pretty big fan of using Xcode Playgrounds for prototyping. They let you quickly mess around when you just have a few lines of an idea without having to go through the hassle of setting up a brand new project1.

They also have these little previews that show you what happens when each line when your code is executed, which can be really nifty. If you had ran the following line:

let now = Date()

You’d see something like 01-Jan-2021 at 12:00 AM in the sidebar (or rather the equivalently formatted version of the current time for you).

This is slightly misleading, though. Despite what the preview shows, Date has no concept of days, months, years, any of that stuff. It deals solely with time, and more specifically, the number of seconds that have passed since midnight of 1 January 2001, in UTC.

How You’re Supposed To Deal With Time

Date is a very simple type that models time rather than whatever concept of dates we have. If everyone on the planet created a Date instance at the same moment, they’d be exactly the same.

While dealing time measured in seconds can be reduced to pure maths, date maths isn’t quite simple. We have time zones, leap seconds2, leap years, daylight savings time, and to top it all multiple calendars, each with their own concepts of how days and months should be organised. A single moment in time can be represented in a host of different ways and so rather than creating mechanisms for handling them as such it makes a ton more sense to use the time-based representation. It’s much easier to store, compare, and operate on one single 64-bit number than all of the various formats. As a bonus you also get some semantic guarantees: Every single time interval represents one single date and vice versa. You cannot possibly, accidentally, create say February 30 with this representation.

This is also why Date is fairly barebones in terms of functionality. There is the one addingTimeInterval method, and it does have a few valid uses, but otherwise it doesn’t really let you perform many operations.

All date-related information and smarts come from Calendar, DateFormatter, and related types. In fact, Playgrounds uses them too, and here’s how you can manually generate the string representation used in the preview:

var formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .short
formatter.string(from: now) // "01-Jan-2021 at 12:00 AM"

Both Calendar and DateFormatter do a host of things. If you want to manipulate a date, you should be using the former, and if you want to convert one to or from a string, you should be using the latter (and ISO8601DateFormatter, RelativeDateTimeFormatter, and so on). Your use case is probably already covered and localised by those types.

That said, I do have one pretty massive gripe with this whole setup…

Date Really Is Not a Date

Here’s a screenshot from the Health app, showing my step counts for the day of June 8, 2017. It was a fairly active day, as I got in a bit over 20,000 steps. The distribution of those steps, though, feels a bit off. It reads like I was up walking all night, and then asleep throughout the day, and scrolling around a bit, that’s the story for the rest of the week too. I don’t remember spending the week that way.

As it happens I was in San Jose attending WWDC at the time, which has a -12:30 time difference with India3, and as a result all of my health data from that trip appears time-shifted.

I say appears because in a way, it is accurate. The step counts did happen as the graph reads, except when it says 12 AM it doesn’t refer to 12 AM as I experienced it, but rather 12 AM with respect to my current time zone.

Going back to how Date works, it doesn’t model the actual clock time but rather a fixed point in time that can be interpreted in any time zone. And so what’s happening here is that the data is being interpreted as if it happened in my current time zone, which is the default time zone that Calendar and DateFormatter use.

And as such, a Date alone isn’t sufficient for modelling historical data, or at least personal historical data: You need time zone information too.

HealthKit acknowledges this too. You do have the ability to specify a time zone when constructing the appropriate HKSample subclass for the health data you’re modelling. It just so happens that while you are required to submit the start and end dates for any sample, the time zone information is entirely optional and buried within a metadata dictionary, that you can even omit entirely.

All of the step data shown in the screenshot was captured by the Health app right on my phone, stored in HealthKit, and displayed by the Health app. Somewhere in this pipeline, the time zone information was ignored or discarded.

So clearly the type itself isn’t at fault for this bug4, and while it also isn’t a result of any of individual pieces of the existing setup, the existing design of the whole date-time handling apparatus does feel a little incomplete.

Dates As We Interpret Them

We can accommodate for this use case with a simple custom type that is composed of a Date and a TimeZone.

I think giving Date that name was a mistake, with Moment or Instant or another synonym being a much better and more descriptive fit for what it does, and Date being the ideal name for this new type, but since a Foundation rewrite probably isn’t on the horizon I’ve been content with calling it LocalizedDate.

struct LocalizedDate: Codable {
	var date: Date
	var timeZone: TimeZone
}

A great thing about this setup is that we can still continue relying on Calendar and the other Foundation goodies to perform operations and format dates.

For operations relying on one date you just need to change the calendar or formatter’s timeZone and you’ll be good to go. This has the downside of requiring a mutable instance and having to write mutating functions, but there’s no workaround for that. For operations involving multiple dates such as Calendar.isDate(_:inSameDayAs:) you need a bit more work since we can have dates in different time zones and so we can’t fall back to the existing methods, but DateComponents gets you a lot of the way there.

As for the actual archival, this is where things get interesting.

Swift will autosynthesise a Codable implementation for a struct where all of its stored properties are Codable as well, which means that we get conformance for free just be declaring it. Here’s what serialising an instance of our new LocalizedDate type produces:

let date = LocalizedDate(date: now, timeZone: .current)
let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
let json = try! encoder.encode(date)
print(String(decoding: json, as: UTF8.self))
// {
//   "date" : 631132200,
//   "timeZone" : {
//     "identifier" : "Asia\/Kolkata"
//   }
// }

This is composed of the individual Codable conformances for Date and TimeZone, and as such you can rely on it to keep working regardless of any future changes to date math or time zones.

If you export data in this format though, you’ll have to require any importers to also read it in the same way. While the individual components themselves are based on standards, the shape is not.

As it happens though there is a standardised format that lets you specify the date with a time zone, and there’s some support for it within Foundation too: ISO-8601.

Foundation ships with an ISO8601Formatter which, as it says on the tin, lets you convert dates to and from this format. While there are multiple representations within the standard for different date and time related quantities, the one we care for is the default setup, and here’s how it works:

let isoFormatter = ISO8601Formatter()
isoFormatter.timeZone = .current
isoFormatter.string(from: now) // 2021-01-01T00:00:00+05:30

Instead of the time interval for the Date and the name of the TimeZone, this representation uses a string with the date, time, and time zone offset.

It’s mostly the same information5 but the important thing here is that it is a standardised representation.

There is one small hitch though, and this one I’m inclined to actually label a bug: When parsing an string, the ISO8601Formatter returns just a Date, which, as we’ve discussed already, is a fixed point in time, devoid of any time zone information. If any time zone information is present in the string it is used to calculate the correct time (or else the formatter’s timeZone is used), but then it is discarded.

I don’t know why it works this way, but given that it does, you’d have to rely on any importers, including your own code, to be aware of this behaviour and manually parse the timezone information. It still gives you the correct Date no matter what, but this is a drawback worth noting.

Conclusion

Dates and times are a massively complicated subject that I cannot hope to cover in their entirety, but I hope this post was helpful in understanding some of the basics.

The Foundation documentation is pretty solid so it’s a good place to start if you want to learn more.

While Foundation itself is closed source, swift-corelibs-foundation is an open source project from Apple that aims to bring parity with Foundation in a pure Swift project. Notably it is still a work in progress, and so not all of the API has been ported over yet.

I’d also recommend taking a look at Your Calendrical Fallacy Is, which debunks some beliefs you might have about calendars, in case you still need to be sold on the idea of relying on the system types for any date math.


  1. The only item on my desktop is a Playground called “Scratchpad” used exactly as the name suggests.

    Full disclaimer that I stole this idea from Jared Sinclair.

  2. And maybe even a negative leap second soon.

  3. At that time, at least. The difference is -13:30 when DST is active.

  4. Or behaviour, depending on how you choose to look at it.

  5. A time zone offset is a fixed difference from UTC, whereas a time zone can have different offset values at different points in time.