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 ran the following line:
let now = Date()
You’d see something like 01-Jan-2021 at 12:00 AM
(or rather the equivalently formatted version of the current time for you) in the sidebar.
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 the passage of time rather than whatever concepts 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 you canʼt really do much with just a Date
.
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. Scrolling around in the app 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 information, and in a standardised representation. One big difference though is that a time zone offset is a fixed difference from UTC, whereas a time zone is more abstract. While it will have a fixed offset for a fixed point in time, a time zone can have different offsets at different points in time, be it temporary (daylight savings) or permanent (a timezone can change its offsets). This means that while storing the offset does accurately represent that point in time, any date maths has based off of it the potential to break in the future.
There is also another issue with using this representation, and this one I’m inclined to actually label a bug: When parsing a 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 another drawback worth noting.
So, in terms of both correctness and for practical reasons, while it does require adding a whole new type and will require a bit more work from anyone importing your data, something like the LocalizedDate
type is your best bet, at least until we have a better standard.
As of this writing there is a proposal in place to add a similar type to ECMAScript, (h/t to Sindre Sorhus), and its ZonedDateTime
type looks an awful lot similar to LocalizedDate
as defined here5. Hopefully Swift gains something similar in the future as well.
Conclusion
Dates and times form a massively complicated subject that I cannot hope to cover in its 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.
-
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.↩
-
And maybe even a negative leap second soon.↩
-
At that time, at least. The difference is -13:30 when DST is active.↩
-
Or behaviour, depending on how you choose to look at it.↩
-
It also includes a calendar optionally, whereas with the Foundation setup dates are almost always interpreted with reference to the currently selected one. I can see the utility but I’ve also never given much thought to the idea of a single corpus of data having multiple calendars, so I can’t wait to see the edge cases that creates.↩