tc39 / proposal-temporal

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

Most of methods of Temporal.PlainDate requires a fresh new Calendar instance for each Temporal.PlainDate #1808

Closed Constellation closed 1 year ago

Constellation commented 3 years ago

The current spec creates a fresh instance for each Temporal.PlainDate. Since most of fields of Temporal.PlainDate touches calendars, Temporal.PlainDate almost always requires two objects per instance.

This fresh object is meaningful only when the user adds adhoc method to these created calendar instances after creating Temporal.PlainDate. But the use of custom calendar can be cleanly achieved by passing an optional calendar parameter to the Temporal.PlainDate constructor.

So, how about removing necessity of creating a calendar object for known calendars for performance and memory saving?

ptomato commented 3 years ago

My thinking on this topic so far has been that it will be mostly unnecessary to create calendar objects. If the following three conditions hold, then you won't need to create the object:

  1. Temporal.Calendar.prototype is untouched
  2. The calendar is a built-in calendar (I'm guessing iso8601 and the built-in human calendars will account for 99.9% of usage)
  3. The .calendar property of the Temporal object isn't accessed

It's annoying that the implementation would have to keep checking that condition 1 holds every time you would have to call a calendar method. But otherwise I expect that in the vast majority of applications, these 3 conditions will hold, and implementations can elide the calendar objects while still maintaining the same semantics as if they were created.

Do you have any ideas already in mind for how to remove the necessity of creating calendar objects?

Related: https://github.com/tc39/proposal-temporal/issues/1432; https://github.com/tc39/proposal-temporal/issues/1588; thread starting at https://github.com/tc39/proposal-temporal/issues/1294#issuecomment-789335468 for an exploration of a different way that it could work and the weirdnesses that that brings in.

gibson042 commented 2 years ago

I don't think implementation optimizations are the right perspective from which to approach this issue. The question is instead whether or not the following kinds of action-at-a-distance (and possibly covert communication) should be supported.

const instance = Temporal.PlainDate.from("2021-11-10");
Temporal.Calendar.from("iso8601").year = () => 0;
instance.year; // => 0
ptomato commented 2 years ago

Here's my summary of what we discussed in the meeting of 2021-11-18.

ptomato commented 2 years ago

I have done some initial investigation around this and will continue by figuring out how to express these changes in the spec text.

(1) Built-in calendar objects have own-property methods: https://github.com/tc39/proposal-temporal/compare/frozen-builtin-calendar-1?expand=1 (2) Built-in calendar objects inherit from BuiltinCalendar: https://github.com/tc39/proposal-temporal/compare/frozen-builtin-calendar-2?expand=1

In both branches you can see some tests that illustrate what this change would mean for Calendar objects. You can view the branches commit-by-commit to see what would change between the two approaches.

A few things to note:

Please let me know if you have any opinions about this.

ptomato commented 2 years ago

I haven't had any comments in the meantime, so I intend to proceed with approach (1) after the September plenary.

anba commented 2 years ago
  1. In https://github.com/tc39/proposal-iterator-helpers/issues/173, there was a requirement that all intrinsics should be reachable from the global object. Is this a non-issue here, because the built-in calendars and all their own properties are frozen?

  2. The approach to add all built-in Calendar methods as own properties seems to cover this comment:

    For implementors it's important to be able to easily determine "this calendar is built-in and unmodified" so they know whether it would be unobservable to optimize by skipping calling the calendar methods and just using the built-in calendar logic directly.

It's probably worth mentioning that this will make it more easier to optimise Calendar/TimeZone methods in the interpreter, but will probably require special handing in the JITs. For example if timeZone.toString() is called with different built-in TimeZone objects, the JIT compiler will see distinct toString functions.

  1. In https://github.com/tc39/proposal-temporal/compare/frozen-builtin-calendar-1?expand=1, every copied intrinsic is (essentially) a bound function (except for the "id" getter). So this is now valid: let {year} = Temporal.Calendar.from('iso8601'); year(2022);. I'm not sure how I feel about adding bound functions, because it means let {year} = calendar; year(2022); only works as long as only built-in Calendar object are used. This could lead to confusing errors when user-defined calendars are used.

I'm still not convinced that we need this optimisation. Especially copying every built-in method seems questionable to me. Checking if a built-in Calendar uses the built-in Calendar methods shouldn't be too hard to implement in engines and fairly fast. (Should be fast because there are only memory loads and comparisons.) Engines typically know how to implement this sequence of operations:

if (ClassOf(calendar) is not Temporal.Calendar) bail;

// If built-in calendars aren't frozen, check if any own properties are
// defined which may shadow built-in Calendar.prototype methods.
// (This is a cheap check, but can lead to deoptimisations when unrelated properties
// are added.)
if (HasOwnProperties(calendar)) bail;

// If built-in calendars aren't frozen, check we still use the initial prototype.
if (GetPrototypeOf(calendar) != Temporal.Calendar.prototype) bail;

// Either bail or handle updating the shape of Temporal.Calendar.prototype.
if (ShapeOf(Temporal.Calendar.prototype) != InitialShape(Temporal.Calendar.prototype)) bail;

// If the shape is still optimisable, we know where the Calendar method named "method" is
// stored. Get the current value and compare it against the expected value.
if (GetValue(Temporal.Calendar.prototype, Slot("method")) != InitialValue("method")) bail;
ptomato commented 2 years ago

Thanks for the comments!

  1. In Discoverability of wrapper prototypes proposal-iterator-helpers#173, there was a requirement that all intrinsics should be reachable from the global object. Is this a non-issue here, because the built-in calendars and all their own properties are frozen?

I intend to take this proposal to the SES meeting and discuss it there before discussing it in TC39. My understanding is that the way that adding new unreachable intrinsincs would "break existing code" is that the code expects to be able to reach them by walking the properties of the global object in order to freeze them. So, it seems like if they were already frozen that wouldn't apply.

I considered whether we could make them reachable by walking the properties, which seems doable for calendars (and you could test for support of a particular calendar with Temporal.Calendar.DISCORDIAN !== undefined; I'm not sure if that's an advantage or a disadvantage) but it doesn't seem practical for time zones.

  1. The approach to add all built-in Calendar methods as own properties seems to cover this comment:

For implementors it's important to be able to easily determine "this calendar is built-in and unmodified" so they know whether it would be unobservable to optimize by skipping calling the calendar methods and just using the built-in calendar logic directly.

It's probably worth mentioning that this will make it more easier to optimise Calendar/TimeZone methods in the interpreter, but will probably require special handing in the JITs. For example if timeZone.toString() is called with different built-in TimeZone objects, the JIT compiler will see distinct toString functions.

I'm glad you raised this. How big of a problem is it? I don't have a sense of whether it'd be a minor annoyance for JIT implementors or something that would break all sorts of assumptions.

  1. In https://github.com/tc39/proposal-temporal/compare/frozen-builtin-calendar-1?expand=1, every copied intrinsic is (essentially) a bound function (except for the "id" getter). So this is now valid: let {year} = Temporal.Calendar.from('iso8601'); year(2022);. I'm not sure how I feel about adding bound functions, because it means let {year} = calendar; year(2022); only works as long as only built-in Calendar object are used. This could lead to confusing errors when user-defined calendars are used.

That sounds like a good reason to go for approach 2 rather than 1, if there are no larger disadvantages.

Although in approach 2 let {year} = calendar; would give you a different year method depending on whether calendar was a built-in or user calendar. I'm not sure whether that's a concern.

I'm still not convinced that we need this optimisation. Especially copying every built-in method seems questionable to me. Checking if a built-in Calendar uses the built-in Calendar methods shouldn't be too hard to implement in engines and fairly fast. (Should be fast because there are only memory loads and comparisons.) Engines typically know how to implement this sequence of operations:

This is helpful too. This is a more detailed version of my hand-wavy assumption in https://github.com/tc39/proposal-temporal/issues/1808#issuecomment-919593231 that engines could optimize this. All other things being equal, I'd be happy not to make a change here. Maybe @Constellation and @FrankYFTang could weigh in about these checks?

anba commented 2 years ago

How big of a problem is it? I don't have a sense of whether it'd be a minor annoyance for JIT implementors or something that would break all sorts of assumptions.

It kind of depends on the exact specification. For example it's more complicated if every built-in Calendar/TimeZone method is a bound function. It should be less complicated if the methods are the same function object for all built-in Calendar/TimeZone objects. That means if Temporal.Calendar.from("iso8601").year === Temporal.Calendar.from("gregory").year. It's also less complicated when approach 2 is used. (This is from my SpiderMonkey POV, not sure if other engines have better support for polymorphic calls through bound functions.)

Although in approach 2 let {year} = calendar; would give you a different year method depending on whether calendar was a built-in or user calendar.

That's also the case for approach 1, isn't it?

With approach 1:

var iso8601 = Temporal.Calendar.from("iso8601");
var gregorian = Temporal.Calendar.from("gregory");

assert.notSameValue(iso8601.year, gregorian.year);
assert.notSameValue(iso8601.year, Temporal.Calendar.prototype.year);
assert.notSameValue(gregorian.year, Temporal.Calendar.prototype.year);

With approach 2:

var iso8601 = Temporal.Calendar.from("iso8601");
var gregorian = Temporal.Calendar.from("gregory");

assert.sameValue(iso8601.year, gregorian.year);
assert.notSameValue(iso8601.year, Temporal.Calendar.prototype.year);
assert.notSameValue(gregorian.year, Temporal.Calendar.prototype.year);
littledan commented 1 year ago

I think this issue is an important one to address, since the optimization cited earlier is very fragile: It needs to be turned off both whenever there are any changes to the Timezone/Calendar prototypes, and whenever the calendar accessor is explicitly used. ES6 introduced the need for a number of such fragile optimizations, and this lead to performance cliffs when expectations are violated, which are ultimately not a great experience for everyone all around.

At the same time, I'd be cautious about using frozen classes to enable singleton instances. The most natural way to freeze classes would be to make Temporal.Calendar and Temporal.TimeZone deeply frozen, but this prevents important polyfilling use cases, as discussed above. The alternatives, based on a parallel class hierarchy or many own methods, have a couple downsides (1) they add a bunch of complexity and differ from the normal pattern of built-in classes (2) as @anba pointed out, they introduce built-in values which aren't reachable from the global object just through property access and not method calls.

To avoid these problems, I'd suggest a different design. The following is based on significant discussion with @gibson042 @ptomato and @pipobscure who helped me refine it.

The data model of calendars and timezones, and occur as internal slots in dates and times and such, changes from "always an object with methods for operations" to "either an object or a string". If a string is provided as the argument to a date/time constructor, or if the Now/iso APIs are used, or the default iso calendar from certain date APIs appears, then rather than creating a Temporal.Calendar or Temporal.TimeZone instance, the built-in calendar/timezone is represented directly as the string that was going to be passed into that constructor.

For example, the .calendar accessor may return either a string or an object, directly reflecting the data model. You can always use Temporal.Calendar.from, either on the date or the .calendar, to get something that you can then call calendar methods on. Each time you do Temporal.Calendar.from(aDateWithISOCalendar), you'll get a new ISO calendar instance, even though it's the same date, since it's recreating something new from the "gregory" string.

When an operation is done on a calendar or timezone, the logic changes from "call the appropriate method and use the result" to "if this is a string, directly apply the correct built-in algorithm; otherwise, call the right method on the calendar/timezone object".

In the end, the observable behavior is extremely similar to if we used frozen classes for singleton objects for built-in timezones and calendars, but without all the complexity around the object model of those instances, and substituting that for the complexity of the logic to switch between the two cases at method call sites in internal algorithms. Frozen classes and the string-based system are similar in that there is no risk of a "performance cliff" from optimizations that may turn off if you get them confused.

What do you think?

sffc commented 1 year ago

I like the direction of using a primitive to represent built-in calendars and time zones.

At what point do we check that we support the calendar or time zone ID? Do we verify eagerly, or do we wait until we actually need it to perform an operation?

Is the string calendar/time zone ID the correct primitive, or should we consider e.g. symbols as the primitive?

justingrant commented 1 year ago

The proposed solution from @littledan is intriguing. I see its advantages, especially for implementers. Is this "string or object" pattern used in other built-in objects, either for method return values or object properties? (Other than types like Set whose entire raison d'être is storing and returning arbitrary userland values.)

I admittedly don't understand the downsides of the frozen object alternative. @littledan, I heard from @ptomato that you have some insight about challenges (technical as well as more opinionated concerns from committee members) about freezing. Could you share those here?

I also see some significant challenges from the perspective of app developers and userland libraries... challenges which would go away if a frozen-object solution were adopted. I'll follow up with another post soon to explain the problems I see for app developers, but in parallel I'd also like to understand the problems with frozen objects, which might outweigh the problems I'm seeing for "string or object" properties.

justingrant commented 1 year ago

I also see some significant challenges from the perspective of app developers and userland libraries... challenges which would go away if a frozen-object solution were adopted.

Here's a few concerns around the "string or object" solution:

TypeScript

One problem is library code:

// library.ts
export function nowInMyTimeZone(timeZone?: string) {
 return Temporal.Now.zonedDateTimeISO(timeZone); 
}

// app.ts
function doSomething(zdt: Temporal.ZonedDateTime) {
  const now = nowInMyTimeZone(zdt.timeZone); // ERROR!
  ...
}

Library authors (including people in your own company) will probably assume that TZ is always a string, which puts callers of that code in a bind. They can lie to TS and claim it's a string, knowing that it will probably be OK. Or they can try to get the library author to change their signature, which can often be hard or impossible depending on the maintainer.

This could also happen with the current API. Nothing stops library authors from being more restrictive. But I suspect that having the properties be strings almost all the time will make it more likely.

There's also an easy-to-fix but annoying ergonomic issue, where type casts will be needed in a lot of places.

let tz = 'America/Los_Angeles';
if (someCondition) tz = zdt.timeZone; // ERROR!  

The core problem is that even if a developer's code has no custom timezones/calendars, TypeScript doesn't know this so type casts are required. And if the developer adds type casts, then the code becomes brittle if, later, custom timezones/calendars are added.

Challenges with ===

If developers assume (as most probably will) that .timeZone / .calendar is always a string, then it's obvious and ergonomic to write code like this:

if (timeZone === 'UTC') { ... }

This code will almost always work, but it will break if timeZone is an object like Temporal.TimeZone.from('UTC'). This can make code, esp. library code, vulnerable to subtle bugs.

Challenges with ===, cont'd: React Hooks

Some libraries use === (or similar APIs like Object.is) to make decisions about whether a value has changed. The most obvious example is how some React APIs depend on fixed object identity of the strings, which then breaks for custom TZ/Calendars.

For example, React's useCallback API accepts a dependencies array parameter. React uses those dependencies to decide if a component needs to be re-rendered.

With the current Temporal API, developers using useCallback will know that they need to always call .id or .toString() before dropping a timezone into a dependency array. With the new API, they'll probably just drop the .timeZone value as-is. If the timezone is custom, or if the code has been passed a built-in timezone object instead of a string, then it'd cause React to mistakenly assume that the dependencies have changed every time.

Conditional Logic

I suspect that most developers will just assume that timezones and calendars will always be strings, because that's what almost all code will return for the vast majority of programs where there's no custom timezones or calendars.

But conscientious developers (including many library developers but sadly not all of them!) will know that these values can sometimes be objects. Defending against this rare case will require conditional code wherever timezone methods are called, wherever timezone/calendars are tested for equality, and probably a few other cases I'm not thinking of.

Ideally we could avoid the additional complexity (and code size and...) of conditional code.

ljharb commented 1 year ago

Given that there’s TS syntax to extract the type of the first argument from zonedDateTimeISO, and the DT types would probably export this as a type for convenience, if a dev only accepts a string they just have a bug. I don’t think we need to be concerned with this case in TS when designing JS.

littledan commented 1 year ago

Apologies for my delayed response here. It will take me more time to consider how significant the downsides of object-or-string are, but here's my reasoning for avoiding frozen objects:

If we make a frozen class, we have two choices, both of which violate constraints that we've discussed in this thread:

I'm also motivated by a possibly-weaker rationale for the object-or-string version: I see it as having a smaller "surface area". Now, this is a very debatable concept--exposing strings is its own surface area--but making this whole parallel class, which then implementations are supposed to realize they can elide, seems to me like a lot of "stuff" when there's a lighter-weight option sitting right there.

I think JS is based on pervasive "union types", so it wouldn't be out of character to use one here. I do see your point that APIs that expect a timezone object should now also accept a string, if we adopt this change.

justingrant commented 1 year ago

@littledan I think JS is based on pervasive "union types", so it wouldn't be out of character to use one here.

My understanding is that unions (aka permissive typing) are used extensively in parameters to methods and constructors, as well as in coercion. In other words, as input.

But my understanding is that the language generally canonicalizes input into well-known types on output generated by the language. Is this assumption correct?

Are there any other cases in the language today where the return type of a method or property getter is a union type? (Other than the obvious exceptions of Array, Set, etc. who's entire purpose is to expose user data which could be a union.)

justingrant commented 1 year ago

@littledan If we make a frozen class

Dumb question: why do classes need to be frozen? Why not just freeze the instances but not the class?

ljharb commented 1 year ago

The prototype would still need to be frozen, otherwise the instance methods wouldn't be safe to use.

littledan commented 1 year ago

If the methods are frozen, then the built-in implementation can be used rather than dispatching out to JS all the time. The string version meets this goal. Freezing the instances doesn't achieve the elimination of state, since the big issue is that we shouldn't have any "hidden" identities embedded in methods like Temporal.TimeZone.from, per SES. (e.g., Identity + WeakMap ==> state)

justingrant commented 1 year ago

The prototype would still need to be frozen, otherwise the instance methods wouldn't be safe to use.

@ljharb is SES-compatible that what you meant by "safe to use"? Or is there a different problem you were referring to?

If the former, @gibson042 noted in today's meeting that because all the methods on the TZ/Cal prototypes would be reachable from the global object, they could be locked down by SES even if shared singleton TZ/Cal instances were used. Or did we miss something?

@littledan Freezing the instances doesn't achieve the elimination of state, since the big issue is that we shouldn't have any "hidden" identities embedded in methods like Temporal.TimeZone.from, per SES. (e.g., Identity + WeakMap ==> state)

Would it be possible to explain this case a bit more? What's the specific attack you have in mind? Also, @gibson042 how does Dan's point here relate to your conclusion in today's meeting that shared singleton frozen objects seemed to be SES-compatible?

@littledan If the methods are frozen, then the built-in implementation can be used rather than dispatching out to JS all the time.

Does frozen instances achieve this goal too?

ljharb commented 1 year ago

@justingrant yeah that's fair that in SES the methods would be frozen too so it'd be fine.

justingrant commented 1 year ago

We discussed a few more things related to this issue:

Polyfillling

We discussed whether doing this would make polyfilling harder, and our answer was "No" for these reasons:

So our conclusion in the meeting was that freezing TZ/Cal builtin instances (and making those frozen instances singletons) didn't seem to make polyfilling those types any harder than it is today when each new TZ/Cal instance is a new object.

Is this conclusion correct?

Expandos

A capability that is lost with frozen builtin instances is the ability to add expando properties to existing instances. This capability would also be lost if we used strings, because strings don't seem to store expando properties either.

s = "s";
s.foo = 42;
s.foo === undefined
// => true

So regardless of how we solve the problem in this issue, arbitrary expandos are out of scope anyways.

SES

We also discussed SES issues. @gibson042's explanation was that any singleton object returned by builtin objects must not be able to access any mutable object that is not reachable from the global object. The only mutable objects referenced by TZ/Cal instances are the prototypes of those classes, and those prototypes are reachable from the global object. So our consensus was that frozen singleton objects weren't an SES problem.

Anyway, that was the discussion today. What did we miss?

littledan commented 1 year ago

Sorry, when I wrote the above comment, I forgot about the idea where these are all have own methods on the frozen objects. I guess this sidesteps some of the issues. I don't understand the bounds of the idea from @gibson042 that immutable objects are fine to have singletons of--maybe he can explain the reasoning there. If you don't want to go the object-or-string route, I recommend bringing whatever proposal to the SES group as soon as possible, given that there are diverse opinions there; also note that frequently it takes that group time to realize that they have concerns about something.

Personally, I find this frozen instance solution very complicated, and significantly prefer the string version for its simplicity (but I will not block whatever solution the champion group settles on). We don't generally use own methods in the JS standard library, and engines generally optimize for prototype methods. Implementations will need to be coherent with respect to whether these frozen objects share identities, so I guess in practice this will mean caching the timezone or calendar objects when they are created--a small extra source of complexity incurred.

littledan commented 1 year ago

Yes, I agree that the solution you are thinking of selecting meets the polyfilling constraint (with the tradeoff of then violating what I thought the other constraint was, but I guess @gibson042 has some context here that I lacked)--the point of the post is that they are in tension. As for expandos--yeah, I agree that this does not seem important to consider to be a goal.

ptomato commented 1 year ago

To be clear, I prefer the solution with strings, also for its simplicity; both for implementors and also for its learnability for developers. (I'd find it significantly easier to explain "calendars can either be strings, for built-in behaviour, or objects, for custom behaviour" than "calendars are objects and the ones with built-in behaviour are frozen" — I'd guess that your average JS developer rarely if ever encounters frozen objects)

That said, I think all three solutions — do nothing, frozen singletons, and strings — would be acceptable. They all have minor drawbacks, but none are particularly concerning to me.

But, to the extent that I understand the SES concerns after talking with Richard, my understanding is also that the frozen singleton solution wouldn't be a problem, even if the singletons (and the built-in calendar class, if applicable) were not reachable from the global object via property traversal, because they'd be deep-frozen. My understanding is that we can't add unfrozen built-ins that aren't reachable via property traversal, because old versions of hardened JS won't know to freeze them and will therefore be rendered insecure. But if built-ins are already deep-frozen then they don't have to be reachable, because even if old versions of hardened JS aren't aware of them, they still can't be used as communication channels.

(But still, I prefer the strings)

gibson042 commented 1 year ago

I also prefer the "built-in calendars are represented as strings" approach, but you have correctly summarized my assertion that unreachable built-ins are compatible with Hardened JS if every object in the subset of their object graph that is not reachable by transitive property access from the primordials is frozen—e.g., the prototype of such objects could be unfrozen if it is reachable from Temporal (where lockdown() could find and harden it), but the objects theirselves and all of their property values (including own methods) would need to be frozen because they are not discoverable by lockdown().

sffc commented 1 year ago

I find @justingrant's DX-focused arguments compelling, especially the issues surrounding === and anything that wrongly assumes the values are always primitives. DX is something that will bite for years and decades to come.

It does seem though that TypeScript can help us here, by returning a union type in the canonical 262 TS declarations file.

justingrant commented 1 year ago

Good discussion!

the idea where these are all have own methods on the frozen objects.

My understanding from today's meeting is that the frozen proposal was NOT to add own methods. Rather, when Temporal built-in objects create TimeZone or Calendar objects, those instances would be frozen, singleton objects whose prototypes would point to the (unfrozen by default, but frozen in an SES environment) Temporal.Calendar and Temporal.TimeZone classes. The only "own data" on TZ/Calendar instances would be the id of the built-in instance. Everything else would be on the prototype.

@littledan does this change your (or anyone else's) feedback?

My understanding is below-- please holler if I got this wrong:

DX-focused arguments compelling, especially the issues surrounding === and anything that wrongly assumes the values are always primitives

Yep. Lately I've been thinking of DX in terms of "ecosystem risk". For example, a simple React app I'm working on has 829 dependencies (mostly transitive). Even if 95% of library developers correctly navigate a DX gotcha, given today's deeply-intertwined dependencies, the chance of all dependencies evading it quickly approaches zero.

This is especially true for issues like this one: where code works great with "normal" (inputs things that library developers think to test for) but that break given unexpected and/or malicious input when that code is in the wild.

It does seem though that TypeScript can help us here, by returning a union type in the canonical 262 TS declarations file.

I agree, to a point. TS will help users notice the potential problem. But having observed many sloppy teams using TS, many developers will throw up their hands and apply type casts because they've never seen an object TZ or calendar in the wild so they'll assume it will never happen in their app. If casts are applied, then TS is now just as bad as JS downstream of those casts... perhaps even worse if users are lulled into a false sense of comfort. But I agree... TS makes this union safer.

littledan commented 1 year ago

We're all trying to make the DX as good as possible here--I agree with some of the others upthread that strings are a natural way to represent the built-in calendars/timezones. They are already represented in our API surface, as the strings you can pass into Temporal.{Calendar,TimeZone}.from, so it isn't even really adding a new concept.

Overall, I still prefer the string variant, principally due to its simplicity, smaller surface area, and avoidance of charting a new path for how the JS standard library works.

Because the instances are frozen singletons, I'm naively assuming that they'll be easier for implementers to optimize. (Is this true?)

Not as far as I can tell. Compared to the current version of the proposal, depending on the details, some optimizations should be permitted, but it is somewhat complicated to take advantage of (but a little less complicated than the optimizations possible against the current proposal that @ptomato explained, depending how much exactly is frozen/own). The string version should be simpler to implement and optimize, and land us at a more optimized final point than any of the frozen object versions that I've heard of (though probably this won't form any meaningful sort of "bottleneck"). I tried to explain that upthread. After the ES6 experience, I think many JS engines will be hesitant to implement "protector" bits in the first go, and just implement these things naively if they have to do all the fallback cases that the "frozen objects but no own methods/frozen prototypes" path implies, plus they will have the complexity of needing to implement the reliable caching for these objects.

ptomato commented 1 year ago

Because the instances are frozen singletons, I'm naively assuming that they'll be easier for implementers to optimize. (Is this true?)

This is the part that wouldn't work — only the instances being frozen singletons would not allow the particular optimization under discussion. The prototype would also have to be frozen, or the prototype methods would have to be shadowed by own methods. So, the "frozen" proposal really does have to do one of those two things.

sffc commented 1 year ago

Would it address the concerns here if we returned a non-primitive like

let date = Temporal.PlainDate.from({ year: 2022, month: 10, day: 28, calendar: "gregory" });
date.calendar
// { id: "gregory", builtin: true }

This is not a Temporal.Calendar, so engines can still optimize, and we don't need to worry about frozen prototypes and all the adjacent issues to that, but it's also not a primitive, so developers are less likely to make mistakes like ===.

pipobscure commented 1 year ago

Ultimately any object whose valueOf is a calendar name string would do the trick. Especially since we use string comparisons to compare calendars. But at that point I think sanity overrules DX-hypotheses and we could just go with strings.

While I do agree DX is important, I also think that it’s entirely overestimated. First I think we are overestimating our capacity to predict DX effects; and second I’d claim JS is proof positive that DX is no predictor for success.

In short: I have serious doubts that the hypothesis “string object mixtures are bad for DX” is significantly true. I also have doubts that “frozen objects is better than strings/objects for DX” is significantly true.

I agree that for people that love typed languages that intuition seems true; I just don’t think we’ve seen any actual evidence of this having any significant impact on real-life JS developers experiences.

ljharb commented 1 year ago

I agree with both of those doubts, fwiw. Anyone using TS will rely on the type defs, and it'll be their own failure if they ignore them; anyone not using it at this point, will likely not make the mistake of assuming the type without runtime reflection.

(i think i lean mildly towards just strings, but i'm not certain what my preference is yet)

littledan commented 1 year ago

If we return an object, we have to decide on the identity of the object, namely whether it is always or never cached. Either of those is more API surface area/logic (and operations at runtime) than using a string, though arguably it is not too much. It's further API surface area if these objects differ in some way (eg are frozen, have a different set of properties, etc). Mostly, I don't understand the arguments that strings are bad DX, which I guess is the core reason for considering extra complexity here.

sffc commented 1 year ago

Symbols are another option we should consider, if they would help the DX versus strings. Symbols come with a few questions of their own (should they be well-known or not, etc), but we should be able to answer those questions.

@justingrant Can you update your list of DX concerns with the impact if "simple objects" or Symbols were used instead of strings?

justingrant commented 1 year ago

Mostly, I don't understand the arguments that strings are bad DX, which I guess is the core reason for considering extra complexity here.

The DX issues mostly stem from the fact that there will be two different types returned, with one of those types (strings) being used 99%+ of the time and the other (objects) used rarely. Any time there's a lopsided distribution of usage, some developers will assume that 99%+ === 100%, and their code will break in the object case. If they're building libraries, then other users' code may break too. For example:

Code that makes these assumptions will work fine for built-in timezones and calendars, but will break when confronted with custom timezones or calendars.

So I expect the main impact of this DX issue will be to make custom timezones or calendars more brittle and unreliable because a lot of existing code won't expect non-string property values.

TS will mitigate this somewhat, although depending on how developers deal with the TS errors, it could make things worse. A reasonable reaction to TS's errors could be for developers to always box TZ/cal string ids into a Temporal.TimeZone or Temporal.Calendar object. This would remove the TS hassle of having to always use conditional code or union types when dealing with property values. But it'd break libraries (esp. if they're not written in TS) that assume strings.

avoidance of charting a new path for how the JS standard library works.

@littledan - Hmm, interesting. Is there an example in the language of using a union type for a property getter or method return value? (Other than the obvious exceptions of Array, Set, etc. who's entire purpose is to expose user data which could be a union.)

Can you update your list of DX concerns with the impact if "simple objects" or Symbols were used instead of strings?

@sffc If simple objects, then I'm not sure how it helps. We'd still have the problem of needing to use frozen objects or strings, just one level down.

I think Symbols would be worse. They'd have all the problems with the union type example without the usability benefit of strings.

justingrant commented 1 year ago

I have a crazy proposal that would remove the DX concerns with union types: what if the timeZone and calendar properties always returned strings, even for object timezones and calendars? And then we added separate methods to get objects? This:

calendar: string;
timeZone: string;
getCalendarObject(): Temporal.CalendarProtocol; 
getTimeZoneObject(): Temporal.TimeZoneProtocol; 

The property getter implementation would be identical to the string proposal, except getters would call toString (or the id getter) on custom objects before returning values.

The new methods would behave like TimeZone.from:

The downside of this proposal would be that callers who read the property and then try to use it to instantiate another instance would require a more full-featured polyfill to intercept the string value and pass it to the custom TZ/calendar constructor. Or they could use getXXXObject() instead.

But there's a few advantages:

If we wanted, I guess we could rename calendar and timeZone properties to calendarId and timeZoneId if we wanted to be super-clear about what's happening... although that's a big breaking change so I'd be hesitant that it's needed at this stage.

Thoughts?

sffc commented 1 year ago

@sffc If simple objects, then I'm not sure how it helps. We'd still have the problem of needing to use frozen objects or strings, just one level down.

Objects solve the === problem and the string-only-functions problem.

However, if custom calendars return the same object each time, then, doesn't === just work, even with strings?

I think Symbols would be worse. They'd have all the problems with the union type example without the usability benefit of strings.

The "usability benefit of strings" is one of the things we're trying to circumvent. Devs shouldn't be treating these as strings. Symbols scream "hey, something weird is going on here."

I have a crazy proposal

Very interesting; needs some more thought, but it seems promising.

pipobscure commented 1 year ago

As stated above, I have yet to see any actual evidence for there being any DX problem with the strings proposal. And no “I have a twitch in my knee about it” is not evidence.

So for now I have a hard time thinking about ever more complex/crazy/different proposals. I truly believe there isn’t a problem to solve. Can someone please enlighten me as to why we think there even is a DC issue with the strings proposal?

littledan commented 1 year ago

I agree that Symbols would be worse. The JS standard library is really small; I think we should refer to the ecosystem and Web APIs rather than ECMA-262 when assessing if something like union types is idiomatic--TS and WebIDL support union types because they are common.

I'd like to mention another alternative which @pipobscure previously raised (due to past discomfort about exactly this issue) which was to remove the .calendar getter and only permit Temporal.Calendar.from(value), which would always return an object. This API will give you a new object if value has a string-based calendar, or an existing object if it does not. Would this solve the problem that @sffc and @justingrant are concerned about, (which I still have trouble understanding)?

justingrant commented 1 year ago

TS and WebIDL support union types because they are common.

@littledan (or others if you know) - Are there any examples in the language, or in other web API families like the ones you mention, for union types that are:

I wasn't aware of any of these cases, and we couldn't think of any in the last champions meeting, but that doesn't mean there isn't a precedent somewhere. Is there?

remove the .calendar getter and only permit Temporal.Calendar.from(value), ... Would this solve the problem that @sffc and @justingrant are concerned about,

This seems unnecessarily undiscoverable and un-ergonomic. If we must remove the getter (or even if we don't, per my proposal above) then we could add methods on Temporal objects' prototypes that would return the same as Temporal.TimeZone.from(this) and Temporal.Calendar.from(this), but with better DX than a static method because:

FWIW, this approach of using a method to return objects is exactly what we did in ZonedDateTime.p.startOfDay(). Could it work here too?

(which I still have trouble understanding)?

Sorry if I wasn't clear enough above, I'll give it another shot. The main challenge is that the property will almost always return a string, so many programmers won't think to build or test for the object case. TS helps here, at the cost of worse ergonomics because you'll have to add conditional code everywhere to satisfy the compiler. But doesn't solve the problem because if developers think of timezones as being strings, then they'll write code (and TS typings for that code!) that make the same assumption, which will make it hard or impossible to introduce custom TZs/Cals later.

The result is that it's easy to write code that will break in the presence of custom calendars or time zones. Across the JS ecosystem of thousands of interdependent libraries (or the equivalent dependency web inside any large app or company), even if only a small % of that code assumes string-only, then it makes custom TZ/Cal objects fragile to use overall because you never know if some dependency is gonna break.

What do y'all think of my proposal above to have two prototype members: a getter for the string ID and a method to return an object?

sffc commented 1 year ago

(which I still have trouble understanding)?

I'll take a shot, too. JS is based around the idea of duck-typing; this shows itself in very many TC39 proposals. Duck typing works when union types all implement some common interface. However, in this case, there's no common interface between the primitive string calendars and the object calendars. It's impossible for code to be built generically between the two types of calendars. Most code will just assume that calendars are strings, and break use cases where the calendars are objects.

littledan commented 1 year ago

What do you think of the alternative I mentioned above, where there is simply no .calendar getter, and you need to do Temporal.Calendar.from(date) to get the calendar out, and this creates a new calendar object each time if it's a string calendar? Built-in algorithms would still cheat and read the string calendar, so it wouldn't allocate in practice unless actual JS code asked for it.

sffc commented 1 year ago

What do you think of the alternative I mentioned above, where there is simply no .calendar getter, and you need to do Temporal.Calendar.from(date) to get the calendar out, and this creates a new calendar object each time if it's a string calendar? Built-in algorithms would still cheat and read the string calendar, so it wouldn't allocate in practice unless actual JS code asked for it.

Fine with me for preventing the footgun, but I think this should be weighed against @justingrant's suggestion of .getCalendarObject(). Remember that this affects time zones, too.

ptomato commented 1 year ago

The reason why I don't consider this union return type a DX problem is because you only need to care about what type it actually is, if you are intending to manually call a method on the calendar: something we've repeatedly affirmed is only for power users. I expect that the vast majority of code that uses the calendar getter will probably look like this, i.e. just pass the calendar through unchanged to some other API:

someApi({ ...someProperties, calendar: someObject.calendar })

It just doesn't seem like a problem if most code assumes that calendars are strings, because most code won't be calling calendar methods on them. The problematic case we are talking about here is when you have an object and you are expecting to be able to call a string method on it. I just expect that calling a String method when the possible strings are a set of predefined short strings is pretty unlikely.

(Maybe it could be a concern when using === to compare an object calendar to a string, but you could arguably make a case that it's better if that comparison evaluates to false. No position on that currently, just saying it's unclear whether that's a wart or an improvement.)

littledan commented 1 year ago

I agree with @ptomato 's comment, but at the same time, I'd be happy with the suggestion in https://github.com/tc39/proposal-temporal/issues/1808#issuecomment-1297823240 as well. Thanks for drawing attention to that, @sffc.

sffc commented 1 year ago

Good perspective @ptomato. I think that speaks to why it should be easy to get the calendar out, but it could still be on a different getter in that case.

I realized that if we make .calendar always return a string, then spreading into .with() will cause the custom calendar object to be lost.

How about:

justingrant commented 1 year ago

I support @sffc's proposed naming above; it's the same as what I proposed above modulo name changes, and Shane's names seem very clear. It'd be a pretty significant breaking change, but seems worth it if it causes a big improvement in implementation perf.

ptomato commented 1 year ago

To clarify, do you mean that .getCalendar() and .getTimeZone() would return a new object each time if the internal slot value was a string? If it was an object, it seems like that object should still be returned.

I realized that if we make .calendar always return a string, then spreading into .with() will cause the custom calendar object to be lost.

That's technically correct, but I'm not sure it's a problem :smile: Spreading any Temporal object loses all the properties, not just .calendar, because they are not own properties, and that wouldn't change. This is the reason we have separate .withCalendar(), .withPlainDate(), etc. methods.

justingrant commented 1 year ago

To clarify, do you mean that .getCalendar() and .getTimeZone() would return a new object each time if the internal slot value was a string? If it was an object, it seems like that object should still be returned.

@ptomato, what I intended in my earlier proposal (which I think is the same as Shane's except for the names?) was what I think you're suggesting. Excerpting from my comment above:

The new methods would behave like TimeZone.from:

  • For built-in calendars and time zones, create a new instance every time using the string id.
  • For custom calendars and time zones, just return the stored object.

@sffc is this what you had in mind? Maybe it would be easier to express in code. Here I'm using Shane's names but can bikeshed other names too. I'm treating slots as private fields because it's pseudocode and I'm lazy.

get calendarId(): string {
  return this.#builtInCalendarId ?? this.#calendarObject.toString(); 
}
get timeZoneId(): string {
  return this.#builtInTimeZoneId ?? this.#timeZoneObject.toString(); 
}
getCalendar(): Temporal.CalendarProtocol {
  return this.#builtInCalendarId ? Temporal.Calendar.from(this.#builtInCalendarId) : this.#calendarObject; 
}
getTimeZone(): Temporal.TimeZoneProtocol {
  return this.#builtInTimeZoneId ? Temporal.TimeZone.from(this.#builtInTimeZoneId) : this.#timeZoneObject; 
}

I realized that if we make .calendar always return a string, then spreading into .with() will cause the custom calendar object to be lost.

That's technically correct, but I'm not sure it's a problem 😄 Spreading any Temporal object loses all the properties, not just .calendar, because they are not own properties, and that wouldn't change. This is the reason we have separate .withCalendar(), .withPlainDate(), etc. methods.

Yep, I didn't understand that concern either. @sffc could you explain, maybe with a code sample, of what the problem is?

One thing we haven't discussed yet is getISOFields. Given that getISOFields method is mainly for advanced use by authors of custom calendars, I think it'd be OK to have that method's timeZone and calendar properties return the string | object union type. Because if there's anyone who'll properly test for custom calendars, it's the authors of custom calendars! 😄

sffc commented 1 year ago

To clarify, do you mean that .getCalendar() and .getTimeZone() would return a new object each time if the internal slot value was a string? If it was an object, it seems like that object should still be returned.

Good point. Yes, that behavior makes sense, since there's no clear mechanism to clone the custom calendar object. So, new object for built-in calendars, and same object for custom calendars.

@ptomato, what I intended in my earlier proposal (which I think is the same as Shane's except for the names?) was what I think you're suggesting. Excerpting from my comment above:

Yes, indeed. This is @justingrant's proposal, just with new names.

The new methods would behave like TimeZone.from:

  • For built-in calendars and time zones, create a new instance every time using the string id.
  • For custom calendars and time zones, just return the stored object.

@sffc is this what you had in mind? Maybe it would be easier to express in code. Here I'm using Shane's names but can bikeshed other names too. I'm treating slots as private fields because it's pseudocode and I'm lazy.

get calendarId(): string {
  return this.#builtInCalendarId ?? this.#calendarObject.toString(); 
}
get timeZoneId(): string {
  return this.#builtInTimeZoneId ?? this.#timeZoneObject.toString(); 
}
getCalendar(): Temporal.CalendarProtocol {
  return this.#builtInCalendarId ? Temporal.Calendar.from(this.#builtInCalendarId) : this.#calendarObject; 
}
getTimeZone(): Temporal.TimeZoneProtocol {
  return this.#builtInTimeZoneId ? Temporal.TimeZone.from(this.#builtInTimeZoneId) : this.#timeZoneObject;
}

I think the first one should use this.#calendarObject.id instead of this.#calendarObject.toString(). The rest looks fine.

I realized that if we make .calendar always return a string, then spreading into .with() will cause the custom calendar object to be lost.

That's technically correct, but I'm not sure it's a problem 😄 Spreading any Temporal object loses all the properties, not just .calendar, because they are not own properties, and that wouldn't change. This is the reason we have separate .withCalendar(), .withPlainDate(), etc. methods.

Yep, I didn't understand that concern either. @sffc could you explain, maybe with a code sample, of what the problem is?

Yes, you're right. We fixed the spread operator a long time ago.

One thing we haven't discussed yet is getISOFields. Given that getISOFields method is mainly for advanced use by authors of custom calendars, I think it'd be OK to have that method's timeZone and calendar properties return the string | object union type. Because if there's anyone who'll properly test for custom calendars, it's the authors of custom calendars! 😄

Sounds fine to me