Proposal-temporal: Conclusions from 2019-03 TC39

Created on 3 Apr 2019  Â·  21Comments  Â·  Source: tc39/proposal-temporal

@pipobscure and I had a long conversation at the TC39 dinner, and looped in @fabalbon, @sffc, and @littledan after the meeting. Here is an unorganized list of the resulting conclusions of relevance to this proposal.

  • [x] We should try to identify time zones exclusively by IANA name, rather than having magic "UTC" and "SYSTEM" strings (the former being "Etc/UTC", the latter ideally being accessed via another means such as a getSystemTimeZone function or systemTimeZone Symbol).
  • [ ] ZonedDateTime values must always have an IANA time zone. Values without a time zone but with a (fixed) nonzero UTC offset will be represented with OffsetDateTime.
  • [ ] OffsetDateTime.fromString rejects input with a bracketed time zone, since it only cares about offset and cannot necessarily verify consistency (e.g., because the implementation doesn't have time zone data). For example, OffsetDateTime.fromString("2019-04-02T22:58:36.123456789-04:00[America/New_York]") does not result in a successful parse.
  • [ ] ZonedDateTime.fromString _requires_ a bracketed time zone and consistency between that value and the offset. For example, ZonedDateTime.fromString("2019-04-02T22:58:36.123456789-05:00[America/New_York]") does not result in a successful parse because the UTC offset and time zone are inconsistent at the given instant.
  • [ ] For eliminating the need to use Date, fromString functions accept reduced precision values, e.g. CivilTime.fromString("10:23") and OffsetDateTime.fromString("2019-04-03T02:30Z") and maybe CivilDate.fromString("2019-04").
  • [ ] {milli,micro,nano}Second are consistently treated as distinct integer-valued elements in all types and signatures, and always representing time since the rollover of the immediately larger element.
  • [ ] All types support arithmetic (plus(Duration [, disambiguationRule])/minus(Duration [, disambiguationRule]), and—if we include it—difference($compatibleType [, largestElementInResult])). plus/minus cast input to Duration; difference (name TBD) _returns_ a Duration.
  • [x] Every property of a Duration must be integer-valued, for avoiding ambiguity about e.g. plus({months: 1.5}).
  • [ ] Eliminate lossy "to{Type}" methods. Non-lossy "with" methods may be kept for convenience, but are probably defined in terms of static "from" functions (or at least consistent with them).

Open Questions

  • [ ] Should the properties be named as below (plural in Duration and singular in every other type)?
  • [ ] Should the relationship between {Offset,Zoned}DateTime and Instant be one of aggregation as shown below (zoned.instant), or of inheritance (zoned.epochSeconds/zoned.epochMilliseconds/etc.)?
  • [ ] Should OffsetDateTime have both offsetSeconds and offsetString, as shown below? If so, which should be used when "with"-extending CivilDateTime and Instant to include an offset (e.g., withOffset(number) vs. withOffset(string) vs. withOffsetSeconds(number) vs. withOffsetString(string))?
  • [ ] I like the symmetry of having every "with" method accept a Temporal-like object (i.e., paying attention only to properties that share a name with a property of ZonedDateTime). But should some of them also accept primitive values? I'm specifically thinking about CivilYearMonth.prototype.withDay(number), CivilMonthDay.prototype.withYear(number), {CivilDateTime,Instant}.prototype.withOffset*(number|string), and withTimeZone(string).
  • [ ] What is the range supported by Temporal objects? I'm not sure it makes sense to match Date, but if it's bigger then perhaps all of the Instant properties should be BigInts.
  • [ ] Even if the ranges do match, we may want Duration properties to be BigInts. new Duration({ nanoseconds: Number.MAX_SAFE_INTEGER }) is only 104 days.
  • [ ] How do we expose offset-transition disambiguation for withZone? https://github.com/tc39/proposal-temporal/issues/84#issuecomment-497245032

Class Relationships

screen

class diagram source

CivilYearMonth <|--> CivilDate : withDay({day})
CivilMonthDay <|--> CivilDate : withYear({year})
Duration --> Duration : plus({year, month, day, hour, minute, …})
CivilDate <|--> CivilDateTime : withTime({hour, minute, second, …})
CivilTime <|--> CivilDateTime : withDate({year, month, day})
CivilDateTime <|--> OffsetDateTime : withOffsetSeconds({offsetSeconds})
Instant o--> OffsetDateTime : withOffsetSeconds({offsetSeconds})
OffsetDateTime <|--> ZonedDateTime : withTimeZone({timeZone})
CivilDateTime --> ZonedDateTime : withTimeZone({timeZone})
Instant --> ZonedDateTime : withTimeZone({timeZone})
CivilYearMonth : Number year
CivilYearMonth : Number month
CivilMonthDay : Number month
CivilMonthDay : Number day
CivilDate : Number weekOfYear
CivilDate : Number dayOfWeek
CivilDate : Number dayOfYear
CivilTime : Number hour
CivilTime : Number minute
CivilTime : Number second
CivilTime : Number millisecond
CivilTime : Number microsecond
CivilTime : Number nanosecond
OffsetDateTime : Instant instant
OffsetDateTime : Number offsetSeconds
OffsetDateTime : String offsetString
ZonedDateTime : String timeZone
Instant : Number epochSeconds
Instant : Number epochMilliseconds
Instant : BigInt epochMicroseconds
Instant : BigInt epochNanoseconds
Duration : Number years
Duration : Number months
Duration : Number days
Duration : Number hours
Duration : Number minutes
Duration : Number seconds
Duration : Number milliseconds
Duration : Number microseconds
Duration : Number nanoseconds

behavior

All 21 comments

Thanks for the summary. From a couple conversations afterwards:

  • Temporal should have built-in support for Intl. @sffc, @fabalbon, @gibson042 @pipobscure and I discussed including this of part of Intl.DateTimeFormat at length, but later, @domenic, pointed out that this isn't really compatible with the idea of lazy-loading built-in modules. So, now I'm thinking that this would make more sense as a separate formatter, e.g., the export from std:temporal could be called IntlTemporalFormat, and work generally the same as Intl.DateTimeFormat (even share options processing spec text, except for ignoring the timeZone). As we discussed previously, the idea of having a single IntlTemporalFormat instance supporting several different Temporal data types seems right. Internally, there would be one pattern per Temporal type that could be formatted. The types that are supported would initially be CivilYearMonth, CivilMonthDay, CivilDate, CivilTime, CivilDateTime, and ZonedDateTime.
  • We may want to factor the two things that get at the current "here and now"--access to the current timezone, and access to the current time (so we don't have to recommend that people use Date.now, and so it can be higher precision) into a separate module. @erights and I discussed this as an alternative to the System object for such things. It could be good for mocking in tests, e.g., in conjunction with import maps or Node module hooks, so you don't have to remap the whole module, only these parts. As a name, I'd suggest something like std:temporal/now, with two exports: now() returning an Instant, and timeZone() returning an IANA timezone string.

The above diagram seems like a great explanation to me, but I worry that it makes things look more complicated than they really are to someone who's not as deep in this problem space. I hope we can work in the next months on developing intuitive explanations and documentation. The intuition is pretty straightforward: You look at the data you have about the date/time, and then find the type that logically represents that. As your diagram explains, the methods are generally analogous between different types that contain subsets of each others' data.

Is there a need to support CivilDate[Time] vs OffsetDate[Time]? Could they be collapsed into 1, considering OffsetDateTime with an offset of 0 is the same as CivilDateTime? I understand that there are some conceptual boundaries here (e.g. OffsetDate doesn't make much sense) but it seems as @littledan points out that the amount of classes can become confusing. Another way to frame this could be that CivilDateTime could simply have an Number offset property which defaults to 0, forgoing any need for Offset*.

Is there a need to support CivilDate[Time] vs OffsetDate[Time]? Could they be collapsed into 1, considering OffsetDateTime with an offset of 0 is the same as CivilDateTime?

That collapsing doesn't work, because OffsetDateTime with offset 0 is _not_ the same as CivilDateTime—the former has a fixed position on the UTC timeline, while the former exists in an abstract calendar space divorced from it.

I understand that there are some conceptual boundaries here (e.g. OffsetDate doesn't make much sense) but it seems as @littledan points out that the amount of classes can become confusing. Another way to frame this could be that CivilDateTime could simply have an Number offset property which defaults to 0, forgoing any need for Offset*.

I made the same argument about collapsing together ZonedDateTime and Instant by means of nullable offsetSeconds/offsetString and timeZone, but was ultimately convinced by the corresponding complexity in method implementation (e.g., having to use different output formats in serialization based upon the presence vs. absence of certain data) that separation was better. I now believe that each class in the above diagram carries its own weight.

it is as complicated as it looks. i don't think this gives a better user-experience than Date. in fact, its less optimal in solving many ux-workflow problems, because Date at least grounds everything in tangible utc-time -- none of this [low-level] abstract-calendar CivilDateTime conversion cr*p.

perhaps the scope of this proposal should be narrowed to focus only on utc-time (_and standardizing timezone-conversion to/from utc in javascript_), which is were most real-world painpoints dealing with datetime arise.

I made a new issue to split off the Intl discussion.

https://github.com/tc39/proposal-temporal/issues/129

I don't think type.difference(compatibleType [, largestElementInResult]) is necessary. It seems like that could instead be a specific constructor of the Duration type. ex. new Duration(type, compatibleType [, largestElementInResult]).

@yay295 agreed. It’s actually something we can take from the types.

Are you both saying the same thing? @Yay295 seems to be suggesting the removal of a difference method in favor of just the Duration constructor, which a) doesn't make sense because one would expect Duration to be constructed from Duration-like data (e.g., {days: 4}) rather than from a pair of other entities, and b) would call into question the with<Element(s)> methods, which are largely also just wrappers around a different-class constructor (instant#withZone(timeZone) is basically just return new ZonedDateTime(this, timeZone), Date#withTime(timeLike) is basically just return new DateTime(this.year, this.month, this.day, timeLike.hour, timeLike.minute, …), etc.). If we have any such convenience methods at all, then difference should make the cut.

No. It’s just that Duration should be creatable and we don’t need “largest unit” argument, since it can be derived from the type.

So yes difference is still key!

Are you in NYC tonight? Drinks & chat?

The difference between the difference method and the with<Element(s)> methods is that the difference method returns a completely different type while the with<Element(s)> methods return what is effectively a superclass of the current type.

Having Duration(type, compatibleType [, largestElementInResult]) does not preclude a different constructor that could take "Duration-like data". new Graduation({days: 4}) would be the way to create a specific duration without context, while new Duration(type, compatibleType) would be the way to create a duration from the difference of two other types. I suppose Duration.fromDifference(type, compatibleType) could be another way to write it if you want to go with the factory-style approach.

As for them being convenience methods,
new Duration(a,b) is a difference of two characters
a.difference(b), but the best you can do with a with<Element(s)> method
new ZonedDateTime(a,b)
a.withTimeZone(b) is a difference of five characters, and it only gets worse
new CivilDate(a.year, a.month, b.day)
a.withDay(b). Of course
Duration.fromDifference(a,b) vs
a.difference(b) would be a more appreciable convenience.

That said, I know there is precedence for odd methods like this (ex. Java's .equals), so I'm not trying to absolutely push for this to change.

I agree that largestElementInResult is unnecessary, though perhaps the opposite would be useful as a method. Basically a Math.round method for Durations. So if you got the difference between two times, but don't need/want nanosecond precision, you could round it to the nearest second instead.

I would also argue that the plus/minus methods shouldn't cast their input to a Duration. If I have a specific date and add five days to it, I would expect to get a new date that is five days later than my starting date, not... a Duration of five days? What Duration is this actually supposed to return?

I would also argue that the plus/minus methods shouldn't cast their input to a Duration. If I have a specific date and add five days to it, I would expect to get a new date that is five days later than my starting date, not... a Duration of five days? What Duration is this actually supposed to return?

this matches what i'd expect; "plus" and "minus" should produce the same type; something that produces a duration would be like "fromNow" or "untilNow" or something. What does the current spec do?

This conversation feels very weird. For one thing there seem to be lots of misunderstandings of what methods do and what they take:

obj.difference(other) - takes an object of the same type and returns a Duration

obj.plus(duration) - takes something that’s like a Duration (Duration, object with the fields of a Duration, ISO-8601 duration). It returns something of the same type as the original object

obj.minus(duration) - same input/output types as .plus()

Now as to a Duration constructor that accepts two objects if the same of the same type: I think that’s a really bad anti-pattern. (Convince me of why it’s a good idea in a new issue, if you want)

I misread how plus/minus work, so that's my bad; it does what I thought it should.

obj.difference(other) - takes an object of the same type and returns a Duration

This was not clear before. The first post here only says it takes a compatibleType, not specifically the same type. I can see how getting the time difference between a CivilYearMonth with a CivilMonthDay wouldn't work, but it seems like you should be able to get the time difference between a CivilYearMonth and a CivilDate.

How would that work? A Temporal.YearMonth has no day. You can however do

date.getYearMonth().difference(yearMonth)

so it’s easy to do, you just have to be explicit

let date = new CivilDate(2019, 10, 1);
let year_month = new CivilYearMonth(2019, 11);
console.assert(date.difference(year_month).months === 1);

Assuming items are compared by their start. This doesn't seem to be specified though. Does it compare the starts, the ends, the time between the start and end, or the time between the end and start? These all give different answers, and actually it might be useful to have that as an option as to what range it returns. In this way it's possible to compare any two types as long as their largest unit is the same.

 

Also, I just noticed this:

Duration objects are produced by subtracting two temporal object from each other using the minus() method.

So according to the current documentation, plus and minus do not return the same type as the type they are called on. Though also here:

Creates a new OffsetDateTime object by subtracting values to its members.

So it seems like it needs some cleaning.

Absolutely: cleaning required!

(The canonical is currently the polyfill, because we are trying to work kinks out which is easiest in code)

A YearMonth represents the entire month. So the ym.difference(date) would require us to choose which end to take the difference from. We don’t want to make this kind if arbitrary choice.

So instead be explicit:

ym.withDay(1).difference(date)

or

ym.withDay(31).difference(date)

or

date.getYearMonth().difference(ym)

all are explicit and clear. So the mixing of objects for difference() is not justified given that it implies a host of unclear assumptions.

also the minus() from that part of the documentation stems from a time when we thought the could be negative Durations. However plus/minus actually require different algorithms.

So there now are:

obj.difference(obj) => duration
obj.plus(duration) => obj
obj.minus(duration) => obj

A YearMonth represents the entire month.

So would

let a = new CivilYearMonth(2019, 1);
let b = new CivilYearMonth(2020, 1);
console.log(a.difference(b).months);

log 11 then, if it's getting the time from the end of the a to the start of b?

Nope: 0 because a.difference(b).years === 1

All of this is dealt with

Was this page helpful?
0 / 5 - 0 ratings

Related issues

ptomato picture ptomato  Â·  5Comments

mj1856 picture mj1856  Â·  7Comments

felixfbecker picture felixfbecker  Â·  6Comments

marikaner picture marikaner  Â·  3Comments

littledan picture littledan  Â·  4Comments