dotnet / runtime

.NET is a cross-platform runtime for cloud, mobile, desktop, and IoT apps.
https://docs.microsoft.com/dotnet/core/
MIT License
15.26k stars 4.73k forks source link

[API Proposal]: IDate, IDateTime, ITime Interfaces #97622

Open robloo opened 9 months ago

robloo commented 9 months ago

Background and motivation

.NET now has many different DateTime types: DateTime, DateTimeOffset, DateOnly, etc. Each of these were added for good reasons.

But there is one obvious gap. For UI programming IANA time zone or Windows ID time zones are required for user selection. It's not 1:1 to convert this to/from a numerical time zone offset which means DateTimeOffset can't be used in some cases as well. Then 3rd party libraries come into play to fill this gap.

Now we have way too many Date/Time types to manage and no way to do it in a generic way. So my proposal is something like the INumber interface to bring all of this together.

The use cases are not only for backend code. But also for UI controls. For example UI frameworks can start writing generic Date/TimePickers and allow the app to define the type they need to work with. An IDate interface is perfect for this to support all known date/time types (since a DatePicker control only cares about the date-related components).

API Proposal

I'll flesh this out when I have more time.

public interface IDate
{
  int Day;
  int Month;
  int Year;
}

public interface IDateTime
{
  int Day;
  int Month;
  int Year;
  int Hour;
  int Minute;
  int Second;
}

public interface ITime
{
  int Hour;
  int Minute;
  int Second;
}

API Usage

TBD

Alternative Designs

It needs to be discussed:

  1. Whether to call these interfaces IDateOnly and ITimeOnly instead and model them almost entirely based on DateOnly and TimeOnly. Then IDateTime will simply be a union of the two interfaces.
  2. The specific members to include in ITimeOnly are debatable. Should this be only high-level hours/minutes/seconds (all that usually needed for general-purpose or UI code) or should it include all members down to ticks. The answer is likely all members down to ticks. However, some 3rd party libraries may not support all these members. Therefore, there could be multiple levels to these interfaces like what was done for INumber. Basically IBasicTimeOnly with only hours/minutes/seconds and then an ITimeOnly that derives from that and includes everything.

Risks

This seems low risk to me as it is only abstracting common, shared properties already used in all the types.

ghost commented 9 months ago

Tagging subscribers to this area: @dotnet/area-system-datetime See info in area-owners.md if you want to be subscribed.

Issue Details
### Background and motivation .NET now has many different DateTime types: `DateTime`, `DateTimeOffset`, `DateOnly`, etc. Each of these were added for good reasons. * `DateTime` : The original but fails to account for time zone offsets * `DateTimeOffset` : Accounts for only a numerical DateTimeOffset (not named IANA zones) * `DateOnly` : Simplified dates without worrying about time and timezone offsets calculating calculations * `TimeOnly` : Simplified again with only time components. But there is one obvious gap. For UI programming IANA time zone or Windows ID time zones are required for user selection. It's not 1:1 to convert this to/from a numerical time zone offset which means `DateTimeOffset` can't be used in some cases as well. Then 3rd party libraries come into play to fill this gap. Now we have way too many Date/Time types to manage and no way to do it in a generic way. So my proposal is something like the new INumber interface to bring all of this together. The use cases are not only for backend code. But also for UI controls. For example UI frameworks can start writing generic Date/TimePickers and allow the app to define the type they need to work with. An `IDate` interface is perfect for this to support all known date/time types (since a DatePicker control only cares about the date-related components). ### API Proposal I'll flesh this out when I have more time. ```csharp public interface IDate { int Day; int Month; int Year; } public interface IDateTime { int Day; int Month; int Year; int Hour; int Minute; int Second; } public interface ITime { int Hour; int Minute; int Second; } ``` ### API Usage TBD ```csharp ``` ### Alternative Designs It needs to be discussed: 1. Whether to call these interfaces `IDateOnly` and `ITimeOnly` instead and model them almost entirely based on `DateOnly` and `TimeOnly`. Then `IDateTime` will simply be a union of the two interfaces. 2. The specific members to include in `ITimeOnly` are debatable. Should this be only high-level hours/minutes/seconds (all that usually needed for general-purpose or UI code) or should it include all members down to ticks. The answer is likely all members down to ticks. However, some 3rd party libraries may not support all these members. Therefore, there could be multiple levels to these interfaces like what was done for INumber. Basically `IBasicTimeOnly` with only hours/minutes/seconds and then an `ITimeOnly` that derives from that and includes everything. ### Risks This seems low risk to me as it is only abstracting common, shared properties already used in all the types.
Author: robloo
Assignees: -
Labels: `api-suggestion`, `untriaged`, `area-System.DateTime`
Milestone: -
danmoseley commented 9 months ago

Would something like nodatime be expected to expose it too? @mattjohnsonpint

robloo commented 9 months ago

@danmoseley Yes, I was thinking of NodaTime specifically when I mentioned 3rd party libraries.

tarekgh commented 9 months ago

What exactly is your scenario here? Can you describe the details of the problem you are trying to solve here and how the proposal will solve it? Having some code examples will help here.

We have exposed TimeProvider for time abstraction in general.

ghost commented 9 months ago

This issue has been marked needs-author-action and may be missing some important information.

robloo commented 9 months ago

@tarekgh I'm not sure why you immediately flagged this as needs authors attention. It also is unexpected that you don't recognize the utility of this. It seems obvious as explained above and the thinking is the same as INumber.

What exactly is your scenario here?

I explained the background of the scenario above. We have too many Date/Time types and it's difficult to manage the types in both backends and frontends in a general-purpose way.

I would also advise you to speak internally with the team that developed the INumber interface. Their reasons for doing so are the same as I'm proposing here.

Can you describe the details of the problem you are trying to solve here and how the proposal will solve it?

Yes, this originates here: https://github.com/AvaloniaUI/Avalonia/discussions/13480. In developing UI controls the underlying date/time type has become an issue. Old controls in WPF used DateTime new controls in WinUI use DateTimeOffset. However, each is only to select a date and really should be using DateOnly but that didn't exist until recently. This creates a problem for app developers that now need to manage two different types (DateTime or DateTimeOffset) as well as their nullability. We need to clean all of this up and just let the app developer specify the type they want to use. These controls can operate on anything (generally speaking) as long as it has Year/Month/Day components to increment/decrement.

Long term you really need to remove DateTime, add a time zone ID component to DateTimeOffset and then use DateTimeOffset internally for all APIs. That will likely never happen though and is out of scope for this discussion.

We have exposed TimeProvider for time abstraction in general.

This is irrelevant. I'm not talking about providing time. I'm talking about making general-purpose code that can edit/modify any date/time types. I was clear about that above.

mattjohnsonpint commented 9 months ago

I was tagged, so I'll add my opinions. First, let's get a few things out of the way...

Long term you really need to remove DateTime ... and then use DateTimeOffset internally for all APIs.

That would be a bad idea, and defeat the whole purpose of having distinct purpose-specific date/time types. The whole reason DateOnly and TimeOnly were added is because there are many needs for having types that just handle one aspect or another. Same with DateTime and DateTimeOffset. If DateTime were removed, then you'd always have to have an offset, and would be unable to handle scenarios where an offset is not available or appropriate.

... add a time zone ID component to DateTimeOffset ...

This has been discussed before. It would be called something like ZonedDateTime or DateTimeZoned, DateTimeWithZone, etc. NodaTime has this, as do date/time libraries in other languages. But as you say, it's not what this issue is about.

... the thinking is the same as INumber

Sorry, but I strongly disagree. INumber is about supporting generic math, not UI binding.

OK - now on to the discussion about the interfaces. The main questions I have are, how do they actually solve the UI binding concern you described, and is it actually a real problem? A date-time picker control needs to ultimately bind to or produce a date and time, while a date-only picker control ultimately needs to bind to or produce a date, etc. An offset picker is quite different than a time zone ID picker, etc. It seems to me that the problem manifests only if one is attempting to build a single control to handle all scenarios, rather than scenerio-specific controls to match their scenerio-specific types.

Even if one wanted to support this, I'm not convinced that an interface-centric approach truly solves the problem. How does the control know what concrete type to construct? If it passes it back on an interface, will downstream code know how to handle it? For example, I can see a problem where a control developer decides to use NodaTime but the consuming application doesn't. If the date-picker constructs a NodaTime LocalDate and passes it back on an IDate interface, an exception would occur when the app receives it and tries to cast it to a DateOnly? Even if the app used IDate throughout their models, what happens when a storage client such as SQL Client receives it? If knows nothing about NodaTime types, it won't be able to work with it unless its internals are also updated to recognize the IDate interface.

In general, I'm not a fan of interfaces on value types anyway. There's enough confusion as it stands trying to know when to use an interface or a concrete type for collections, but if folks start thinking they have to use IDate or IDateTime in their models, it's going to be even more confusing.

The rabbit hole is very deep here.

I suggest rather than trying to cram interfaces into the date-time types, someone who is more saavy on UI binding than I am should suggest another way that UI controls could bind to various similar types to solve the original concern.

robloo commented 9 months ago

If DateTime were removed, then you'd always have to have an offset, and would be unable to handle scenarios where an offset is not available or appropriate.

Ok, I'll give you that one in principle. In practice almost all code I've seen really needs either DateOnly or a DateTimeOffset these days. DateTime does nothing but create complications even when used correctly. Anyway, I shouldn't have mentioned it as it's out of scope.

... the thinking is the same as INumber

Sorry, but I strongly disagree. INumber is about supporting [generic math]

You misunderstand half of my point. Of course it is useful in UI control development but I was clear there are backend uses as well. For example internally in our code base there is a DateRange type with a Start/End member. This has to be duplicated for every single Date/Time type that it needs to operate with which should be unnecessary. So, just like INumber this class should ideally be DateRange{T} : where T : IDateTime. There are several other date and time calculations that could be similarly abstracted. So I disagree with your disagreement :)

The main questions I have are, how do they actually solve the UI binding concern you described, and is it actually a real problem? A date-time picker control needs to ultimately bind to or produce a date and time, while a date-only picker control ultimately needs to bind to or produce a date, etc.

I think you are missing my point here.

Concerning binding, these controls will choose an internal type to operate with and then copy values over to the bound type supporting the interface. Works just fine as long as we capture the necessary members in the interfaces.

An offset picker is quite different than a time zone ID picker, etc.

No... time zone ID is out of scope. I was clear about that above. It isn't supported in these .NET types anyway... I'm also fully aware of NodaTime.

It seems to me that the problem manifests only if one is attempting to build a single control to handle all scenarios, rather than scenerio-specific controls to match their scenerio-specific types.

Yes, EXACTLY, there is a lot of duplication to support all these types in BOTH control's (frontend) and backend (as I mentioned above). Anyone who has spent time developing related controls will see the benefits here.

How does the control know what concrete type to construct?

default(T)... then copy the memebers from the internal type used (it doesn't matter) to the the external SelectedDate/Time. As long as everyone implements IDateOnly (for example) this works.

If the date-picker constructs a NodaTime LocalDate and passes it back on an IDate interface, an exception would occur when the app receives it and tries to cast it to a DateOnly?

Again, no, you are writing bad code if this happens. We have the type information passed to the control as a generic type argument. If you see my linked proposal about generic controls I'm actually proposing something like DatePicker{T} so there is a concrete type defined for the corresponding SelectedDate property. I'm not exposing these as IDateOnly. where T : IDateOnly is the constraint on the control itself.

For backend code (for example by DateRange example) this also isn't an issues. We only need access to the IDateOnly member information for the calculations.

Even if the app used IDate throughout their models, what happens when a storage client such as SQL Client receives it? If knows nothing about NodaTime types, it won't be able to work with it unless its internals are also updated to recognize the IDate interface.

Yes, of course any backend code unaware of IDateOnly or IDate whatever it's called cannot effectively use it. Code must be updated to be aware of IDateOnly and it's intended ONLY for calculation purposes like INumber. You can't be passing this to a database without defining a concrete type. So while you recognize a limitation here you are also missing a point. I'm proposing the same as what INumber allows.

The rabbit hole is very deep here.

Not as deep as you make it sound.

I suggest rather than trying to cram interfaces into the date-time types, someone who is more saavy on UI binding than I am should suggest another way that UI controls could bind to various similar types to solve the original concern.

Don't focus on only UI. That is the example used where this came up. It applies just as much to backend date calculation code.


Every time I interact with the Microsoft team I feel I have to say please look big picture. Please see the larger vision here. If you look at the big picture to start it's clear the direction we need to go. We really need, at minimum, an IDateOnly interface that implements most members of DateOnly and then implement that interface on the following types: DateTime, DateTimeOffset, DateOnly. Doing that would solve the majority of the ask here and really isn't that difficult. I could do it myself.

robloo commented 9 months ago

Let me put this another way that is simplified for everyone. In a perfect OOP design (ignoring struct limations) we would have the following types (focusing only on dates for this example).

Note how each derives members from the previous while adding new functionality. Code (unless it need specific members) could be written to target the base type DateOnly and work with all types. We have an enormous amount of examples for general-purpose code targeting base types only.

I'm simply proposing an IDateOnly interface to effectively support this (analogous to INumber). This is both because we are working with structs and the types evolved organically not designed from the get-go with each other in mind.

Use cases are:

  1. Front-end development where only Date selection matters (most cases honestly)
  2. Back-end date calculations where again only dates matter. This commonly includes things like IsBetween calculations with a Start/End date, etc...

Now if you expand that line of thinking from dates only in the example above to dates and times. You will also understand why I'm proposing all three interfaces.

mattjohnsonpint commented 9 months ago

If this moves forward, my vote would be for IDate and ITime interfaces only. IDateOnly is redundant wording IMHO, and no need for an IDateTime interface when DateTime can just implement both IDate and ITime. I'll let others respond to the rest. (FWIW, I left Microsoft a few years ago, and was never part of the core .NET team.)

robloo commented 9 months ago

I agree IDateTime is redundant and it's not much more work to type both. IDateTime was only convenience.

I thought IDateOnly would be more obvious since it's basically abstracting the members in DateOnly. I also agree it's redundant wording there too though and my personal preference is for IDate. Since we went with DateOnly instead of Date elsewhere it might need to follow for the original reasons and for consistency though.

Windows10CE commented 9 months ago

How does the control know what concrete type to construct?

default(T)... then copy the memebers from the internal type used (it doesn't matter) to the the external SelectedDate/Time. As long as everyone implements IDateOnly (for example) this works.

All of the types being discussed here are currently readonly structs, and as such cannot be copied into in this fashion. Are you also suggesting this should be changed? If you are not, this proposal probably needs to change to reflect that somehow, maybe by making the interface generic over TSelf with With(Day/Month/Year/Hour/Minute/Second/etc) methods that return TSelf, similar to INumber?

robloo commented 9 months ago

All of the types being discussed here are currently readonly structs, and as such cannot be copied into in this fashion. Are you also suggesting this should be changed? If you are not, this proposal probably needs to change to reflect that somehow, maybe by making the interface generic over TSelf with With(Day/Month/Year/Hour/Minute/Second/etc) methods that return TSelf, similar to INumber?

Ah yea, my mistake and that's an oversight.

In several cases it's only necessary for the interface to constrain a generic type argument to a class or method. The concrete type would still always be defined in usage and IDateOnly/IDate would be used read only... that said you are right.

I will amend the proposal to require TSelf on the interfaces directly.

Clockwork-Muse commented 9 months ago

Let me put this another way that is simplified for everyone. In a perfect OOP design (ignoring struct limations) we would have the following types (focusing only on dates for this example).

  • DateOnly
  • DateTime : DateOnly
  • DateTimeOffset : DateTime

Note how each derives members from the previous while adding new functionality. Code (unless it need specific members) could be written to target the base type DateOnly and work with all types.

... what about time-of-day? eg, why no TimeOnly type in that hierarchy? And the answer shouldn't be "because you can only extend one type", because then the question just becomes "Why didn't you choose time as the base type?".

This violates the general principle of "Prefer composition over inheritance". Note that date/time types have very little behavior comparatively, instead (almost) being purely dumb datatypes.

robloo commented 9 months ago

@Clockwork-Muse Read the last part of the comment you quoted after the separator. IT WAS A SIMPLIFIED EXAMPLE. I mentioned this several times in the comment itself and it's also obvious in the issue description at the very top. Of course time is included in the discussion.

If at the end of the day we can only agree on date to be added I would take that by itself though as it's more useful.

ScarletKuro commented 3 months ago

What exactly is your scenario here? Can you describe the details of the problem you are trying to solve here and how the proposal will solve it? Having some code examples will help here.

Our use case is Blazor. We are working on MudBlazor and we have components like DatePicker and TimePicker. For example, with DatePickers, we want users to be able to use DateTime, DateTimeOffset, and DateOnly. Currently, this can be achieved in three ways:

  1. Create different variations of the component: DatePickerDateOnly, DatePickerDateTime, and DatePickerDateTimeOffset. This approach is too boilerplate.
  2. Use object, which requires a lot of casting and type checking.
  3. Use generics without any constraints, which is similar to using object.

The INumber interface works very well for us. For example, we use it with slider, allowing users to bind to any type that supports INumber.

Perhaps if discriminated unions will make into the language it can be solved with it instead.