Skip to main content
net10.0

NodaTime range types for CodoMetis.ValueRanges: LocalDateRange (daterange), LocalDateTimeRange (tsrange), InstantRange (tstzrange) and YearMonthRange β€” a month-granularity range for billing and reporting periods, stored as a month-aligned daterange by the EF Core companion. Each range type is a discriminated union of five sealed variants (Finite, UnboundedStart, UnboundedEnd, EmptyRange, Infinity) with the full interval algebra of the core package: Contains, Overlaps, IsAdjacentTo, Intersect, Union, Except, Merge, bound accessors, RangeAgg/RangeIntersectAgg aggregates, RangeSet<TRange, T> multiranges, PostgreSQL literal parsing/formatting and System.Text.Json support. Includes conversions to and from NodaTime's own Interval and DateInterval. LocalDateTime is wall-clock time by construction and Instant is an instant by construction, so the DateTimeKind and offset-normalization caveats of the BCL-based types do not arise. v6 adds the NodaTime value sets β€” LocalDateSet (date[]), LocalDateTimeSet (timestamp[]), InstantSet (timestamptz[]), LocalTimeSet (time[]) and the month-granularity YearMonthSet (month-aligned date[]): immutable, canonical (deduplicated, sorted, null-free) sets with the full membership algebra, ISO-calendar normalization at construction, PostgreSQL array literals and JSON support. v6.4 adds a validated-wrapper arity for each of them (LocalDateSet<T>, LocalDateTimeSet<T>, InstantSet<T>, LocalTimeSet<T>, YearMonthSet<T>), constrained only on BCL interfaces, so generator-produced domain values never reference this package.

Published
Oct 11, 2026
7d ago
Total Downloads
1.3K
1,302

Install

dotnet add package CodoMetis.ValueRanges.NodaTime --version 9.0.0
ProductVersions
.NET
net10.0

README

CodoMetis.ValueRanges.NodaTime

NodaTime types for CodoMetis.ValueRanges: the full PostgreSQL interval algebra over NodaTime's temporal primitives β€” and, since v6, the value-set family over the same elements.

Native AOT and trimming compatible, like the core package β€” verified by a NativeAOT consumer in CI (details).

Range types

Type Element PostgreSQL equivalent Discrete
LocalDateRange LocalDate daterange βœ“ (step: one day)
LocalDateTimeRange LocalDateTime tsrange β€”
InstantRange Instant tstzrange β€”
YearMonthRange YearMonth daterange (month-aligned) βœ“ (step: one month)

Each type is the same discriminated union of five sealed variants as the core package (Finite, UnboundedStart, UnboundedEnd, EmptyRange, Infinity), and every operation of the core algebra works unchanged: Contains, Overlaps, IsAdjacentTo, the directional comparisons, Intersect, Union, Except, Merge, bound accessors, RangeAgg/RangeIntersectAgg, RangeSet<TRange, T> multiranges, PostgreSQL literal parsing/formatting, and System.Text.Json serialization.

Value set types (v6)

A range says "every moment between these two"; a set says "exactly these moments" β€” public holidays, billing months, appointment slots. Immutable, canonical (deduplicated, sorted, never null), and stored as a native PostgreSQL array rather than a range.

Type Element PostgreSQL equivalent
LocalDateSet LocalDate date[]
LocalDateTimeSet LocalDateTime timestamp[]
InstantSet Instant timestamptz[]
LocalTimeSet LocalTime time[]
YearMonthSet YearMonth date[] (month-aligned)

The whole core set algebra applies: Contains, Overlaps, IsSubsetOf/IsSupersetOf and their proper variants, Union, Remove, Count, IsEmpty, plus client-side Intersect/Except/Add β€” along with array-literal parsing/formatting, JSON (no setup needed), and collection expressions.

using CodoMetis.ValueRanges;
using NodaTime;

var sprint = LocalDateRange.CreateFinite(new LocalDate(2025, 1, 6), new LocalDate(2025, 1, 17));
sprint.Contains(new LocalDate(2025, 1, 10));   // true

var deploy = InstantRange.CreateFinite(
    Instant.FromUtc(2025, 6, 1, 22, 0),
    Instant.FromUtc(2025, 6, 2, 2, 0));        // [start, end) β€” half-open, like tstzrange

var blocked = RangeSet<LocalDateRange, LocalDate>.From([
    LocalDateRange.CreateFinite(new LocalDate(2025, 1, 1), new LocalDate(2025, 1, 31)),
    LocalDateRange.CreateFinite(new LocalDate(2025, 2, 1), new LocalDate(2025, 2, 28))
]);   // { [2025-01-01,2025-02-28] } β€” adjacent months merge (discrete step)

var billing = YearMonthRange.CreateFinite(new YearMonth(2025, 1), new YearMonth(2025, 12));
billing.Contains(new YearMonth(2025, 6));      // true β€” month-granularity periods (v5)

// Value sets (v6) β€” "exactly these", not "everything between"
LocalDateSet holidays = [new LocalDate(2025, 12, 26), new LocalDate(2025, 1, 1)];
holidays.ToString();                           // {2025-01-01,2025-12-26} β€” sorted, deduplicated
holidays.Contains(new LocalDate(2025, 1, 1));  // true

var closed = LocalDateSet.From(new LocalDate(2025, 1, 1));
closed.IsProperSubsetOf(holidays);             // true

var slots = LocalTimeSet.From(new LocalTime(17, 30), new LocalTime(9, 0));
slots.ToString();                              // {09:00:00,17:30:00}

Installation

dotnet add package CodoMetis.ValueRanges.NodaTime

Requires .NET 10 or later. A companion EF Core package, CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime, maps these types to PostgreSQL columns via Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime.

JSON

Ranges and value sets carry their converters, as the core types do, so they need no setup at all:

JsonSerializer.Serialize(sprint);      // "[2025-01-06,2025-01-17]"
JsonSerializer.Serialize(holidays);    // ["2025-01-01","2025-12-26"]

The range types format themselves. The set types get there differently: they delegate elements to System.Text.Json, which has no built-in converter for NodaTime types, so each family supplies an ISO 8601 fallback through IValueSetFactory<TSet, T>.ElementJsonConverter β€” the same text form its array literals use. The fallback is consulted last, so any converter you register for the element type still wins.

Use AddNodaTimeRangeConverters() when the payload also carries bare NodaTime values next to the sets:

var options = new JsonSerializerOptions().AddNodaTimeRangeConverters();

JsonSerializer.Serialize(new { Day = new LocalDate(2025, 1, 1), Days = holidays }, options);
// {"Day":"2025-01-01","Days":["2025-01-01","2025-12-26"]}

It registers the converter factory plus the same five element converters on the options, so a LocalDate property outside a set gets the ISO 8601 form too. The element converters are a fallback: any other converter on the same options that handles one of the five types is used instead, wherever it was registered. That makes the call idempotent, and NodaTime.Serialization.SystemTextJson stays authoritative in either order:

var options = new JsonSerializerOptions()
    .ConfigureForNodaTime(DateTimeZoneProviders.Tzdb)   // Duration, Period, ZonedDateTime, …
    .AddNodaTimeRangeConverters();                      // ranges, sets, and anything left over

Reach for that package when the payload carries NodaTime types beyond these five.

A bare LocalDate, LocalDateTime or YearMonth in a calendar other than ISO is refused on write with an ArgumentException, as NodaTime's own converter refuses it. Its ISO 8601 text would spell the other calendar's fields, so a Julian LocalDate(2024, 1, 1) (ISO 2024-01-14) would read back as ISO 2024-01-01. Convert it with WithCalendar(CalendarSystem.Iso) first. Sets and ranges never get that far: they convert a non-ISO value to ISO at construction, or reject it (YearMonth).

Before 9.0 the element converters claimed a type by being registered first, so AddNodaTimeRangeConverters().ConfigureForNodaTime(…) shadowed NodaTime's converters, and a bare non-ISO value was written in its own calendar's fields with no error.

Two documented caveats, dissolved

The core package documents two reinterpretation rules at the database boundary for the BCL-based types. With NodaTime they do not arise, because the types cannot express the ambiguity in the first place:

  • tsrange / DateTimeRange: a UTC-kinded DateTime is reinterpreted as wall-clock time. A LocalDateTime is wall-clock time by construction β€” there is no Kind to reinterpret.
  • tstzrange / DateTimeOffsetRange: bounds are normalized to UTC and the original offset is not round-tripped. An Instant is what timestamptz stores β€” a point on the global timeline with no offset attached β€” so there is nothing to normalize and nothing to lose. Zoned or offset values convert explicitly (zonedDateTime.ToInstant()) before entering a range, which is exactly NodaTime's own philosophy.

This is the same design move the core package makes for unboundedness: the invalid state is not validated away, it is unrepresentable.

Why these element types

The core package restricts its element types to domains with a total order that the type's own comparisons agree with, plus β€” for adjacency β€” a defined step between neighbours ("Why these element types" in the core README). Applying the same bar to NodaTime:

  • LocalDate, LocalDateTime, Instant pass, and each maps onto a PostgreSQL built-in range domain.
  • YearMonth (v5) passes too β€” totally ordered with a one-month step β€” and although PostgreSQL has no month-granularity type, every month range is a month-aligned daterange, which is how the EF Core satellite stores it. Billing and reporting periods finally get a type instead of a daterange plus a CHECK constraint.
  • ZonedDateTime and OffsetDateTime have no default ordering at all β€” NodaTime deliberately declines to implement IComparable<T> on them, because ordering by instant and ordering by local time give different answers, and ships named comparers (Comparer.Instant, Comparer.Local) instead. The core's T : struct, IComparable<T>, IEquatable<T> constraint therefore rejects them at compile time. This is the double/NaN argument from the core README with the enforcement moved a level earlier: double slipped through the constraint and had to be excluded by policy; here the type system does the excluding. Hold instants in an InstantRange and convert at the boundary β€” the zone is presentation, the instant is the value (and the offset is exactly what tstzrange discards on the server, too).
  • LocalTime is totally ordered, and since v6 it has a set type β€” LocalTimeSet, over the built-in time[]. There is still no LocalTimeRange: a time-of-day interval is covered by the core package's TimeRange (over TimeOnly, mapping to the custom timerange type, which the database must CREATE TYPE first); convert with localTime.ToTimeOnly(). The asymmetry is PostgreSQL's, not ours β€” time[] is built in, timerange is not. Duration, Offset remain excluded β€” no PostgreSQL domain and no interval-algebra meaning.
  • Period is not comparable at all β€” NodaTime refuses to rank 30 days against 1 month, for the same reason this library refuses double: an ordering would have to lie.

Semantics worth knowing

  • Defaults match the core conventions. LocalDateRange.CreateFinite is closed [start, end] (discrete); LocalDateTimeRange/InstantRange default to half-open [start, end) (continuous timestamp convention). Discrete canonicalization applies: (2025-01-01, 2025-01-31) normalizes to [2025-01-02, 2025-01-30].
  • The ISO calendar is a construction rule. LocalDate.CompareTo is only defined between dates of the same calendar system, and PostgreSQL date/timestamp are proleptic Gregorian. LocalDateRange and LocalDateTimeRange therefore normalize bounds to the ISO calendar at construction (WithCalendar(CalendarSystem.Iso) β€” same day on the timeline, ISO representation). Ranges never hold mixed-calendar bounds, so comparisons cannot throw. A date outside the ISO calendar's year range (far-future non-ISO dates) throws ArgumentOutOfRangeException at construction. YearMonthRange is stricter: a non-ISO year-month spans parts of two ISO months and has no lossless equivalent, so it rejects non-ISO bounds with ArgumentException instead of reinterpreting them.
  • The same calendar rule covers the set types, at every entry point. LocalDateSet/LocalDateTimeSet normalize to ISO and YearMonthSet rejects non-ISO β€” not only in From, but in Contains, Add and Remove, which take a bare element rather than a set. That matters because LocalDate.Equals is calendar-sensitive and LocalDate.CompareTo throws across calendars: without it, holidays.Contains(copticDate) would quietly answer false for a date the set actually holds. The EF Core package applies the same normalization to a bare probe bound as a query parameter.
  • The wrapper arities refuse a non-ISO element instead. LocalDateSet<T>, LocalDateTimeSet<T> and YearMonthSet<T> cannot convert an element to ISO β€” the only way to build a T from an ISO value is to parse it β€” so an element backed by a value in another calendar is refused with ArgumentException: in From, in a collection expression, when reading (JSON through a registered element converter), and as the probe of Contains/Add/Remove. The test is the round trip: the element's ISO text must read back as that element. Before 9.0 a wrapper over a Julian 2024-01-01 β€” ISO 2024-01-14 β€” was written as 2024-01-01 to the literal, JSON and the database, and read back thirteen days off. The EF Core package refuses one as a bare query operand too.
  • YearMonthRange follows the discrete conventions. Closed [start, end] by default, one-month step: [2025-01, 2025-03] and [2025-04, 2025-06] are adjacent and merge; [2025-01, 2026-01) canonicalizes to [2025-01, 2025-12]. Literals use the ISO uuuu-MM form: [2025-01,2025-12].
  • Formatting is culture-free. Literals use NodaTime's ISO patterns: [2025-01-01,2025-03-31], [2024-06-01T08:00:00,2024-06-01T17:30:00), [2024-06-01T00:00:00Z,2024-07-01T00:00:00Z). Subsecond digits appear only when present, up to nanosecond precision.
  • Parsing accepts the PostgreSQL wire form too, and nothing looser. Besides its own canonical output, Parse handles literals as psql prints them: space-separated timestamps ("2024-06-01 00:00:00"), numeric offsets ("2024-06-01 14:30:00+02" β€” converted to the instant they denote) and infinity / -infinity for an Instant, LocalDate or LocalDateTime bound, read as the extreme value Npgsql maps them from. The wrapper arities read the text with the same ISO patterns before their element sees it.
  • Precision at the database boundary. NodaTime carries nanoseconds; PostgreSQL stores microseconds. Sub-microsecond precision is reduced when persisting through the EF Core package β€” truncated toward 2000-01-01, as Npgsql truncates β€” in a column, a JSON document and a SQL constant alike (pinned by the live-PostgreSQL integration suite). In-memory operations keep full nanosecond precision.
  • Instant.MinValue / Instant.MaxValue map to PostgreSQL -infinity / infinity by default (an Npgsql rule) β€” a finite bound that happens to be infinite, still distinct from an unbounded side, exactly as the core README describes for DateTime.MinValue/MaxValue.

Interop with NodaTime's own interval types

NodaTime ships two interval types of its own; both are deliberately narrower than the range model, and conversions are provided in both directions:

NodaTime type Shape it can express Conversions
Interval [start, end) over instants; ends may be absent; no empty interval.ToInstantRange() (total) Β· range.ToInterval() (throws for shapes an Interval cannot express)
DateInterval finite, fully closed [start, end] over dates dateInterval.ToLocalDateRange() (total) Β· finite.ToDateInterval() (declared on LocalDateRange.Finite β€” pattern match first)

YearMonthRange additionally converts through its days: range.ToLocalDateRange() (total β€” each bound month expands to its first/last day), localDateRange.ToYearMonthRange() (inverse β€” throws when a canonical bound is not a month boundary), and finite.ToDateInterval() on YearMonthRange.Finite.

var interval = new Interval(Instant.FromUtc(2025, 1, 1, 0, 0), Instant.FromUtc(2025, 2, 1, 0, 0));
InstantRange range = interval.ToInstantRange();          // [start, end) Finite
Interval back      = range.ToInterval();                 // round-trips

if (LocalDateRange.Parse("[2025-01-01,2025-01-31]", null) is LocalDateRange.Finite finite)
{
    DateInterval dates = finite.ToDateInterval();
}

What the range types add over Interval/DateInterval: the empty range, unbounded date ranges, all four bound-inclusiveness combinations, the full set algebra (Union/Except/Intersect/Merge/Complement), multiranges, PostgreSQL literals, and LINQ-to-SQL translation.

Entity Framework Core

dotnet add package CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime
options.UseNpgsql(connectionString, npgsql => npgsql.UseValueRangesNodaTime());

One line β€” it implies both UseNodaTime() (the Npgsql NodaTime plugin) and UseValueRanges() (the base plugin), so BCL-based and NodaTime-based range types coexist in one model. The full algebra translates to SQL exactly as documented in the core README, including the discrete upper(x) - 1 compensation for LocalDateRange and the satellite's RangeAgg/RangeIntersectAgg overloads inside GroupBy projections.

The same call registers the five value set types, mapped to native array columns β€” no configuration beyond the property itself:

public LocalDateSet Holidays { get; set; } = LocalDateSet.Empty;   // date[]
public YearMonthSet BillingMonths { get; set; } = YearMonthSet.Empty;   // month-aligned date[]

reservations.Where(r => r.Holidays.Contains(day));        // r."Holidays" @> ARRAY['2025-01-01']::date[]
reservations.Where(r => r.Holidays.Overlaps(closures));   // r."Holidays" && @closures
reservations.Where(r => r.BillingMonths.Count > 2);       // cardinality(r."BillingMonths") > 2

Containment always translates as @>, so a plain GIN index serves it.

YearMonthRange columns are stored as month-aligned daterange ([2025-01, 2025-03] β‡’ [2025-01-01, 2025-04-01)) β€” no custom database type involved. Every operator, bound accessor and aggregate translates; reads validate month alignment rather than silently shifting boundaries. Only server-side construction from column values (YearMonthRange.CreateFinite(x.SomeColumn, …) inside a query) is unsupported, because months are coarser than the date subtype.

License

MIT β€” see LICENSE.

Changes from Previous Version

No structural changes between v8.0.0 and v9.0.0.

Adoption Assessment

A verified publisher signal is available. Assess fit from the package's supported frameworks, license and dependency graph rather than popularity alone.

Compatibility metadata lists 1 target framework; confirm the exact target used by your application.

Related Guides & Workflows

Learn how to evaluate, integrate, or audit this package in your .NET project.