tc39 / proposal-temporal

Provides standard objects and functions for working with dates and times.
https://tc39.es/proposal-temporal/docs/
Other
3.2k stars 146 forks source link

Printing in locale format using `toLocaleString` is verbose #2542

Closed thojanssens closed 1 year ago

thojanssens commented 1 year ago

When calling toLocaleString, if the calendar option is absent, the following error occurs:

Uncaught RangeError: cannot format PlainYearMonth with calendar iso8601 in locale with calendar gregory

currentYearMonth.toLocaleString(locale.languageTag, { calendar: currentYearMonth.calendar, month: 'short', year: 'numeric' })

As toLocaleString is called on the temporal object (currentYearMonth in this example), I do not understand why we have to specify again that the calendar is the one in that same object (currentYearMonth).

It makes toLocaleString calls excessively verbose.

thojanssens commented 1 year ago

Secondly, the ISO8601 standard provides a standardized format for representing dates and times in a machine-readable manner.

It is not a calendar like the Gregorian calendar though.

So this leaves me confused as to what the error actually is.

justingrant commented 1 year ago

The problem you're running into is that locales have a calendar included , either implicitly (every locale like en-GB or ar-SA has a default calendar) or explicitly in the locale identifier like en-GB-u-ca-japanese. These locales can be overridden using the calendar option of toLocaleString or the same option of the Intl.DateTimeFormat constructor. But there's always a calendar (separate from the calendar of the Temporal object) present in the arguments of those functions. Even when toLocaleString is called with no arguments, the default locale is used, which in turn has a default calendar.

So when you call toLocaleString or Intl.DateTimeFormat.prototype.format, there are two calendars that could be used: the calendar in the Temporal object, or the calendar passed in the arguments to the formatter object constructor or formatting function. If the two calendars are the same (like your example above of calendar: currentYearMonth.calendar) then there's no problem and the call succeeds.

But if the two calendars are different, then Temporal needs to figure out which calendar to use, or it needs to throw an exception if it can't figure out which one to use.

The next logical question to ask is why must you supply your own calendar option for some Temporal types like Temporal.PlainYearMonth, but not for others like Temporal.PlainDate? This is a good question, but answering it requires some background info below about calendars in Temporal.

Intentional use of calendars is very, very rare in real-world software. Almost all Temporal objects will be created with the default iso8601 calendar), which isn't used by default in any real-world locales.

This means that when a Temporal object has the iso8601 calendar, we assume that the developer doesn't have a preference of which calendar should be used when formatting that object. In other words, for the purpose of formatting we treat iso8601 as an undefined calendar that is always overridden by the formatter arguments. This removes the conflict between the object's calendar and the calendar in the formatting arguments.

In pseudocode, here's how this works for a type like Temporal.PlainDate:

getCalendarIdentifierFromArgs(locales, options) {
  if (options.calendar) return options.calendar.toString();
  const calendarInLocale = parse calendar ID from `locales`;
  if (calendarInLocale) return calendarInLocale;
  const defaultCalendarForLocale = get default calendar ID for `locales`;
  return  defaultCalendarForLocale;
}
class PlainDate { 
 . . .
  toLocaleString(locales, options) {
    const calendarFromArgs = getCalendarIdentifierFromArgs(locales, options);
    if (this.calendar.id !== `iso8601` && this.calendar.id !== calendarFromArgs) {
      throw new RangeError('Object calendar doesn't match locale calendar');
    }

    // Extract only the options relevant to Temporal.PlainDate. Omit others.
    const { calendar, dateStyle, era, year, month, day, weekday, numberingSystem, localeMatcher, formatMatcher } = options;
    const revisedOptions = { calendar, dateStyle, era, year, month, day, weekday, numberingSystem, localeMatcher, formatMatcher };
    const formatter = new Intl.DateTimeFormat(locales, revisedOptions);

    // Create a `Date` object using the equivalent date in the ISO 8601 calendar
    const isoDate = this.getISOFields();
    const date = new Date(isoDate.isoYear, this.isoMonth-1, this.isoDay);

    // Finally, format the Date
    return formatter.format(date);
  }
}

This works for all Temporal types that store a full date: Temporal.PlainDate, Temporal.PlainDateTime, and Temporal.ZonedDateTime, because full dates can be converted between calendars.

However, this trick doesn't work for Temporal types that *don't* store full dates: Temporal.PlainMonthDay and Temporal.PlainYearMonth. With those types, there's not enough information to convert between calendars. For example, assume my wedding anniversary birthday is June 1 in the Gregorian calendar. What is my birthday in the Japanese calendar? The answer is that it's impossible to determine without knowing the full date (including the year) of my wedding in the Japanese calendar. But Temporal.PlainMonthDay and Temporal.PlainYearMonth don't have the full date.

So this is why Temporal.PlainMonthDay and Temporal.PlainYearMonth require more verbose formatting code: because Temporal can't automatically convert between the object's calendar and the calendar of the locale. If, however, you manually set the calendar option, then no conversion is needed between the object's calendar and the locale's calendar, and the call succeeds.

Apologies for the long explanation, I hope this makes sense.

thojanssens commented 1 year ago

According to the error message I got, I have

ISO 8601 is a standard for representing date and time formats specifically in the context of the Gregorian calendar.

Therefore I do not understand why it's not possible to convert the iso8601 year-month data into a year-month as the Gregory calendar. I understand that data is missing (the day), but the iso8601 is a representation in Gregorian.

justingrant commented 1 year ago

There are two possible ways we could determine if calendars are equal, to determine whether to throw or not for any particular formatting operation where calendars must be equal:

  1. Would they always return the same formatted string? For your particular case, iso8601 and gregory would pass this test, as would chinese/dangi, islamiccc/islamic-civil, and ethiopic/ethioaa would all pass too.
  2. Do they have the same id?

The champions of this proposal chose (2) because it was simpler, had slightly better performance, and because it would avoid special-casing for gregory in userland code.

@sffc may be able to add more context around this decision.

sffc commented 1 year ago

Related: https://github.com/tc39/proposal-temporal/issues/2521

I would be in favor of automatically setting the calendar field in toLocaleString methods on relevant types, but this can be an improvement for a small follow-on proposal.

thojanssens commented 1 year ago

Ty for your answers. I think my points were clear but in other words:

ptomato commented 1 year ago

I agree, this is one of the more confusing parts of the proposal.

About this specifically:

we can simply still throw if the conversion can't be automatic

That brings its own disadvantages: it seems likely that it would lead to apps working in development, and crashing in production when executed in locales that use a calendar that can't be automatically converted to and from ISO.

I think it deserves a thread in proposal-temporal-v2, let's continue designing the API there: https://github.com/js-temporal/proposal-temporal-v2/issues/29