Anyone who's been writing software for a while knows dates are hard, from differences in formatting (can we just agree that YYYY-MM-DD is the "One True Date Format"? Yes? Good.), comparing them, and arithmetic. The basic concepts are easy enough, but it's error prone. Anyone who has worked or lived with me, or is connected on social media, will have been exposed to some ranting and pontificating on this subject (particular apologies to my non-technical spouse).
"Time" for the why?
It seems like it should all be easy, but the complexity comes down to time, and not just time, time zones. The data type to use for dates in .NET has been DateTime, look there in the type name you can see it “Time”. What this means is that you can't just get a date when you want one, it's a date time; enter your birthdate in a system and it's likely to be stored as the date plus a time, usually "00:00:00.000".
So far, so good. If your system is completely disconnected then you can probably control that, but it’s rare that anything is totally isolated, and you have no control how the data will be used by the next person to touch your code.
Happy system anniversary to currentUser.Name!
In our hypothetical (somewhat contrived) system we have a User object and it stores the date of the user’s first anniversary as a DateTime (ok, very contrived). We have a requirement to wish them a happy anniversary on the right day. OK, let’s implement that:
(currentUser.Anniversary == DateTime.Now)
This is obviously not a good idea as it’ll only work if time is exactly the same:
(currentUser.Anniversary == DateTime.Today)
Better, Today returns a date time with the time set to "00:00:00.0000". Cool! We’ll get the right result... unless at some point the data capture was off and a time was included. So, in the principle of being forgiving in what you receive:
(currentUser.Anniversary.Date == DateTime.Today)
This works! For a given value of “works”. Tests are green and we deploy. Life is good, at least until your app becomes legacy (the day after deployment).
In a slightly less contrived example, let’s assume an insurance policy that’s got a particular period of validity:
If a person makes a claim, then the incident must be within that period. We could just go with:
(policy.ValidFrom <= claim.OccuredAt &&
policy.ValidTo >= claim.OccurredAt)
Seems reasonable, but what if the claim is at the start or end of that range? Do we care about the time? If we do, then our test above is fine but if not, we'll have to decorate everything:
(policy.ValidFrom.Date <= claim.OccuredAt &&
(policy.ValidFrom.Date <= claim.OccuredAt &&
policy.ValidTo.Date.AddDays(1) > claim.OccurredAt)
Again, not too bad, you can work with it, but you (or the next developer) need to be aware of the required specificity. It’s a little fragile.
The DateTime type has quite a few very useful methods for doing date arithmetic, so we’re actually in pretty good shape here. The only exception is really calculating the number of days between two dates, you can subtract one date from another giving a TimeSpan and get the number of days from that. However (unsurprisingly) you need to be careful with times:
(newDateTime(2021,12,25) -newDateTime(2021,12,21)).Days ==4
(newDateTime(2021,12,25) -newDateTime(2021,12,21,0,0,1)).Days ==3
So, we again have to use .Date to get things behaving as expected.
Is it Really That Hard?
No, it’s not. You can add tests to make sure you treat the data correctly, add documentation so that people know if they should pay attention to the time portion of the value. It’s not a big deal. Except for the fact that people make mistakes, forget to check the usage, make assumptions, and make changes without checking all the old data.
These examples are (more than) a little contrived, but they do illustrate the point: “don’t record more precision than you need”, and the corollary “don’t assume less precision than you have”.
Or to put it another way: “make the right choice, the easy choice.” - also refer to The pit of success.
Comparison to numeric data
To clarify things a little, consider numbers. When dealing with numbers, we’re quite used to dealing with precision:
Signed integral numbers
sbyte, short, int, long, nint (C#9 platform dependent precision)
Unsigned integral numbers
Unsigned: byte, ushort, uint, ulong, nuint (C#9)
Floating point: float, double, decimal
It’s very straightforward: if we are only dealing with whole numbers don’t use floating point, use the appropriate integral type based on the range. These are primarily used for counting and indexing; it makes no sense to try and get the 2.5th item in an array, or have 9.7 users. If we aren’t dealing with whole numbers then we use floating point, and we know we have to deal with the precision of the numbers when comparing.
When interacting with temporal data DateTime seems analogous to floating point: it’s an instance in time. However, sometimes (as indicated above) we don’t want that precision. I see the date as being like an integral number. Just giving us DateTime is like saying "OK you have floating point, but not integers".
.Net 6: DateOnly
With all this context, you can imagine how happy I was to see that .NET 6 has introduced a new “DateOnly” data type ?? (not exaggerating, it made my morning when I read about it).
We can now define our data structures with the precision we want! You’ll know the expiry date (for example) is just that, not a point in time, but a day. If you’re storing a room booking it’s just the check-in-date and check-out-date (as a not so random example).
Internally it’s stored as an int with January 1st year being 0, and the largest value being December 31st year 9999 (3,652,058) (though given int.MaxValue is 2,147,483,647 it could have been much much higher).
You can construct one using numbers for the year, month, day, and optional Calendar. Internally it leverages the matching constructors on DateTime. Digging further under the covers, here (thanks to .NET being open source) you can see that it leverages the functionality of DateTime, so it’s all very safe.
Obviously, I’m a fan of the new type (well, struct), but there are a couple of use cases that aren’t covered:
Anniversary dates: you’ll still have to compare the Day and Month properties, just like in DateTime
Comparisons: .NET has no trouble comparing integer and floating point numbers, but you can’t do that with DateOnly and DateTime. At first I thought this was an oversight, but the dates ≅ integers breaks down in that an integer is still a specific number, but a date equals a 24 hour period. There is some discussion about this in the original issue on GitHub here.
There’s TimeOnly too
For my usage, this isn’t as useful, however the issue does mention a use case:
Right now, I'm working on API to store shop working hours.
I'm usingTimeSpantype to represent time of day in request types and DB models, but it's quite painful - I need to add separate validations for >0 and <24h etc.
I could useDateTimetype, and consider Time component only, but it leads to issues with serialization -DateTime.Parse("07:00").ToString() ->"11/9/2020 7:00:00 AM".
I often need a separate Date type as well, but it's a bit less painful to useDateTimetype for Date only (comparing to case with Time only).
How to use it?
This type was introduced in .NET 6 Preview 4 (released May 25th 2021), so that’s the minimum version you’ll need.
Head over to https://dotnet.microsoft.com/download/dotnet/6.0 to download and install the latest preview version of .NET 6.
Check you have the SDK installed:
You should get something like this:
6.0.100-preview.5.21302.13 [C:\Program Files\dotnet\sdk]
Create a new project (I find test projects a simple way to play with these things)
dotnet new nunit -o DateOnly
Open that folder in your preferred editor
Check the DateOnly.csproj has the right target framework (e.g. below) If not: fix it!
Jump into UnitTest1.cs and have a play!
Written by a Senior Technical Consultant at Diversus.