Reminder to use something like the date-fns library for immutable operations on dates. The native JS date library can be painful to work on when doing operations like adding or subtracting time.
A function that takes all the components at once can resolve this conundrum internally.
A function that takes a simple struct (containing each component) can also handle the conundrum. The function could of course even take a string representation.
To have a better understanding of what operations you are trying to do. I can't answer your question specifically because it depends a lot on use case and what date/time standards you want to use.
To expand on this a little, there are operations that have slightly ambiguous meanings/can end up with different answers depending on your assumptions.
e.g. If today is the 30th of January 2024. What is the date in 1 months time?
Should it be the 29th of February? (as the 30th doesn't exist). The 28th? (the penultimate day of the month, like the 30th of Jan). The 1st of March (a 'standard' month should be considered 30 days, so 30 days from now).
To be honest, use a library where someone else figured out the ambiguities and accounted for the edge cases. Good starting point: https://moment.github.io/luxon/#/math
Date-fns is fine for simpler use cases but Luxon is a lot more complete, especially where it comes to time zones.
This is not the kind of thing you just want to blindly hack your way through without a really thorough understanding of how different cultures/locales/governments handle dates and times.
If you have to do math across daylight savings time boundaries that change over time (because governments and regulations change), converted to two or more time zones (which again have their own separate DST rules), and possibly span some 30 or 45 minute time zones (they exist!) this will quickly get out of hand and unreadable. Not just unreadable, but incredibly difficult to reason about.
It gets even worse when the backend stores only the offset (as in the ISO time, like T23:00:00+08:00) because then you lose the original time zone and can't be sure whether it came from a DST zone or another country in the same zone (but only during part of the year).
When you cross DST switchovers, for example, you may magically gain or lose an hour. A naive milliseconds calculation will miss that. It gets worse because DST in a country isn't a static thing either, but will change with the laws over time. So over decades, you need to have several lookup tables.
And people are ambiguous. Is January 31 plus 1 month the end of February or the beginning of March? What about during a leap year?
And a "day" isn't necessarily 24 hours (because of DST, again). Humans doing day math across those boundaries will just ignore (or really, never think about) the missing or gained hours, but computers have to explicitly account for it.
Then once you throw in different time zones it gets even crazier, especially for the half hour and 45 minute zones.
It's okay to store datetimes in epoch milliseconds (ideally with a separate field for the time zone, not just offset, which holds less information). But you can't easily/correctly do math on that without more specific instructions and cultural/locale adjustments.
targetEndDate = new Date();
targetEndDate.setFullYear(endDate.getFullYear());
targetEndDate.setMonth(endDate.getMonth());
targetEndDate.setDate(endDate.getDate());
Even if the endDate is valid, this can fail, because today is the 31st, which doesn't exist in endDate's month.
If you just did:
targetEndDate = createDate(endDate.getFullYear(), endDate.getMonth(), endDate.getDate());
// createDate is an intentional placeholder, I didn't want to double-check the actual syntax
you wouldn't pass through the transitional stage where you have the new month but today's day of the month, which is what causes the bug.
In this case, using mutation on the individual fields makes you have to transition through an invalid state to get back to a valid one, and the JS date object does something unexpected (though I think there's nothing good to do here, an exception is probably the best you could do). So mutation really is at issue here. In general, mutation creates room for these kind of counter-intuitive state transitions to arise.
The direct "immutable" equivalent of the bug code (purely based on my experience with .NET Core immutable collections) would have the setX methods return a new immutable datetime with the field set as requested. So just "use an immutable type" wouldn't fix this bug.
"Change the code completely so you supply all 3 components at the same time" obviously fixes the bug, but that doesn't require an immutable type.
The .NET immutable collections seem to mostly avert problems like this. For example, ImmutableDictionary.Add<TKey, TValue>(key, value) throws an exception if key already exists with a different value (as determined by an explicit or implicit IEqualityComparer<TValue>).
.NET's (immutable) DateTime, on the other hand, has AddMonths(Int32), which sets the day to Min(original day number, last day of result month), which, while arguably reasonable, isn't obvious without reading the documentation.
My favorite counterintutive DateTime "mutator," however is AddMilliseconds(Double): prior to .NET 7, its floating-point argument is rounded to the nearest integer (!?!).
mutation on the individual fields makes you have to transition through an invalid state
If you want to do this mutation-style for some reason, you should use the builder pattern (or whatever it's called). So in this case you'd instantiate a DateBuilder instance instead of a Date instance, and finally call .date() to get the actual Date instance from the builder instance.
Better yet, in this particular case, don't use the builder pattern, and instead just provide a sensible set of constructors with appropriate defaults for unspecified arguments (e.g., 00:00 for unspecified time).
For a simple date/time class, the builder pattern reeks of pointless overengineering.
Comments
Reminder to use something like the date-fns library for immutable operations on dates. The native JS date library can be painful to work on when doing operations like adding or subtracting time.
I would argue that doing time arithmetic by modifying components of a date individually is wrong in any language.
A function that takes all the components at once can resolve this conundrum internally.
A function that takes a simple struct (containing each component) can also handle the conundrum. The function could of course even take a string representation.
And the right way would be?
To have a better understanding of what operations you are trying to do. I can't answer your question specifically because it depends a lot on use case and what date/time standards you want to use.
To expand on this a little, there are operations that have slightly ambiguous meanings/can end up with different answers depending on your assumptions.
e.g. If today is the 30th of January 2024. What is the date in 1 months time?
Should it be the 29th of February? (as the 30th doesn't exist). The 28th? (the penultimate day of the month, like the 30th of Jan). The 1st of March (a 'standard' month should be considered 30 days, so 30 days from now).
To be honest, use a library where someone else figured out the ambiguities and accounted for the edge cases. Good starting point: https://moment.github.io/luxon/#/math
Date-fns is fine for simpler use cases but Luxon is a lot more complete, especially where it comes to time zones.
This is not the kind of thing you just want to blindly hack your way through without a really thorough understanding of how different cultures/locales/governments handle dates and times.
If you have to do math across daylight savings time boundaries that change over time (because governments and regulations change), converted to two or more time zones (which again have their own separate DST rules), and possibly span some 30 or 45 minute time zones (they exist!) this will quickly get out of hand and unreadable. Not just unreadable, but incredibly difficult to reason about.
It gets even worse when the backend stores only the offset (as in the ISO time, like T23:00:00+08:00) because then you lose the original time zone and can't be sure whether it came from a DST zone or another country in the same zone (but only during part of the year).
Convert to unix times, and do the calculation with those?
It's not that simple. Luxon docs have a good section on this: https://moment.github.io/luxon/#/math
Some random examples...
When you cross DST switchovers, for example, you may magically gain or lose an hour. A naive milliseconds calculation will miss that. It gets worse because DST in a country isn't a static thing either, but will change with the laws over time. So over decades, you need to have several lookup tables.
And people are ambiguous. Is January 31 plus 1 month the end of February or the beginning of March? What about during a leap year?
And a "day" isn't necessarily 24 hours (because of DST, again). Humans doing day math across those boundaries will just ignore (or really, never think about) the missing or gained hours, but computers have to explicitly account for it.
Then once you throw in different time zones it gets even crazier, especially for the half hour and 45 minute zones.
It's okay to store datetimes in epoch milliseconds (ideally with a separate field for the time zone, not just offset, which holds less information). But you can't easily/correctly do math on that without more specific instructions and cultural/locale adjustments.
No if you are manipulating dates do not use time.
Use an integer type denoting days since the start date. See Modified Julian date.
Thus tomorrow is always 1 more than today - if you have times you need leap seconds.
Thanks for the explanation! (edit - this was meant genuinely, not sarcastically)
Slightly sad to have been downvoted, though, just for making a tentative suggestion (note use of question mark) :'(
Temporal is quite nice; should be coming to browsers soon: https://tc39.es/proposal-temporal/
Nice! JS has always been awkward with dates, good to see that is being fixed on the language level.
they've been saying that for years now
What does mutability have to do with this bug?
The old code was
Even if the endDate is valid, this can fail, because today is the 31st, which doesn't exist in endDate's month.If you just did:
you wouldn't pass through the transitional stage where you have the new month but today's day of the month, which is what causes the bug.In this case, using mutation on the individual fields makes you have to transition through an invalid state to get back to a valid one, and the JS date object does something unexpected (though I think there's nothing good to do here, an exception is probably the best you could do). So mutation really is at issue here. In general, mutation creates room for these kind of counter-intuitive state transitions to arise.
The direct "immutable" equivalent of the bug code (purely based on my experience with .NET Core immutable collections) would have the setX methods return a new immutable datetime with the field set as requested. So just "use an immutable type" wouldn't fix this bug.
"Change the code completely so you supply all 3 components at the same time" obviously fixes the bug, but that doesn't require an immutable type.
The .NET immutable collections seem to mostly avert problems like this. For example, ImmutableDictionary.Add<TKey, TValue>(key, value) throws an exception if key already exists with a different value (as determined by an explicit or implicit IEqualityComparer<TValue>).
.NET's (immutable) DateTime, on the other hand, has AddMonths(Int32), which sets the day to Min(original day number, last day of result month), which, while arguably reasonable, isn't obvious without reading the documentation.
My favorite counterintutive DateTime "mutator," however is AddMilliseconds(Double): prior to .NET 7, its floating-point argument is rounded to the nearest integer (!?!).
Yep, that's what I was getting at. Immutability is not a "silver bullet"
If you want to do this mutation-style for some reason, you should use the builder pattern (or whatever it's called). So in this case you'd instantiate a DateBuilder instance instead of a Date instance, and finally call .date() to get the actual Date instance from the builder instance.
Better yet, in this particular case, don't use the builder pattern, and instead just provide a sensible set of constructors with appropriate defaults for unspecified arguments (e.g., 00:00 for unspecified time).
For a simple date/time class, the builder pattern reeks of pointless overengineering.
Oh certainly, didn't mean to imply it's great for a freaking date.
But I've seen the mutating pattern in other cases causing the weird state bug which could have avoided using a builder.
I agree, reading the example code was painful as someone who tries to write all code in a functional style.