Today we reached tentative consensus in favor of offering negative durations in Temporal. There was a long discussion in #558 about whether we _should_ do this, but now that we're moving forward I wanted to open a separate issue for how exactly negative durations should work. That's this issue.
1. A Duration can be negative.
2. All Duration units must share the same sign; intra-duration sign variation is not supported
Duration. We explicitly stopped doing this in #642, so I don't see any need to support it now. The (easy) workaround is to construct a single-sign duration, and then apply the math operation.3. The string persistence format for Duration will be extended with an optional leading sign
-P2D means a negative 2-day duration, while P2D and +P2D both mean a positive 2-day duration.Duration.prototype.toString will emit the leading negative sign for negative durations, but will NOT emit a leading plus for positive durations, so that users who are using ISO8601-compliant positive duration will get an ISO8601-compliant string persistence format.Duration.from will accept a leading minus, a leading plus, or no leading sign. Intra-duration (non-leading) plus or minus characters are not supported and must throw when parsed.4. If a Duration is negative, its nonzero fields will all be negative too.
Duration fields (property getters, getFields, from, and with) will emit and accept only negative integer values for every nonzero unit of a negative duration.sign field. The main problem with this approach is that it makes with seem ambiguous. If you have a duration -P2D and you say .with({days: 1}) do you mean that the resulting duration should be positive or negative? We'd define it to mean the latter, but this seems like it'd be a source of confusion. If the sign is represented in every unit, then there's no ambiguity about the meaning of a negative or positive field value.const getDateDuration = d => { years: d.years, months: d.months, weeks: d.weeks, days: d.days };
const totalDays = dur1.days + dur2.days;
const harderTotalDays = dur1.days*dur1.sign + dur2.days*dur2.sign;
Duration.from, Duration.prototype.with, or any type's plus or minus method) should throw if any of the non-zero input units have different signs.Duration constructor must throw if it's passed non-zero units with different signs.Duration.with can reverse the sign of a duration, but only if all of the existing duration's non-zero units are replaced.Duration.from('-P2DT12H').with({weeks: 3, days: 0, hours: 12}); // OK
Duration.from('-P2DT12H').with({weeks: 3, days: 0})`; // throws
5. Duration should gain a few convenience properties/methods
Duration.prototype.negated() - reverse the signDuration.prototype.sign - 0, -1, or 1. Not included in getFields because it's redundant. Not accepted by with or from because of potential conflicts.Duration.prototype.abs() - if negative, reverse the sign6. Non-Duration plus and minus methods should accept negative durations
plus is passed a negative duration, then the implementation should treat it as if the user had called minus on the equivalent positive duration.minus is passed a negative duration, then the implementation should treat it as if the user had called plus on the equivalent positive duration.plus and minus methods can now perform addition or subtraction according to the sign of the duration, both methods must now accept identical options. Currently plus uses 'constrain' (default) and 'reject' while minus uses 'balanceConstrain' (default) and 'balance'. To align these and to retain consistency with other Temporal uses, we'll rename 'balanceConstrain' to 'constrain'. Both methods will now accept 'constrain' (default), 'reject', or 'balance'.balance option will now be supported for addition operations. 7. Order of operations should not be affected by negative durations
8. No change to Duration.prototype.toLocaleString; continue passthrough to Intl.DurationFormat
toLocaleString should follow the conventions already used by Intl.RelativeTimeFormat, where both negative and positive values are accepted. Examples from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/RelativeTimeFormatconst rtf1 = new Intl.RelativeTimeFormat('en', { style: 'narrow' });
console.log(rtf1.format(3, 'quarter'));
//expected output: "in 3 qtrs."
console.log(rtf1.format(-1, 'day'));
//expected output: "1 day ago"
Intl.RelativeTimeFormat.Intl.RelativeTimeFormat today.Intl.DurationFormat to decide both whether to accept (or throw for) negative durations and what format options should be accepted. This means that for the purposes of this proposal, the Duration.prototype.toLocaleString should not change from the current implementation which simply passes the duration and options through unchanged to Intl.DurationFormat, which should decide how to display the duration (or to throw if that's decided).
- Non-Duration plus and minus methods should accept negative durations
Are you proposing to get rid of minus? (I would be in favor of getting rid of minus)
- Duration.prototype.toLocaleString behavior is TBD (open issue)
I'm trying to think of use cases. People might try formatting strings like "the game starts 1 hour after the train arrives" for a positive duration, or "the train arrives 1 hour before the game starts" for a negative duration. In English, the sign doesn't seem to matter, but you need to handle it properly to choose whether to say "before" or "after".
My gut feeling is if we don't know better, we should make toLocaleString throw an exception for a negative duration. But, I'll double-check with the other i18n experts on my team.
:heavy_plus_sign: to all of this. I agree that all nonzero fields being either negative or positive is the least surprising. As for toLocaleString(), I think we should leave it entirely to the discretion of Intl.DurationFormat.
Are you proposing to get rid of minus? (I would be in favor of getting rid of minus)
I wasn't proposing to make that change. Minus is trivial to implement and is ergonomically helpful for users so I'm not sure there's enough value in omitting it that outweighs those benefits.
I'm trying to think of use cases. People might try formatting strings like "the game starts 1 hour after the train arrives" for a positive duration, or "the train arrives 1 hour before the game starts" for a negative duration. In English, the sign doesn't seem to matter, but you need to handle it properly to choose whether to say "before" or "after".
Apparently, Intl.RelativeTimeFormat already supports negative units:
console.log(rtf1.format(3, 'quarter'));
//expected output: "in 3 qtrs."
console.log(rtf1.format(-1, 'day'));
//expected output: "1 day ago"
So I think we should not throw for negative durations and instead to follow this same convention, e.g. 1 hour and 30 minutes ago or in 3 weeks, 2 days, and 12 hours. But regardless of the actual formatted text emitted, I agree with @ptomato: _"As for toLocaleString(), I think we should leave it entirely to the discretion of Intl.DurationFormat."_
It is interesting that Intl.DurationFormat has chosen to format using "relative to now" language (e.g. "in 2 days", "2 days ago") and not "relative to another event" language (e.g. "2 days before", "2 days after"). I could see a future Intl.DurationFormat providing an option for that latter format. But that's outside the scope of Temporal, IMHO.
~Is one of the assumptions in Temporal that Intl.DurationFormat will be extended to support being passed a Duration or a Duration-like property bag that can format multiple units?~
EDIT: Sorry, above I mixed up RelativeTimeFormat with DurationFormat. The latter doesn't exist yet on MDN.
It's an interesting question, therefore, what the format of toLocaleString should be. Should it be a relative-to-now format (e.g. "1 day and 12 hours ago"), a "relative-to-something" format (e.g. "1 day and 12 hours before"), or a non-relative format (e.g. "1 day and 12 hours").
I updated the OP with @ptomato's suggestion to defer to Intl.DurationFormat. This means that, for the purposes of this proposal, toLocaleString implementation won't change-- it'll continue passing through all options unchanged to Intl.DurationFormat.
Intl.DurationFormat is non-relative. It's for things like "the video is 10 minutes long" or "it takes 6 hours and 30 minutes to drive from San Jose to Los Angeles".
However, I could see a future proposal extending Intl.RelativeTimeFormat to accept a duration as an argument.
Anyway, follow up on toLocaleString in tc39/proposal-intl-duration-format#29.
OK, I edited the proposal to resolve any open issues that I knew about. AFAIK , the only remaining open issue is whether toLocaleString will accept or throw for negative durations, and that decision is up to DurationFormat so is out of scope to this proposal.
At this point unless there are objections, I think we're ready to move to a PR. Any objections or other concerns?
+1
Decision at 2020-07-31 Champions' meeting: proposal is approved. @ptomato will build the PR because he is awesome. ;-)
After today's meeting, I realized we forgot to discuss aligning the names of disambiguation options between plus and minus. Because both methods can now perform either subtraction or addition, the disambiguation options must be the same for both methods. My proposal is simply:
'balanceConstrain' to 'constrain', so both plus and minus methods will accept 'constrain' (default), 'reject', and 'balance'.balance for both addition and subtraction operationsI added this into new sections 6.4 and 6.5.
IIRC, we used different names for constrain and balanceConstrain because the latter can move data between fields, and we wanted to emphasize that.
I'm OK to bikeshed on the "constrain" vs. "balanceConstrain" name-- that's easy to change later. Mostly I wanted to capture the core requirements to use the same three options for both plus and minus and for the three options to have identical behavior for addition operations (either plus with a positive duration or minus with a negative one). And also for subtraction operations via plus with a negative duration or minus with a positive duration.
The challenge with naming is that the same name needs to work for both addition "constraining" and subtraction "balance constraining". Or we'd need to have 4 options which seems unnecessarily complicated. I assumed that "constrain" is the common attribute.
Regardless, I don't think naming should block implementation.
When we added Duration.plus and Duration.minus I didn't think it was useful to have "reject" — see https://github.com/tc39/proposal-temporal/issues/408#issuecomment-598492031
@ptomato - what do you think the options should be here? I don't have a strong opinion as long as they're the same names for plus and minus and the balance option works for both addition and subtraction. I don't think reject is particularly useful and would not cry if it vanished.
If we have negative durations then we need to have balance and balanceConstrain for plus as well as minus.
The only time that constrain and reject are used for plus, is when the result is out of range of Number.MAX_VALUE. Since we changed the other types to always throw a RangeError when out of range in #664, I think maybe it would be best to do the same for Temporal.Duration, and just not have constrain (because it doesn't do anything). I also don't think reject is particularly useful (it would throw on operations like PT2H30M – PT45M) so I would prefer not to have it.
So, I would advocate for just two options: balance and balanceConstrain, for both plus and minus.
Sounds good to me. Would balanceConstrain do anything for plus with a positive duration or for minus with a negative duration? I assume not, but wanted to make sure I wasn't missing something.
That's correct.
A couple of implementation notes:
Math.sign() can return five values: -1, 1, -0, 0, NaN. We already disallow NaNs in durations because they are not finite. I'm inclined to silently disallow -0 as well: according to the current spec, any fields of -0 already get converted to 0 anyway by ToInteger, and it seems like a nightmare if you would have to make sure the sign of your zeroes agrees with the sign of the rest of the fields when constructing a duration!duration.abs() returns a copy of the duration if its sign is already positive or zero. (That is, a new object.)constrain/reject distinction by always throwing a RangeError past the end of the range, then what should the non-balance mode be called? balanceConstrain makes sense for .plus and .minus, but not for .from and .with. disambiguation: 'none' vs. disambiguation: 'balance'? Or just balance: true and balance: false? This seems very much related to the balancing method / rounding method in #337 so I'll just implement it as originally described (rename balanceConstrain to constrain, add reject) for the purposes of this PR, otherwise it could get out of scope.FWIW, I really like balance: boolean. Seems much clearer than overloading disambiguation for purposes of balancing. Given that balancing is already complicated (we wouldn't have an entire dedicated page in the docs if it were obvious!) then making the option more self-describing seems like a good idea to limit that complexity. Also, we're already planning to split disambiguation into a disambiguation and overflow property per #607, so retaining disambiguation as a universal option seems like it's already on its way out.
Agreed on preventing -0 and NaN.
Agreed on creating new object. My assumption is that this should be a general rule for all Temporal methods: if an object is returned, it's a new object.
One thing I'm not sure we discussed, I assume reverting https://github.com/tc39/proposal-temporal/pull/667 is part of this?
Yep agreed