Range of valid values

Noda Time can only represent values within a limited range. This is for pragmatic reasons: while it would be possible to try to model every instant since some estimate of the big bang until some expected "end of time," this would come at a significant cost to the experience of the 99.99% of programmers who just need to deal with dates and times in a reasonably modern (but not far-flung future) era.

The Noda Time 1.x releases supported years between around -27000 and +32000 (in the Gregorian calendar), but had significant issues around the extremes. Noda Time 2.0 has a smaller range, with more clearly-defined behaviour.

Ranges for calendars

The "main" calendar for Noda Time is the Gregorian calendar. (We actually use CalendarSystem.Iso by default, but that's just the Gregorian calendar with slightly different numbering around centuries; it doesn't affect ranges.) This is the maximal calendar: a date in any other calendar can always be converted to the Gregorian calendar.

Additionally, all calendars are restricted to four digit formats, even in year-of-era representations, which avoids ever having to parse 5-digit years. This leads to a Gregorian calendar from 9999 BCE to 9999 CE inclusive, or -9998 to 9999 in "absolute" years. The range of other calendars is determined from this and from natural restrictions (such as not being proleptic).

The date range is always a complete number of years - the range shown below is inclusive at both ends, so every date from the start of the minimum year to the end of the maximum year is valid. (The min/max Gregorian year shows the Gregorian year corresponding to the min/max calendar values; this does not mean that the whole of that Gregorian year can be converted into the relevant calendar.)

Calendar Min year Max year Min year (Gregorian) Max year (Gregorian)
Gregorian/ISO -9998 9999 -9998 9999
Julian -9997 9998 -9998 9999
Islamic 1 9665 622 9999
Persian 1 9377 622 9999
Hebrew 1 9999 -3760 6239
Coptic 1 9715 284 9999

Instant

The range for Instant is simply the range of the Gregorian calendar, in UTC. In other words, it covers -9998-01-01T00:00:00Z to 9999-12-31T23:59:59.999999999Z inclusive.

LocalDateTime, LocalDate, ZonedDateTime, and OffsetDateTime

All of the types in this heading are based on dates in calendars, and they all have ranges based on that local date (and thus on the calendar). So even if the logical position on the global time-line for a ZonedDateTime or OffsetDateTime is out of the range of Instant, the value is still valid. For example, consider this code:

LocalDate earliestDate = new LocalDate(-9998, 1, 1);
OffsetDateTime offsetDateTime = earliestDate.AtMidnight().WithOffset(Offset.FromHours(10));

Here, offsetDateTime has a local date/time value of -9998-01-01T00:00:00, but it's 10 hours ahead of UTC... giving a logical instant of -9999-12-31T14:00:00Z... which is out of range. That's fine, and the code above won't throw an exception... unless you try to convert offsetDateTime to an Instant.

Duration

Duration is designed to allow for at least the largest difference in valid Instant values in either direction. As such, it needs to cover 631,075,881,599,999,999,999 nanoseconds - which is just shy of 7,304,119 days. Internally, durations are stored in terms of "day" and "nanosecond within the day" (an implementation detail to be sure, but one which sometimes affects other decisions).

Additionally, it seems useful to be able to cover the full range of TimeSpan, given that Duration is meant to be the roughly-equivalent type.

The result is that we have a range of days from -224 to +224-1 - and the nanosecond part means that the total range is from -224 days inclusive to +224 days exclusive - the largest valid Duration is 1 nanosecond less than 224 days.

Period

The Years, Months, Weeks, Days properties are 32-bit integers; the Hours, Minutes, Seconds, Milliseconds, Ticks, Nanoseconds properties are all 64-bit integers. Properties are independent of each other, and can take the full range of values for their respective types. "Extreme" periods may have a very limited (or empty) set of LocalDate/LocalDateTime/LocalTime values that they can be added to or subtracted from.

Failure modes

Assuming Noda Time is bug free1, we ensure that if you try to create a value outside the valid range for that type (via any normal API calls, or deserialization), an exception of one of the following types will be thrown:

  • OverflowException
  • ArgumentOutOfRangeException
  • ArgumentException
  • InvalidOperationException

The "best" exception in each case depends very much on the context - but in the aim of code reuse and performance, we may not always have that context available when the exception is thrown. For example, a call to OffsetDateTime.ToInstant() doesn't have any arguments - but internally we may end up calling another method and passing one or more arguments. If the validation of the "inner" method throws an ArgumentOutOfRangeException, that's what you'll see too. It's not ideal, but it's a reasonable balance. You should almost never be catching any of those exceptions explicitly anyway, so hopefully it's just a matter of being aware that if one of those exceptions is thrown from the depths of Noda Time, it's probably due to a value being out of range somewhere.

As far as possible, we try to ensure that if you try to perform an operation which can succeed, it will succeed. This is not guaranteed in all cases, however. For example, consider adding a Period consisting of -1 year and 365 days to -9998-01-01. The "logical" result is a no-op, but as Period addition is performed one unit at a time, it will fail on the addition of the -1 year.

1 I'm not claiming that it is bug free, but I can't predict what will happen if there are bugs.