Proposal-temporal: proposal: improve the behavior of `DateLike.prototype.with({month})` methods

Created on 1 Sep 2019  路  6Comments  路  Source: tc39/proposal-temporal

This is a proposal to change the behavior of the DateLike.prototype.with() methods which accept a month argument (e.g. OffsetDateTime.prototype.with(arg: DateTimeLike)).

Currently, the spec does not appear to specify what happens in the following scenerio:

const date = new CivilDate(2010,10,31);
date.with({month: 2})

Here, the month associated with the new CivilDate, February, only has 28 days in it but the existing CivilDate has 31. How is this scenerio handled?

Using the existing javascript date object, this operation results in:

const date = new Date(2019,9,31)
date.setMonth(1) // "2019/3/3"

_the browser sets the month to February 1st plus 31 days -> March 3rd_

I argue that this behavior is surprising and (almost) never desirable. The one use case (I've seen) for the existing Date behavior is to perform addition/subtraction on a date. However, in the temporal API we have .plus() and .minus() for that.

I propose that, when setting the month value, if the old day value is greater than the number of days in the new month, the day value is changed to be the last day of the new month (which, I argue, is a more intuitive/desired result than the existing javascript Date object's behavior).

  • e.g. If a CivilDate represents 2010/10/31 and you call .with({month: 2}) on it, the new CivilDate returned would represent 2010/2/28 (or on a leap year such as 2012 it would be 2012/2/29)

When calling new CivilDate(2010, 2, 28).with({month: 10}), the result would be 2010/10/28.

If a developer moving from 2010/2/28 to a new month wished to ensure that they moved to the last day of the new month, they'd need to manually do that as an extra step.

For example:

let date = new CivilDate(2010, 2, 28)
date = date.with({month: 10, day: 1}).plus({month: 1}).minus({day: 1})

In general, if you go to the trouble of setting the month to a specific value (e.g. February), you want the returned DateLike object to reflect the specified month.

Most helpful comment

I don't think we should be performing hidden arithmetic alterations in with, and _especially_ not intermediate ones (your step-6 logic above would have Date.fromString("2016-02-29").with({ year: 2017, month: 4 }) result in 2017-04-28 rather than 2017-04-29 because there was no 2017-02-29 leap day).

All 6 comments

Alternatively, I could see an argument for throwing an error if you attempt to create an invalid DateLike object (e.g. new CivilDate(2019, 10, 31).with({month: 2})). However, I think users will generally want to set the day to the last day of the new month. With this understanding, having this be the behavior makes sense.

Edit

Actually, the more I think about this the more I think throwing an error makes the most sense. Even though I believe users in this scenerio will generally want to transform the date to the be the last day of the new month, forcing people to be explicit will ensure that there are no overlooked surprises and probably prevent a few bugs.

A developer could always make a function to handle things:

function setMonth(date: DateLike, month: number) {
  // separately, I realize `DateLike#lengthOfMonth` doesn't exist, but it should be added
  const day = date.with({month, day: 1}).lengthOfMonth;
  return date.with({month, day});
}

The answer is the same as for plus() and minus():

Beginning with the largest unit going to the smallest do:

  1. Set/Add/Subtract the given value
  2. Adjust all smaller values by restricting to respective larger one
  3. Adjust this and larger values into range
  4. Repeat for next smaller value.

Example: Date,fromString('2019-10-31').with({ month: 2 }) == '2019-02-28'

  1. Set year to 2019
  2. Ensure is 10 a valid month (yes)
  3. Ensure 32 is a valid day (yes)
  4. Set month to 2
  5. Ensure 2 is a valid month (yes)
  6. Ensure 31 is a valid day (no: set to 28)

Example: Date.from('1976-12-31').with({ year: 2019, month:2, day: 15 }) == '2019-02-15'

  1. Set year to 2019
  2. Ensure 12 is valid month (yes)
  3. Ensure 31 is a valid day (yes)
  4. Set month to 2
  5. Ensure 2 is a valid month (yes)
  6. Ensure 31 is a valid day (no: set to 28)
  7. Set day to 15
  8. Ensure 15 is valid day (yes)

This is already reflected to be correct in the current polyfill. I'm in the process of putting this into the specification as well.

I don't think we should be performing hidden arithmetic alterations in with, and _especially_ not intermediate ones (your step-6 logic above would have Date.fromString("2016-02-29").with({ year: 2017, month: 4 }) result in 2017-04-28 rather than 2017-04-29 because there was no 2017-02-29 leap day).

I changed my stance on this issue (see edit above) and agree with @gibson042. I think with() should throw an error if it would result in an invalid date-time. Hidden arithmetic will be surprising to most, and will, I think, inevitably lead to some bugs. Conversely, forcing the developer to be explicit in this area is, at worst, a minor inconvenience at times.

Agreed, and it was just a matter of bad explanation.

const dt = Temporal.Date.fromString('2019-01-31');
dt.with({ month: 2 }); // 2019-02-28

What I was illustrating was the algorithm for addition / subtraction. My Bad

With does not do arithmetic. The algorithm for with is roughly:

  1. { year = this.year, month = this.month, day = this.day } = argument;
  2. RangeCheck(year) // no op really
  3. RangeCheck(month) // Math.min(12, Math.max(1, month))
  4. RangeCheck(day) // Math.min(daysInMonth, Math.max(1, day))
  5. new object // new Date(year, month, day)

Which also means you can get the last day of any month by doing dt.with{ day: 32 });

Was this page helpful?
0 / 5 - 0 ratings