Fully functional, in-memory range and set types for .NET — complete interval and membership algebra without any database dependency.
CodoMetis.ValueRanges provides immutable, canonical-at-construction value domain types with PostgreSQL-native storage shapes: concrete, type-safe range types covering the same six value domains as PostgreSQL's built-in range types (int4range, int8range, numrange, daterange, tsrange, tstzrange) with a full in-memory implementation of every range operation PostgreSQL exposes, their multirange counterpart RangeSet<TRange, T>, and — since v6 — value sets, canonical sets of scalar values whose storage shape is a native PostgreSQL array.
The library is designed to stand on its own: all operations execute in process, with no ORM or database driver required. A companion EF Core package (CodoMetis.ValueRanges.EFCore.PostgreSQL) bridges the range types to NpgsqlRange<T> and the set types to native arrays for automatic LINQ-to-SQL translation, making the same code work both in memory and as PostgreSQL queries.
Each range type is modelled as a discriminated union of five sealed variants:
| Variant | Represents | Interval notation |
|---|---|---|
Finite |
Bounded on both sides | [1, 10] |
UnboundedStart |
Unbounded on the left | (-∞, 10] |
UnboundedEnd |
Unbounded on the right | [1, +∞) |
EmptyRange |
The empty range (no values) | ∅ |
Infinity |
Unbounded on both ends | (-∞, +∞) |
The shape of a range is encoded in its static type. An UnboundedEnd range has no End property — the property does not exist at compile time. An Empty range carries no bound information whatsoever. Invalid states are unrepresentable by construction, and pattern matching over a range is exhaustive with compiler-enforced coverage.
Encoding the shape in the type also keeps "there is no upper bound" apart from "the upper bound is the largest representable value" — two different facts that a bounds-plus-flags representation stores in the same object.
In a representation built from two nullable bounds plus an IsUpperInfinite bit, the two facts occupy the same fields and have to be reconciled at runtime. NpgsqlRange<T> reconciles them by discarding: pass an upper bound together with upperBoundInfinite: true and the constructor keeps the flag and silently drops the value. That is a sound invariant, but it is enforced by a constructor rather than by the type, and it leaves LowerBound/UpperBound typed T? on every instance — so even code that has already established the range is bounded still has a nullable to answer for.
Here the question cannot be asked in the first place. UnboundedEnd has no End property to put a sentinel in; Finite has no flag to disown its End, and its Start/End are not nullable. The distinction is carried by the type rather than by a constructor rule that callers have to know about:
DateTimeRange.CreateUnboundedEnd(start) // UnboundedEnd — genuinely open-ended
DateTimeRange.CreateFinite(start, DateTime.MaxValue) // Finite — ends at a specific instantThe two are not interchangeable, and the compiler will not let them be confused. This matters at the database boundary as well, where Npgsql maps DateTime.MaxValue to PostgreSQL infinity — a finite bound that happens to be infinite, which is still distinct from an unbounded side. See Entity Framework Core for how that round-trips.
Three gaps in the existing surface, closed. Nothing here extends the model; each item is something one half of the library had and the other half did not. No breaking changes.
RangeSetgainsIsInfinity()andIsFinite(), the two shape predicates a single range already had. The distinction that makes them worth stating:IsInfinity()is notIsUnboundedStart() && IsUnboundedEnd(). That equivalence holds for a range, which is contiguous, and fails for a set —{(,5],[10,)}runs to infinity in both directions and does not contain 7. The EF translation keeps the distinction, mapping the set predicate to equality against the infinite multirange rather than tolower_inf AND upper_inf(verified against live PostgreSQL).- Collection expressions for
RangeSet<TRange, T>—RangeSet<Int32Range, int> set = [a, b];, which the nineteen value set types and arities have supported since v6.0. Normalization is the same asFrom's. ISpanParsable<T>on every parsable type — all eleven ranges,RangeSet, and all nineteen set types and arities now takeReadOnlySpan<char>inParse/TryParse. The literal grammars were always parsed over spans internally; this exposes that entry point, so parsing a slice of a larger buffer no longer allocates a substring first.Length— the measure of a range. Discrete domains count inclusively ([2024-01-01, 2024-01-31]is 31 days), continuous ones measure the span. Empty measures zero, unbounded measuresnull.Values()on the discrete ranges, enumerating what they contain — declared only where a step exists, so asking aDecimalRangeis a compile error.- A bridge between the two type families —
{1,2,3,7}↔{[1,3],[7,7]}for the discrete domains, so the storage shape can follow the density of the data. Clamp(value)on every range, and an indexer on the value sets.
Two defects found by an audit, both of which had been documented as correct. That is what made them survive review: reading the code confirmed the comment, and the comment was the bug. Neither changes an outcome that was previously right.
⚠️ A null range now reads back asnullinstead of throwing. A nullInt32Range?property serialized to{"Seats":null}and threwJsonExceptionon the way back in — the package could not read a document it had just written, so an API could return a body it was unable to accept.nullis now left to System.Text.Json in both directions, asRangeSetand the value sets always did.nulland the empty range stay distinct: absent isnull, empty is the literal"empty". If you relied on the exception to reject a null where a non-nullable range was expected, that validation is gone — the property now receivesnull, as any other reference-typed property would. Malformed literals are still rejected. Applies to the NodaTime ranges too, which serialize through the same factory.Countover a union reached throughRemovecounted shared elements twice.Uniontranslates toarray_cat, which concatenates, soCountover a server-computed union has always been refused — but the check matched only the outermost call, andarray_removepreserves canonical form rather than establishing it. Against live PostgreSQL,{a,c}unioned with{a,b}answered 4 where the in-memory expression is{a,b,c}— 3. A query that previously ran now behaves differently: in a predicate it fails translation rather than filtering on an inflated number, and in a projection it falls back to client evaluation and returns the correct count.
Also in this release, for every package: symbol packages (.snupkg) and Source Link, so you can step into the code you are running and confirm it was built from the commit it claims; deterministic builds; and a CycloneDX SBOM per release. CodoMetis.ValueRanges and CodoMetis.ValueRanges.EFCore.PostgreSQL now ship their own package READMEs instead of this one.
A JSON audit, and three defects it found. All three shared one shape: System.Text.Json fell back to reflection where the library expected a converter, and the result was silence rather than an exception. Nothing that previously worked changes — every fix replaces a crash or a wrong answer.
⚠️ Value set elements without a converter now serialize as their text form, not as an object. This is the one visible payload change. A validated wrapper —StringSet<PermissionKey>,GuidSet<TenantId>, … — whose element type carries no[JsonConverter]used to be handed to System.Text.Json's reflection path, which wrote[{"Value":"users.read"}], or[{}]for the generator-typical shape of a record struct over a private field. The[{}]form destroyed data on read; both disagreed with the{users.read}stored in PostgreSQL. Elements now go through the family's own text form —["users.read"]for string- and Guid-backed sets,[1,2]for integer-backed ones, identical to the primitive each wraps — and reads re-run the element'sIParsablevalidation. If you serialize such a set and have persisted or published the old object form, that payload shape changes. Registering a converter for the element type (on the options, the property, or the type) overrides this, exactly as before.- The same fix reaches the NodaTime sets, which had the identical failure —
[{"Calendar":{…},"Year":2024,…}]on write,defaulton read.AddRangeConverters()alone is now enough; the satellite additionally exposesAddNodaTimeRangeConverters()for bare NodaTime values sitting next to a set, which the element hook does not reach. Composes withConfigureForNodaTimein either registration order. - Nullable range properties no longer throw.
HandleNullrouted nulls into the write path, which dereferenced them: serializing an object with a nullInt32Range?threwNullReferenceException. It writesnull. (Reads rejected a null token in 6.1.0; since then they returnnull— see JSON Serialization.) - Ranges reached through
objectno longer throw.Serialize<object>(range), anobject-typed property and heterogeneous collections all present the union's sealed variant, for which the converter could not be constructed — a reflectionArgumentExceptionescaped. Variants now serialize to the same literal, and reads into a variant-typed declaration reject a literal of the wrong shape.
New API: IValueSetFactory<TSet, T>.ElementJsonConverter (a defaulted virtual static; the interface is closed to external implementation), RangeVariantJsonConverter<TVariant, TRange, T>, and AddNodaTimeRangeConverters().
Value sets — a second type family. The package's model was never "ranges" narrowly; it is immutable, canonical-at-construction value domains with PostgreSQL-native storage shapes. RangeSet has embodied "canonical set with a native store shape" (multirange) since v2; v6 applies the same concept one level down: canonical sets of scalar values, stored as native PostgreSQL arrays (text[], uuid[], integer[], …) — deduplicated, sorted, structurally equal, with the membership algebra PostgreSQL's own array operators speak.
- Ten closed types in the core package —
StringSet,GuidSet,Int16Set,Int32Set,Int64Set,DecimalSet,DateSet,TimeSet,DateTimeSet,DateTimeOffsetSet— plus validated-wrapper aritiesStringSet<T>,GuidSet<T>,Int32Set<T>,Int64Set<T>for generator-produced domain values (Vogen, Metalama aspects, StronglyTypedId, hand-written wrappers), constrained only on BCL interfaces so domain types never reference this package. See Value Sets. - Five NodaTime types in the satellite:
LocalDateSet,LocalDateTimeSet,InstantSet,LocalTimeSet, and the month-granularityYearMonthSet(stored as a month-aligneddate[], likeYearMonthRange'sdaterange). - Membership algebra —
Contains,Overlaps,IsSubsetOf,IsSupersetOf,IsProperSubsetOf,IsProperSupersetOf,Union,Remove,Count,IsEmpty(plus client-sideIntersect/Except/Add) — PostgreSQL array literals ({a,b}), JSON support through the existing converter factory, and collection expressions (StringSet tags = ["a", "b"];). - The EF Core packages map them by convention to native array columns — no configuration, no registration, wrapper instantiations recognized automatically — with LINQ translation to the array operator algebra (
@>,&&,<@,cardinality,array_cat,array_remove). Containment always translates as@>, so a plain GIN index serves it. See Value set columns.
v6.0 contains no breaking changes; the major marks the package growing a second type family.
Two new range domains — the first additions beyond PostgreSQL's six built-ins, chosen because their element types clear the same bar (a total order the type's own comparisons agree with, and a defined step where adjacency needs one):
TimeRange(core package) — a time-of-day range overTimeOnly, the equivalent of the most common custom range type in PostgreSQL practice:CREATE TYPE timerange AS RANGE (subtype = time). Continuous, half-open by default, so[09:00, 12:00)and[12:00, 17:00)compose the way opening hours and shifts do. A window that crosses midnight is two ranges — whichRangeSetrepresents naturally. See TimeRange and the custom timerange type for the EF Core mapping.YearMonthRange(NodaTime satellite) — a month-granularity range over NodaTime'sYearMonthfor billing and reporting periods. Discrete with a one-month step:[2024-01, 2024-03]and[2024-04, 2024-06]are adjacent and merge. The EF Core NodaTime satellite stores it as a month-aligneddaterange— no custom database type needed, and every operator works server-side. Conversions to and fromLocalDateRangeandDateIntervalare included.
Both types carry the complete algebra, multiranges, literals, JSON support and aggregate overloads of the existing eight. The EF Core packages map them by convention; timerange needs two one-line opt-ins on the database side (documented below).
v5.0 contains no breaking changes to existing APIs; the major bump marks the model growing beyond the PostgreSQL built-ins.
NodaTime satellites — two new packages bring the range model to NodaTime-based projects:
- CodoMetis.ValueRanges.NodaTime —
LocalDateRange(daterange),LocalDateTimeRange(tsrange) andInstantRange(tstzrange) with the complete algebra, multiranges, literals and JSON support, plus conversions to and from NodaTime's ownIntervalandDateInterval. See NodaTime. - CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime — maps them to PostgreSQL via
Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime:options.UseNpgsql(..., npgsql => npgsql.UseValueRangesNodaTime()).
The core and base EF packages are unchanged apart from the EF plugin's internal type registry becoming extensible for satellites. No source changes are required.
PostgreSQL feature-matrix completion — every remaining range/multirange operator and function now has an in-memory implementation and a LINQ-to-SQL translation:
- Bound accessors —
LowerBound()/UpperBound()returnT?(nullwhen unbounded or empty, matching PostgreSQLlower/upperNULLsemantics), andLowerBoundInclusive()/UpperBoundInclusive()mirrorlower_inc/upper_inc— on ranges and onRangeSet. Sorting by range start finally works straight from LINQ:query.OrderBy(b => b.Period.LowerBound())→ORDER BY lower("Period"). See Bound Accessors. Merge— the smallest single range spanning both operands including any gap (PostgreSQLrange_merge), on ranges and asRangeSet.Merge(). See Merge (Convex Hull).- Aggregates —
RangeAgg()andRangeIntersectAgg()over sequences of ranges (range_agg,range_intersect_agg), translated insideGroupByprojections. See Aggregates. - Multirange operator parity —
RangeSetgainsContains(RangeSet),Overlaps(RangeSet),IsAdjacentTo,IsStrictlyLeftOf/RightOfandDoesNotExtendLeftOf/RightOf(range and set operands), plus the state checksIsEmpty(),IsUnboundedStart(),IsUnboundedEnd()— each translating to its multirange operator or function. ==/!=onRangeSet— structural equality operators, so in-memory comparisons agree with the SQL=the EF Core provider generates. Behavioral change:==on sets was previously reference equality; recompiling against v4 switches those call sites to value equality. This is the change that makes v4 a major version.- Full
&</&>parity —DoesNotExtendRightOf/DoesNotExtendLeftOfnow treat an infinite bound as comparing equal to another infinite bound (+∞ ≤ +∞,-∞ ≥ -∞), exactly like PostgreSQL. Behavioral change: an unbounded receiver previously always returnedfalse, even against an operand unbounded on the same side. - Bug fix —
RangeSet.Infinite.Contains(range)andRangeSet.Infinite.Overlaps(range)threwInvalidOperationExceptionfor operands with a finite bound; they now return the expected result. - Live-PostgreSQL integration suite — a Testcontainers-based test project executes the translated SQL against real PostgreSQL and asserts agreement with the in-memory results: round-trips for all six range and both multirange column types, the timestamp normalization rules, and the v4 operations end-to-end.
Performance — RangeSet<TRange, T> now exploits its sorted, disjoint, non-adjacent invariant for sub-linear queries and merge-join set operations. No public API or results changed — only the time complexity:
| Operation | Before | After |
|---|---|---|
Contains(T), Contains(IRange<T>), Overlaps(IRange<T>) |
O(n) linear scan | O(log n) binary search on lower bounds |
Union(RangeSet, RangeSet) |
re-sort of concatenation | O(n + m) merge of two pre-sorted streams |
Intersect(RangeSet, RangeSet) |
O(n · m) nested loop | O(n + m) two-pointer merge-join |
Except(RangeSet, RangeSet) |
per-element re-normalization | O(n + m) two-pointer walk |
Except from Infinite |
O(|other|²) | O(|other|) single-pass complement walk |
From single-element input |
list + sort + merge | zero-allocation fast path |
New API — RangeSet<TRange, T>.LowerBoundComparer exposes the set's internal lower-bound ordering as a public IComparer<TRange> singleton, for sorting arbitrary List<TRange>s the same way the set does. Also available as RangeLowerBoundComparer<TRange, T>.Instance. See RangeSet — Sorting ranges externally.
Bug fix — Quoted range bounds now unescape PostgreSQL \" → " and \\ → \ on parse, so element types whose stringification can contain quotes or backslashes round-trip correctly. See Parsing — Quoted bounds.
| .NET type | PostgreSQL equivalent | Element type | Discrete |
|---|---|---|---|
Int32Range |
int4range |
int |
✓ |
Int64Range |
int8range |
long |
✓ |
DecimalRange |
numrange |
decimal |
— |
DateRange |
daterange |
DateOnly |
✓ |
DateTimeRange |
tsrange |
DateTime |
— |
DateTimeOffsetRange |
tstzrange |
DateTimeOffset |
— |
TimeRange |
timerange (custom) |
TimeOnly |
— |
Discrete types (int, long, DateOnly) know their step size. This matters for adjacency checks: [1, 5] and [6, 10] are adjacent for integers because there is no integer between 5 and 6.
TimeRange is a time-of-day range — opening hours, shifts, booking slots. A single range cannot cross midnight; a 22:00–06:00 window is two ranges, which is exactly what a two-element RangeSet (and its PostgreSQL multirange counterpart) represents. PostgreSQL has no built-in timerange, so the EF Core companion maps it to the custom type users conventionally create for this — see TimeRange and the custom timerange type.
The list is deliberately vetted. Interval algebra needs a total order that the type's own comparisons agree with, and — for adjacency — a defined step between neighbouring values. The first six domains have both and are the six PostgreSQL ships as built-ins; TimeOnly (and, in the NodaTime satellite, YearMonth) clear the same bar and joined in v5.
double and float have neither, and fail quietly. double.CompareTo reports NaN as less than every value and equal to itself, which is a total order; the IEEE operators disagree, since NaN < 5.0, NaN > 5.0 and NaN == NaN are all false. A range library generic over IComparable<T> therefore accepts double without complaint and answers containment against a NaN bound with a straight face. There is no exception to catch and no bound to reject at construction — the result is simply wrong. Restricting T to a vetted set is what makes the algebra sound, not a limitation left in for later.
Guid is absent for a different reason: v7 values are ordered, so the algebra would be well-defined, but "every GUID between these two" is not a question with a domain meaning.
For projects that build on NodaTime's primitives instead of the BCL date/time types, the satellite package CodoMetis.ValueRanges.NodaTime provides the three NodaTime types that clear the same bar — a total order the type's own comparisons agree with, mapping onto a PostgreSQL built-in:
| .NET type | PostgreSQL equivalent | Element type | Discrete |
|---|---|---|---|
LocalDateRange |
daterange |
LocalDate |
✓ |
LocalDateTimeRange |
tsrange |
LocalDateTime |
— |
InstantRange |
tstzrange |
Instant |
— |
YearMonthRange |
daterange (month-aligned) |
YearMonth |
✓ |
YearMonthRange (v5) is a month-granularity range for billing and reporting periods — discrete with a one-month step, so [2024-01, 2024-03] and [2024-04, 2024-06] are adjacent. It converts losslessly to the LocalDateRange covering exactly its months (ToLocalDateRange() / ToYearMonthRange()), which is also how the EF Core satellite stores it: as a month-aligned daterange, with every operator working server-side and reads validating alignment rather than silently shifting boundaries.
The same algebra, literals, JSON support and RangeSet multiranges apply unchanged. Notably, the two timestamp caveats documented below do not arise there: LocalDateTime is wall-clock time by construction and Instant is an instant by construction, so there is no Kind to reinterpret and no offset to normalize away. ZonedDateTime and OffsetDateTime are excluded by the same reasoning as double — NodaTime deliberately gives them no default ordering (instant order and local order disagree), so the IComparable<T> constraint rejects them at compile time. See the satellite's README for the full rationale and the Interval/DateInterval interop.
A companion EF Core package, CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime, maps them to the same PostgreSQL columns through Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime:
options.UseNpgsql(connectionString, npgsql => npgsql.UseValueRangesNodaTime());
// implies UseNodaTime() and UseValueRanges() — BCL and NodaTime ranges coexist in one modelOne point where the model is stricter than the database it mirrors: PostgreSQL's numeric has a NaN value (sorted above all others by fiat), so a numrange bound can be NaN. .NET's decimal has no such value, so DecimalRange cannot form one — the case that numrange has to define away does not arise.
dotnet add package CodoMetis.ValueRangesRequires .NET 10 or later.
Every type exposes four static factory methods:
// Bounded on both sides
Int32Range closed = Int32Range.CreateFinite(1, 10); // [1, 10]
Int32Range half = Int32Range.CreateFinite(1, 10, endInclusive: false); // [1, 10)
// Unbounded on the left — end exclusive by default
DateRange upToToday = DateRange.CreateUnboundedStart(DateOnly.FromDateTime(DateTime.Today)); // (-∞, today)
// Inclusive variant:
DateRange throughToday = DateRange.CreateUnboundedStart(DateOnly.FromDateTime(DateTime.Today), endInclusive: true);
// Unbounded on the right — start inclusive by default
Int32Range fromFive = Int32Range.CreateUnboundedEnd(5); // [5, +∞)
// Unbounded on both ends
Int32Range everything = Int32Range.Infinite; // (-∞, +∞)
// Explicitly empty
Int32Range empty = Int32Range.Empty;CreateFinite() automatically returns an Empty when the arguments form a degenerate or inverted interval (e.g. start > end, or equal bounds that are both exclusive).
Default boundary inclusiveness:
| Range type | CreateFinite() default |
|---|---|
Int32Range, Int64Range, DateRange |
[start, end] — closed |
DecimalRange, DateTimeRange, DateTimeOffsetRange |
[start, end) — half-open |
Discrete types default to fully closed intervals; continuous types default to the half-open convention that is conventional for monetary amounts and timestamps.
The nested sealed records are first-class citizens and ideal for exhaustive pattern matching:
string Describe(Int32Range range) => range switch
{
Int32Range.EmptyRange => "empty",
Int32Range.Finite f => $"[{f.Start}, {f.End}]",
Int32Range.UnboundedStart s => $"(-∞, {s.End}]",
Int32Range.UnboundedEnd e => $"[{e.Start}, +∞)",
Int32Range.Infinity => "(-∞, +∞)",
};The private constructor on the abstract base record prevents any subtypes being declared outside the assembly, so the compiler guarantees this switch is complete.
All query methods are extension methods on IRange<T> and work across any combination of range shapes.
var sprint = DateRange.CreateFinite(new DateOnly(2025, 1, 6), new DateOnly(2025, 1, 17));
sprint.Contains(new DateOnly(2025, 1, 10)); // true — point containment
sprint.Contains(new DateOnly(2025, 1, 20)); // false
var inner = DateRange.CreateFinite(new DateOnly(2025, 1, 8), new DateOnly(2025, 1, 14));
sprint.Contains(inner); // true — range containment
inner.IsContainedBy(sprint); // true — symmetric aliasvar a = Int32Range.CreateFinite(1, 5);
var b = Int32Range.CreateFinite(5, 10);
var c = Int32Range.CreateFinite(6, 10);
a.Overlaps(b); // true — they share the point 5
a.Overlaps(c); // falseTwo ranges are adjacent when they are contiguous with no gap and no overlap — their union would form a single range.
// Discrete: consecutive integer values are adjacent
var a = Int32Range.CreateFinite(1, 5);
var b = Int32Range.CreateFinite(6, 10);
a.IsAdjacentTo(b); // true — NextValueAfter(5) == 6
// Continuous: touching bounds with complementary inclusiveness
var x = DecimalRange.CreateFinite(1m, 5m, endInclusive: true); // [1, 5]
var y = DecimalRange.CreateFinite(5m, 10m, startInclusive: false); // (5, 10)
x.IsAdjacentTo(y); // true — one side claims 5, the other does notAn unbounded range is adjacent on its bounded edge, and the relation is symmetric — the receiver's shape does not change the answer, matching PostgreSQL's -|-:
var upTo = Int32Range.CreateUnboundedStart(0, true); // (-∞, 0]
var from = Int32Range.CreateUnboundedEnd(1); // [1, +∞)
var between = Int32Range.CreateFinite(1, 3); // [1, 3]
upTo.IsAdjacentTo(between); // true between.IsAdjacentTo(upTo); // true
upTo.IsAdjacentTo(from); // true — the two halves close the domain with no overlap
// The empty and infinite ranges are adjacent to nothing, and two ranges open at the
// same end always overlap:
Int32Range.Infinite.IsAdjacentTo(between); // false
upTo.IsAdjacentTo(Int32Range.CreateUnboundedStart(9, true)); // falseChanged in 6.2.1. Before 6.2.1
IsAdjacentToansweredfalsewhenever the receiver was unbounded, so the relation was asymmetric and disagreed with PostgreSQL. BecauseRangeSetnormalization merges neighbours after sorting by lower bound — which always puts an unbounded-start element in the receiver position —RangeSet.From([(,0], [1,)])returned{(,0],[1,)}instead of{(,)}. See the changelog.
Length reports what a range covers. The convention follows the domain: a discrete one counts its values inclusive of both bounds, a continuous one measures the span between them.
DateRange.CreateFinite(new DateOnly(2024, 1, 1), new DateOnly(2024, 1, 31)).Length; // 31 (days)
Int32Range.CreateFinite(1, 10).Length; // 10 (integers)
DecimalRange.CreateFinite(1m, 5m).Length; // 4 (span)
DateTimeRange.CreateFinite(nineAm, fiveThirtyPm).Length; // TimeSpan of 8.5 hoursEmpty and unbounded are different answers and stay distinguishable — the empty range contains nothing, an unbounded one contains too much to measure:
Int32Range.Empty.Length; // 0
Int32Range.Infinite.Length; // nullThe type follows the domain: long? for the integer ranges, int? days for DateRange, TimeSpan? for the timestamp ranges, decimal? for DecimalRange, and Duration?/Period? for the NodaTime ranges — an instant range measures exact elapsed time, a wall-clock range a calendar quantity. Length is client-side and does not translate to SQL.
The discrete range types enumerate what they contain. The continuous ones do not declare Values() at all, so the mistake is caught at compile time rather than at runtime:
foreach (var day in DateRange.CreateFinite(monday, friday).Values())
Schedule(day);
Int32Range.CreateFinite(1, 5).Values(); // 1, 2, 3, 4, 5
DecimalRange.CreateFinite(1m, 5m).Values(); // does not compile — no step to walkAn unbounded range throws NotSupportedException at the call rather than at the first iteration, so the failure points at the line that was wrong.
var year = DateRange.CreateFinite(jan1, dec31);
year.Clamp(new DateOnly(2020, 5, 5)); // jan1 — pulled up to the lower bound
year.Clamp(new DateOnly(2024, 6, 15)); // unchanged — already inside
DateRange.Empty.Clamp(anyDate); // null — nothing to snap toAn unbounded side never constrains: clamping into (-∞, 10] only ever pulls a value down.
Int32Range.CreateFinite(1, 3).IsStrictlyLeftOf(Int32Range.CreateFinite(5, 9)); // true
Int32Range.CreateFinite(1, 5).IsStrictlyLeftOf(Int32Range.CreateFinite(5, 9)); // false — they share 5
Int32Range.CreateFinite(7, 9).IsStrictlyRightOf(Int32Range.CreateFinite(1, 5)); // truePostgreSQL &< / &> equivalents:
// Does not extend to the right of other (&<)
Int32Range.CreateFinite(1, 5).DoesNotExtendRightOf(Int32Range.CreateFinite(1, 10)); // true
// Does not extend to the left of other (&>)
Int32Range.CreateFinite(3, 10).DoesNotExtendLeftOf(Int32Range.CreateFinite(1, 10)); // trueThe PostgreSQL lower / upper / lower_inc / upper_inc functions, on any range shape. The variants expose Start/End only where they exist structurally; the accessors provide the dynamic view: T? with null for a missing bound — exactly PostgreSQL's NULL semantics.
Int32Range.CreateFinite(1, 10).LowerBound(); // 1
Int32Range.CreateFinite(1, 10).UpperBoundInclusive(); // true
Int32Range.CreateUnboundedStart(5, true).LowerBound(); // null — no lower bound
Int32Range.Empty.UpperBound(); // null
Int32Range.Infinite.LowerBoundInclusive(); // false
// On RangeSet: the first element's lower bound, the last element's upper bound.
var set = RangeSet<Int32Range, int>.From([Int32Range.CreateFinite(1, 3), Int32Range.CreateFinite(7, 9)]);
set.LowerBound(); // 1
set.UpperBound(); // 9Set operations are extension methods on the concrete range types (any type that implements IRangeFactory<TRange, T>).
Returns the largest range contained by both operands. The intersection of two ranges is always expressible as a single range, so Intersect returns the range type directly — Empty genuinely means an empty intersection.
var a = Int32Range.CreateFinite(1, 10);
var b = Int32Range.CreateFinite(5, 15);
Int32Range intersection = a.Intersect(b); // [5, 10]
a.Intersect(Int32Range.CreateFinite(11, 20)); // Empty — no overlapAll shape combinations are handled: Finite ∩ UnboundedStart, UnboundedEnd ∩ UnboundedStart, and so on, each producing the correctly shaped result type.
Returns a RangeSet<TRange, T> containing every value of both operands. When the ranges overlap or are adjacent, the set holds the single merged range; when they are disjoint, the set holds both — the union of two separated ranges genuinely is two ranges, and the result type says so.
var a = Int32Range.CreateFinite(1, 5);
var b = Int32Range.CreateFinite(5, 10);
var c = Int32Range.CreateFinite(7, 10);
var ab = a.Union(b); // { [1, 10] } — overlapping, one element
var ac = a.Union(c); // { [1, 5], [7, 10] } — disjoint, two elements
ab.Count; // 1
ac.Count; // 2
ac[1]; // [7, 10]Merging an UnboundedEnd with an overlapping Finite yields an UnboundedEnd; an UnboundedStart overlapping an UnboundedEnd covers the entire domain and yields { Infinity }.
Removes the overlap of other from the receiver, returning a RangeSet<TRange, T> whose cardinality reflects the structural outcome directly.
var range = Int32Range.CreateFinite(1, 10);
var remove = Int32Range.CreateFinite(4, 6);
// [4, 6] is interior to [1, 10] — the result is split in two
var result = range.Except(remove);
// result[0] = [1, 4) ≡ [1, 3]
// result[1] = (6, 10] ≡ [7, 10]| Result | Meaning |
|---|---|
0 elements |
The receiver is fully contained by other; nothing remains |
1 element |
One-sided trim or no overlap; the remaining range |
2 elements |
other was strictly interior to the receiver; it is split in two |
Boundary inclusiveness is inverted at the cut point so that no value is lost or double-counted across the resulting pieces.
Returns the smallest single range containing both operands — PostgreSQL's range_merge. Unlike Union, the result also covers any gap between disjoint operands.
var a = Int32Range.CreateFinite(1, 3);
var b = Int32Range.CreateFinite(10, 12);
a.Union(b); // { [1, 3], [10, 12] } — two elements, the gap stays open
a.Merge(b); // [1, 12] — one range, the gap is covered
// Empty operands are ignored; unbounded edges span accordingly:
Int32Range.CreateUnboundedStart(3, true).Merge(Int32Range.CreateUnboundedEnd(10)); // (-∞, +∞)
// RangeSet.Merge() spans the whole set:
RangeSet<Int32Range, int>.From([a, b]).Merge(); // [1, 12]RangeAgg() and RangeIntersectAgg() aggregate a sequence of ranges — the in-memory counterparts of PostgreSQL's range_agg and range_intersect_agg:
new[] { Int32Range.CreateFinite(1, 5), Int32Range.CreateFinite(3, 8), Int32Range.CreateFinite(20, 25) }
.RangeAgg(); // { [1, 8], [20, 25] } — a normalized RangeSet
new[] { Int32Range.CreateFinite(1, 10), Int32Range.CreateFinite(5, 15) }
.RangeIntersectAgg(); // [5, 10] — the common intersection; null for an empty sourceIn EF Core queries they translate to the SQL aggregates inside GroupBy projections — see Entity Framework Core.
RangeSet<TRange, T> is the in-memory counterpart of a PostgreSQL 14+ multirange (int4multirange, nummultirange, …): an immutable, always-normalized set of disjoint ranges. Its invariant — elements sorted by lower bound, pairwise disjoint, pairwise non-adjacent — is enforced on every construction: empty ranges are dropped, overlapping or adjacent inputs are merged, and any Infinity input collapses the set to RangeSet<TRange, T>.Infinite.
using IntSet = RangeSet<Int32Range, int>;
// Construction normalizes: [1, 5] and [6, 10] are adjacent for int and merge.
var set = IntSet.From([
Int32Range.CreateFinite(6, 10),
Int32Range.CreateFinite(1, 5),
Int32Range.CreateFinite(20, 30)
]);
// { [1, 10], [20, 30] }
// Query operations
set.Contains(7); // true
set.Contains(Int32Range.CreateFinite(2, 8)); // true — within a single element
set.Overlaps(Int32Range.CreateFinite(15, 25)); // true
// Set operations — single-range and bulk variants, with operator aliases (|, &, -)
set.Union(Int32Range.CreateFinite(11, 19)); // { [1, 30] } — bridges the gap
set | Int32Range.CreateFinite(11, 19); // { [1, 30] }
set.Intersect(Int32Range.CreateFinite(5, 25)); // { [5, 10], [20, 25] }
set & Int32Range.CreateFinite(5, 25); // { [5, 10], [20, 25] }
set.Except(Int32Range.CreateFinite(4, 6)); // { [1, 3], [7, 10], [20, 30] }
set - Int32Range.CreateFinite(4, 6); // { [1, 3], [7, 10], [20, 30] }
// Complement — every value not covered by the set
set.Complement(); // { (-∞, 0], [11, 19], [31, +∞) }
// State checks — isempty / lower_inf / upper_inf equivalents, plus the two derived shapes
set.IsEmpty(); // false
set.IsUnboundedStart(); // false
set.IsUnboundedEnd(); // false
set.IsFinite(); // true — non-empty and bounded at both ends
set.IsInfinity(); // false — only the set covering the whole domain answers true
// Set-operand comparisons — the full multirange operator matrix
set.Contains(IntSet.From([Int32Range.CreateFinite(2, 8)])); // true (@>)
set.Overlaps(IntSet.From([Int32Range.CreateFinite(25, 40)])); // true (&&)
set.IsStrictlyLeftOf(Int32Range.CreateFinite(40, 50)); // true (<<)
set.DoesNotExtendRightOf(Int32Range.CreateFinite(1, 30)); // true (&<)
set.IsAdjacentTo(Int32Range.CreateFinite(31, 40)); // true (-|-)Adjacency mirrors PostgreSQL exactly: it is directional through the outer edges — the operand must end exactly where the set's first element begins, or begin exactly where the set's last element ends. Touching any interior boundary, even the inner side of the first or last element, does not count (verified against live PostgreSQL):
var three = IntSet.From([
Int32Range.CreateFinite(1, 3), Int32Range.CreateFinite(7, 9), Int32Range.CreateFinite(20, 22)
]);
three.IsAdjacentTo(Int32Range.CreateFinite(23, 25)); // true — attaches after the last element
three.IsAdjacentTo(Int32Range.CreateFinite(4, 6)); // false — inner side of the first element
three.IsAdjacentTo(Int32Range.CreateFinite(10, 12)); // false — touches only the interior [7, 9]The positional operators (<<, >>, &<, &>) likewise compare the first/last element's bounds.
IsInfinity() is not the conjunction of the two unbounded checks. For a single range it would be — a range is contiguous, so unbounded on both sides means the whole domain. A set can be open at both ends and still have a hole:
var gapped = IntSet.From([
Int32Range.CreateUnboundedStart(5, true), // (-∞, 5]
Int32Range.CreateUnboundedEnd(10) // [10, +∞)
]);
gapped.IsUnboundedStart(); // true
gapped.IsUnboundedEnd(); // true
gapped.Contains(7); // false — the gap
gapped.IsInfinity(); // false
IntSet.Infinite.IsInfinity(); // true — the only set that covers everythingBecause normalization collapses any Infinity input to the one-element Infinite set, that set is the unique representation of full coverage, which is what lets the check be exact both in memory and in SQL.
RangeSet<TRange, T> supports collection expressions, and they normalize exactly as From does:
RangeSet<Int32Range, int> set = [
Int32Range.CreateFinite(10, 12),
Int32Range.CreateFinite(1, 3),
Int32Range.CreateFinite(2, 5)
];
// { [1, 5], [10, 12] } — sorted and merged, not wrapped as written
RangeSet<Int32Range, int> none = []; // the Empty singleton
RangeSet<Int32Range, int> all = [Int32Range.Infinite, someRange]; // the Infinite singletonThe builder behind it is the non-generic RangeSet.Create<TRange, T>, since a [CollectionBuilder] target cannot itself be generic. Prefer the collection expression over calling it: C# does not infer type arguments from constraints, so T cannot be deduced from TRange and a direct call has to name both. There is also a From(params ReadOnlySpan<TRange>) overload beside From(IEnumerable<TRange>).
The set implements IReadOnlyList<TRange> (enumeration in lower-bound order, Count, indexer) and structural equality, including ==/!=: two sets built from different inputs that normalize identically are equal.
var a = IntSet.From([Int32Range.CreateFinite(1, 10)]);
var b = IntSet.From([Int32Range.CreateFinite(1, 5), Int32Range.CreateFinite(6, 10)]);
a.Equals(b); // true — both normalize to { [1, 10] }
a == b; // true — same value semantics as the ranges themselvesThe set's internal lower-bound ordering is exposed as RangeSet<TRange, T>.LowerBoundComparer — an IComparer<TRange> singleton for sorting arbitrary List<TRange>s the same way the set does, for example to pre-sort inputs before handing them to From. IUnboundedStartRange<T> sorts first (its lower bound is -∞); at the same finite value, an inclusive lower bound sorts before an exclusive one ([5, … before (5, …).
var unsorted = new List<Int32Range>
{
Int32Range.CreateFinite(20, 30),
Int32Range.CreateFinite(1, 5),
Int32Range.CreateUnboundedStart(10, true)
};
unsorted.Sort(RangeSet<Int32Range, int>.LowerBoundComparer);
// { (-∞, 10], [1, 5], [20, 30] }The same instance is available as RangeLowerBoundComparer<Int32Range, int>.Instance for contexts where you only have the comparer type and not the set type.
A value set is an immutable, canonical set of scalar values: deduplicated, sorted, never containing null, with structural equality. It relates to a PostgreSQL array column exactly as RangeSet<DateRange, DateOnly> relates to datemultirange — the CLR type models the domain concept (a set), the column is its storage encoding (an array):
| CLR type | Canonical set of… | PostgreSQL shape |
|---|---|---|
RangeSet<DateRange, DateOnly> |
ranges | datemultirange |
StringSet |
values | text[] |
| .NET type | Element type | PostgreSQL column | Wrapper arity |
|---|---|---|---|
StringSet |
string |
text[] |
StringSet<TElement> |
GuidSet |
Guid |
uuid[] |
GuidSet<TElement> |
Int16Set |
short |
smallint[] |
— |
Int32Set |
int |
integer[] |
Int32Set<TElement> |
Int64Set |
long |
bigint[] |
Int64Set<TElement> |
DecimalSet |
decimal |
numeric[] |
— |
DateSet |
DateOnly |
date[] |
— |
TimeSet |
TimeOnly |
time[] |
— |
DateTimeSet |
DateTime |
timestamp[] |
— |
DateTimeOffsetSet |
DateTimeOffset |
timestamptz[] |
— |
The NodaTime satellite adds LocalDateSet (date[]), LocalDateTimeSet (timestamp[]), InstantSet (timestamptz[]), LocalTimeSet (time[] — a built-in array type, so unlike timerange no CREATE TYPE is needed) and YearMonthSet (month-aligned date[]). LocalDate/LocalDateTime elements normalize to the ISO calendar at construction; YearMonth elements must already be ISO, mirroring the range types.
var tags = StringSet.From("beta", "alpha", "beta"); // {alpha,beta} — deduplicated, sorted
StringSet more = ["gamma", "alpha"]; // collection expressions work
tags.Contains("alpha"); // true
tags.Overlaps(more); // true — shares "alpha"
tags.IsSubsetOf(more); // false
tags.IsProperSubsetOf(more); // false — proper containment excludes equality
tags.Union(more); // {alpha,beta,gamma}
tags.Remove("beta"); // {alpha}
tags.Count; // 2
tags.IsEmpty; // falseIntersect, Except and Add are also available; they evaluate client-side only (PostgreSQL has no native array intersection or difference operator, and cannot insert at a sorted position), and operations that change nothing return the same instance.
Every construction path deduplicates and sorts — From, parsing, JSON, and materialization from the database. This is load-bearing twice: the EF ValueComparer collapses to a cheap equality with no false diffs in change detection, and SQL = on the stored array coincides with set equality.
The order rules are deliberate:
- String-backed sets sort ordinal — never a culture-sensitive comparison. Canonical form is a cross-writer storage contract, not a display order; a culture sort would make two machines disagree about the same set.
- Everything else sorts by the element's own comparison (numeric, chronological,
Guid.CompareTo).
PostgreSQL itself motivates the design: its array query algebra is already set-semantic — @>, <@ and && ignore both order and duplicates (ARRAY[1,1] <@ ARRAY[1] is true) — while only = compares arrays as sequences. Canonical form closes that split, the same way PostgreSQL itself canonicalizes discrete ranges and multiranges. Arrays that need to be lists (ordered, duplicates preserved) are a different concept — and one Npgsql already maps natively as T[]/List<T>.
The wrapper arities carry domain values — typed keys, strongly typed IDs — without the domain type referencing this package. TElement is constrained only on BCL interfaces, which validated-value generators emit out of the box:
// A generator-shaped wrapper: struct, IEquatable (record), IFormattable, IParsable.
public readonly record struct AccessRight : IFormattable, IParsable<AccessRight>
{
private readonly string _value;
private AccessRight(string value) => _value = value;
public static AccessRight Parse(string s, IFormatProvider? provider)
=> Validate(s) ? new(s.Trim().ToLowerInvariant()) : throw new FormatException(…);
public string ToString(string? format, IFormatProvider? formatProvider) => _value;
// TryParse elided
}
StringSet<AccessRight> rights = [AccessRight.Parse("users.read", null)];IFormattable supplies the element's backing text on the way out; IParsable<TSelf> re-runs the element's validation on the way in, so materializing corrupt data throws instead of smuggling invalid values into the domain. GuidSet<T>, Int32Set<T> and Int64Set<T> additionally require IComparable<TElement> (canonical order delegates to the backing primitive). One contract cannot be expressed in constraints and is convention instead: the element's invariant text form must be exactly the backing primitive's text form — a decorative format ("CUST-{value}") fails loudly at the persistence boundary with an error naming the contract.
String-backed wrappers sort ordinal over their text form — deliberately not the element's own IComparable, whose generated implementations typically delegate to culture-sensitive string comparison.
That same text form carries into JSON, so a wrapper set is indistinguishable on the wire from the primitive set it replaces — StringSet<AccessRight> writes ["users.read"], Int32Set<OrderId> writes [1,2] — and reads run Parse, so the validation above applies to deserialized payloads too. Give the element type its own [JsonConverter] if you want a different shape; it takes precedence.
The same vetting as for ranges applies, with one notable difference: Guid is absent from ranges ("every GUID between these two" has no domain meaning) but present in sets — membership is exactly the question ID collections ask. Excluded, deliberately: bool (a set over a two-value domain), float/double (NaN breaks total order and equality — the same quiet failure as for ranges), byte[] (nested variable-length elements have no cheap canonical order), and TimeSpan (PostgreSQL interval is a months/days/microseconds triple that TimeSpan cannot represent losslessly).
ToString() produces the PostgreSQL array literal, with the same quoting rules the server uses; Parse/TryParse accept it back, normalizing to canonical form:
StringSet.From("a b", "plain").ToString(); // {"a b",plain}
Int32Set.Parse("{2,1,2}", null); // {1,2} — normalizes on parse
StringSet.Parse("{a,NULL}", null); // FormatException — sets never contain nullJSON serialization goes through the same converter factory as the ranges (options.AddRangeConverters()) and produces plain JSON arrays (["alpha","beta"]), delegating element serialization to System.Text.Json — element converters apply. Reads normalize and reject null elements. Element types the serializer does not know natively are covered by the family's own element converter.
Over a discrete domain the same membership has two shapes: {1,2,3,7} and {[1,3],[7,7]} contain exactly the same values. Which one to store is a question of density, and the conversion moves between them:
Int32Set.From(1, 2, 3, 7).ToRangeSet(); // { [1, 3], [7, 7] }
DateSet.From(fri, sat, sun, mon).ToRangeSet(); // { [fri, mon] } — one range
rangeSet.ToInt32Set(); // back to individual values
rangeSet.ToDateSet();A thousand consecutive dates are one daterange and a thousand-element date[], and @> against the range column is the cheaper question by a wide margin. Sparse data goes the other way, where ranges of one value each cost more than the values do.
Only the discrete families convert — Int32Set, Int64Set, DateSet, and LocalDateSet/YearMonthSet in the NodaTime satellite. The continuous domains have no step, so there is no set of values to expand to. Both directions run client-side and neither translates: PostgreSQL converts between arrays and multiranges only through unnest and a custom aggregate. Expanding an unbounded range set throws rather than hanging.
A value set is for value catalogs: tags, codes, keys, dates — elements that are data, not entity references. If the elements are rows in another table and you need referential integrity, PostgreSQL cannot put a foreign key on array elements (a long-standing limitation); use a junction table. If you need order-as-data or duplicates, you want a list, which Npgsql's native T[]/List<T> mapping already serves.
All range types and RangeSet<TRange, T> implement IParsable<T> and IFormattable. The canonical string representation is the PostgreSQL range literal format — the same syntax PostgreSQL uses on the wire.
ToString() (and IFormattable.ToString(format, provider)) produces PostgreSQL range literals:
Int32Range.CreateFinite(1, 10).ToString() // "[1,10]"
Int32Range.CreateFinite(1, 10, endInclusive: false)
.ToString() // "[1,10)"
Int32Range.CreateUnboundedStart(5).ToString() // "(,5]"
Int32Range.CreateUnboundedEnd(5).ToString() // "[5,)"
Int32Range.Infinite.ToString() // "(,)"
Int32Range.Empty.ToString() // "empty"
DateRange.CreateFinite(new DateOnly(2025, 1, 1),
new DateOnly(2025, 3, 31)).ToString()
// "[2025-01-01,2025-03-31]"
DateTimeOffsetRange.CreateFinite(
new DateTimeOffset(2024, 6, 1, 0, 0, 0, TimeSpan.FromHours(1)),
new DateTimeOffset(2024, 7, 1, 0, 0, 0, TimeSpan.FromHours(1))).ToString()
// "[2024-06-01T00:00:00.0000000+01:00,2024-07-01T00:00:00.0000000+01:00)"The optional format parameter is forwarded to the element type, so you can control how individual bound values are rendered:
((IFormattable)DateRange.CreateFinite(new DateOnly(2025, 1, 1),
new DateOnly(2025, 3, 31)))
.ToString("MMM d yyyy", CultureInfo.InvariantCulture)
// "[Jan 1 2025,Mar 31 2025]"RangeSet<TRange, T> formats as a PostgreSQL multirange literal:
IntSet.From([Int32Range.CreateFinite(1, 5), Int32Range.CreateFinite(7, 10)])
.ToString() // "{[1,5],[7,10]}"
IntSet.Empty.ToString() // "{}"
IntSet.Infinite.ToString() // "{(,)}"Every concrete range type exposes Parse and TryParse static methods that accept any valid PostgreSQL range literal:
var r1 = Int32Range.Parse("[1,10]", null); // Finite [1, 10]
var r2 = Int32Range.Parse("(,5]", null); // UnboundedStart (−∞, 5]
var r3 = Int32Range.Parse("[3,)", null); // UnboundedEnd [3, +∞)
var r4 = Int32Range.Parse("(,)", null); // Infinity (−∞, +∞)
var r5 = Int32Range.Parse("empty", null); // Empty
if (Int32Range.TryParse(userInput, null, out var range))
Console.WriteLine(range);Discrete types canonicalize on parse — "[1,10)" is equivalent to "[1,9]" and both parse to the same closed [1, 9] range:
Int32Range.Parse("[1,10)", null).ToString() // "[1,9]"RangeSet<TRange, T> parses multirange literals in the same way:
var set = RangeSet<Int32Range, int>.Parse("{[1,5],[7,10]}", null);
set.Count; // 2
set[0]; // [1, 5]
set[1]; // [7, 10]Every parsable type implements ISpanParsable<T>, so Parse and TryParse also take a ReadOnlySpan<char>. The literal grammars are parsed over spans internally either way — the overload just removes the substring allocation when what you have is a slice of a larger buffer:
ReadOnlySpan<char> line = "period=[2024-01-01,2024-12-31];rate=4.5".AsSpan();
var period = DateRange.Parse(line[7..29], null); // no substring allocated
var tags = StringSet.Parse("{a,b}".AsSpan(), null);
var blocks = RangeSet<Int32Range, int>.Parse("{[1,5],[7,10]}".AsSpan(), null);ISpanParsable<T> extends IParsable<T>, so the string overloads and any generic code constrained on IParsable<T> keep working unchanged. One thing to know if you write generic code over these types: where a type parameter is constrained to IRangeFactory or IValueSetFactory, both overloads are now visible and a string argument binds to the span one through the implicit conversion. Each type's two overloads are the same call, so results are unaffected.
PostgreSQL allows quoting individual bounds to embed commas, brackets, or other characters that would otherwise confuse the parser:
Int32Range.Parse("[\"1\",\"10\"]", null); // [1, 10]Inside quotes, \" is unescaped to " and \\ to \, matching PostgreSQL's quoted-bound syntax. The no-quote fast path stays allocation-free; unescaping only runs when a backslash is actually present inside the quotes.
The CodoMetis.ValueRanges.Serialization namespace provides System.Text.Json converters for all range types and their multirange counterparts. Ranges serialize as JSON strings in PostgreSQL literal format — compact and round-trippable.
Register all converters at once using the AddRangeConverters() extension:
using CodoMetis.ValueRanges.Serialization;
var options = new JsonSerializerOptions().AddRangeConverters();Or use the factory for automatic registration on any range/multirange type:
var options = new JsonSerializerOptions
{
Converters = { new RangeJsonConverterFactory() }
};In ASP.NET Core, add it to your serializer configuration:
builder.Services.ConfigureHttpJsonOptions(o =>
o.SerializerOptions.AddRangeConverters());var range = Int32Range.CreateFinite(1, 10);
string json = JsonSerializer.Serialize(range, options); // "\"[1,10]\""
var back = JsonSerializer.Deserialize<Int32Range>(json, options);
// back == Int32Range.CreateFinite(1, 10)
// Multirange
var set = RangeSet<Int32Range, int>.From([
Int32Range.CreateFinite(1, 5),
Int32Range.CreateFinite(7, 10)
]);
string setJson = JsonSerializer.Serialize(set, options); // "\"{[1,5],[7,10]}\""
// Works with all six range types and their multirange counterparts
var dates = JsonSerializer.Serialize(
DateRange.CreateFinite(new DateOnly(2025, 1, 1), new DateOnly(2025, 12, 31)), options);
// "\"[2025-01-01,2025-12-31]\""null round-trips as null, in both directions and for every type here — ranges, variants, RangeSet and the value sets — exactly as any other reference-typed property does. It stays distinct from the empty range, which is the literal "empty": a missing value and an empty interval are different facts with different wire forms, and neither is read as the other. A malformed literal is still rejected with JsonException.
Before 6.2, the range converters rejected a null token on read while writing
nullon the way out — so a payload the package produced could not be read back. If you depended on that exception to reject a null where a non-nullable range was expected, the property now receivesnullinstead.
The union's sealed variants serialize to the same literal, so a range reached through object — a boxed value, an object-typed property, a heterogeneous collection — is not a special case:
JsonSerializer.Serialize<object>(Int32Range.CreateFinite(1, 5), options); // "\"[1,5]\""
JsonSerializer.Serialize(new List<object> { range, dateRange }, options); // ["[1,5]","[2024-01-01,2024-03-01]"]Reading into a variant-typed declaration works too, and refuses a literal of the wrong shape: "empty" is not an Int32Range.Finite, so it throws JsonException rather than widening. A property declared as the IRange<T> interface is not covered — the interface carries no factory to parse back through; declare it as the union type.
Value sets serialize as plain JSON arrays and delegate their elements to System.Text.Json, which keeps element converters authoritative — registered on the options, on the property, or on the element type. For element types the serializer knows nothing about, that delegation would silently produce an object of the element's properties on write and default on read. A set family closes that hole by supplying a fallback:
static JsonConverter<LocalDate>? IValueSetFactory<LocalDateSet, LocalDate>.ElementJsonConverter
=> /* ISO 8601, the same text form the array literals use */;It is consulted last — only when System.Text.Json has no scalar converter for the element type at all — so registering one by any of the three normal routes still wins. The primitive-backed families serialize natively and leave it at the default null. The four wrapper arities define one, because their element type is whatever you supply: string- and Guid-backed sets write the element's text form as a JSON string, integer-backed sets write a JSON number, so Int32Set<OrderId> and Int32Set produce identical payloads. The five NodaTime sets define one too, which is why they need no configuration:
var options = new JsonSerializerOptions().AddRangeConverters();
JsonSerializer.Serialize(LocalDateSet.From(new LocalDate(2024, 1, 1)), options); // ["2024-01-01"]The satellite also ships AddNodaTimeRangeConverters(), which registers the same element converters on the options. That extends the ISO 8601 form to bare NodaTime properties sitting alongside a set, which the fallback does not reach — see the satellite README.
The library exposes a structured set of interfaces for writing generic code:
| Interface | Purpose |
|---|---|
IRange<T> |
Base marker for all range types |
IFiniteRange<T> |
Start, End, and their inclusiveness flags |
IUnboundedStartRange<T> |
End and EndInclusive |
IUnboundedEndRange<T> |
Start and StartInclusive |
IEmptyRange<T> |
Marker for the empty range; no bound properties |
IInfinityRange<T> |
Marker for the range covering the entire domain |
IRangeFactory<TRange, T> |
Abstract static factories; also NextValueAfter/PreviousValueBefore for step-aware (discrete) types |
T is constrained to struct, IComparable<T>, IEquatable<T> throughout.
IRangeFactory<TRange, T> and IValueSetFactory<TSet, T> both extend ISpanParsable<TSelf> (and so IParsable<TSelf>) and IFormattable, which is what lets generic code parse and format any range or set without knowing the concrete type:
static T Load<T>(ReadOnlySpan<char> literal) where T : ISpanParsable<T>
=> T.Parse(literal, CultureInfo.InvariantCulture);For sorting ranges externally, RangeLowerBoundComparer<TRange, T> (an IComparer<TRange> singleton) exposes the same lower-bound ordering the set uses internally. See Sorting ranges externally.
In v1.x, calling .ToString() on any range variant returned the default C# record representation:
Finite { Start = 1, End = 10, StartInclusive = True, EndInclusive = True }
From v2.0.0, ToString() returns the PostgreSQL range literal:
[1,10]
If your code depended on the old format for logging, display, serialization, or string comparison, update it to use the new literal format or, if you need the structural representation, reconstruct it from the variant's properties via pattern matching.
IsEmpty, IsFinite, IsInfinity, IsUnboundedStart, and IsUnboundedEnd were extension properties in v2.x. In v3.0.0 they are extension methods — add parentheses at every call site:
// v2.x
if (range.IsEmpty) { … }
// v3.0.0
if (range.IsEmpty()) { … }The change is mechanical and the compiler will flag every affected site. The motivation is EF Core compatibility: extension properties cannot appear in LINQ expression trees, preventing SQL translation. As extension methods they are fully translated by the EF Core companion package — see the EF Core section below.
RangeSet<TRange, T> defines operator ==/!= as value equality, delegating to Equals — consistent with the range types themselves (records) and with the SQL = the EF Core provider generates. Code that compared sets with == previously got reference equality; recompiling against v4 silently changes those call sites to value comparison. If you relied on reference identity, switch to ReferenceEquals(a, b).
An unbounded receiver previously always returned false. In v4, an infinite bound compares equal to another infinite bound — [5, +∞).DoesNotExtendRightOf([100, +∞)) is now true (+∞ ≤ +∞), matching the &</&> operators exactly. Results against finite-bounded or empty operands are unchanged.
Everything else in v4 is additive — no other source changes are required.
The companion package CodoMetis.ValueRanges.EFCore.PostgreSQL maps every range type to its PostgreSQL range column and RangeSet<TRange, T> to the corresponding multirange column, bridging through NpgsqlRange<T> at the provider boundary — giving you identical semantics whether executing against an in-memory collection or a live PostgreSQL database.
dotnet add package CodoMetis.ValueRanges.EFCore.PostgreSQLEnable it with one line — no value converters, comparers, or column types to configure:
options.UseNpgsql(connectionString, npgsql => npgsql.UseValueRanges());Properties of the range types and of RangeSet<TRange, T> are then mapped by convention:
| Property type | Column type |
|---|---|
Int32Range |
int4range |
RangeSet<Int32Range, int> |
int4multirange |
DateRange |
daterange |
RangeSet<DateRange, DateOnly> |
datemultirange |
TimeRange |
timerange (custom type) |
| … and so on for all types |
The full range algebra translates from LINQ to SQL:
var day = new DateOnly(2024, 6, 15);
// b."Period" @> @day
bookings.Where(b => b.Period.Contains(day));
// b."Period" && b."Blocked", b."Period" << @other, b."Period" -|- @other, ...
bookings.Where(b => b.Period.Overlaps(other));
// b."Period" * @other (intersection)
bookings.Select(b => b.Period.Intersect(other));
// datemultirange(b."Period") + datemultirange(@other) (union -> multirange)
bookings.Select(b => b.Period.Union(other));
// b."BlockedDays" @> @day, multirange + - * operators, complement, ...
bookings.Where(b => b.BlockedDays.Contains(day));
bookings.Select(b => b.BlockedDays | b.Period);
// CASE WHEN b."From" <= b."To" THEN daterange(b."From", b."To", '[]') ELSE 'empty' END
bookings.Where(b => DateRange.CreateFinite(b.From, b.To).Contains(day));Contains, Overlaps, IsContainedBy, IsStrictlyLeftOf/RightOf, DoesNotExtendLeftOf/RightOf and IsAdjacentTo map to @>, &&, <@, <<, >>, &<, &> and -|- — on ranges and, since v4, on RangeSet with range or multirange operands. Intersect maps to *; Union and Except lift both operands to multiranges (+/-), matching their RangeSet return type — a disjoint union is a real two-element multirange, never an error. The CreateFinite/CreateUnboundedStart/CreateUnboundedEnd factories translate to guarded range constructor calls with the model's inverted-bounds-yield-empty semantics.
New in v4:
// ORDER BY lower(b."Period") — bound accessors: lower / upper / lower_inc / upper_inc
bookings.OrderBy(b => b.Period.LowerBound());
// range_merge(b."Period", @other) and range_merge(b."BlockedDays")
bookings.Select(b => b.Period.Merge(other));
bookings.Select(b => b.BlockedDays.Merge());
// range_agg(b."Period") / range_intersect_agg(b."Period") per group
bookings.GroupBy(b => b.CustomerId)
.Select(g => g.Select(b => b.Period).RangeAgg());
// isempty / lower_inf / upper_inf on multirange columns
bookings.Where(b => !b.BlockedDays.IsEmpty());
// Value equality on multirange columns — b."BlockedDays" = @set
bookings.Where(b => b.BlockedDays == someSet);Notes:
- Range state checks translate directly:
IsEmpty()→isempty,IsUnboundedStart()→lower_inf,IsUnboundedEnd()→upper_inf,IsInfinity()→lower_inf AND upper_inf,IsFinite()→NOT lower_inf AND NOT upper_inf AND NOT isempty. - The same state checks exist on
RangeSetand translate to the multirange forms of those functions — exceptIsInfinity(), which translates to equality against the infinite multirange (x = '{(,)}'::datemultirange).lower_inf AND upper_infis the right translation for a range and the wrong one for a multirange, which can satisfy both and still have a gap. PostgreSQL canonicalizes multiranges the way the model does, so the equality is exact (verified against live PostgreSQL). LowerBound()/UpperBound()returnT?because PostgreSQL'slower/upperreturnNULLfor an unbounded or empty operand — the in-memory implementation matches.- For the discrete types (
int4range,int8range,daterange), PostgreSQL canonicalizes to half-open[lower, upper)while the model canonicalizes to closed[lower, upper].UpperBound()therefore translates toupper(x) - 1andUpperBoundInclusive()toNOT upper_inf(x) AND NOT isempty(x), so server results always equal the in-memory results (verified against live PostgreSQL). - The aggregates return
NULLin SQL for zero input rows (standard PostgreSQL aggregate behavior), while the in-memoryRangeAgg()returns the empty set.RangeIntersectAgg()returnsnullin both worlds. - The factory-method bound-inclusiveness flags must be compile-time constants to translate (they pick the bounds literal, e.g.
'[]'); in practice they always are, because the flags default at the call site.
Timestamp semantics:
DateTimeRangebounds are written astimestampwithDateTimeKind.Unspecified— a UTC-kindedDateTimeis reinterpreted as wall-clock time, not converted.DateTimeOffsetRangebounds are normalized to UTC fortimestamptz: the instant is preserved, but the original offset is not round-tripped (values read back carry offset+00:00and compare equal to what was written, sinceDateTimeOffsetequality is instant-based).- Npgsql by default maps
DateTime.MinValue/MaxValueto PostgreSQL-infinity/infinity. A finite bound ofDateTime.MaxValuetherefore becomes an explicitinfinitybound in the database — which is distinct from an unbounded side (upper_infstaysfalse), so shape checks behave consistently. - Reverse engineering (
dotnet ef dbcontext scaffold) maps range columns toNpgsqlRange<T>, not to these types — the plugin provides no design-time services. Apply the range types manually after scaffolding.
The same package maps every value set type to its native PostgreSQL array column — by convention, with nothing to configure. Wrapper instantiations (StringSet<AccessRight>) are recognized automatically from the open generic; there is no per-element registration to forget:
| Property type | Column type |
|---|---|
StringSet, StringSet<TElement> |
text[] |
GuidSet, GuidSet<TElement> |
uuid[] |
Int32Set, Int32Set<TElement> |
integer[] |
DateSet |
date[] |
YearMonthSet (NodaTime) |
date[] (month-aligned) |
| … and so on for all types |
The set algebra translates to PostgreSQL's array operators:
// b."Tags" @> ARRAY[@tag]::text[] — containment, not = ANY: a GIN index always serves it
bookings.Where(b => b.Tags.Contains(tag));
// b."Tags" && @wanted — order- and duplicate-insensitive, like all of these
bookings.Where(b => b.Tags.Overlaps(wanted));
// b."Tags" <@ @allowed / b."Tags" @> @required
bookings.Where(b => b.Tags.IsSubsetOf(allowed));
bookings.Where(b => b.Tags.IsSupersetOf(required));
// b."Tags" <@ @allowed AND NOT (b."Tags" @> @allowed) — the negated converse, not <>,
// so proper containment stays duplicate-insensitive like everything else here
bookings.Where(b => b.Tags.IsProperSubsetOf(allowed));
// cardinality(b."Tags") > 2 / cardinality(b."Tags") = 0
bookings.Where(b => b.Tags.Count > 2);
bookings.Where(b => b.Tags.IsEmpty);
// array_remove(b."Tags", @tag) — preserves canonical form, so it composes freely
bookings.Where(b => b.Tags.Remove(tag).Count > 1);
// array_cat(b."Tags", @more) @> ARRAY[@tag]::text[]
bookings.Where(b => b.Tags.Union(more).Contains(tag));Intersect, Except and Add are client-side only and fail query translation by design.
Union is the one translated operation whose result is not canonical — array_cat
concatenates. That is invisible to the operators above (all duplicate-insensitive) and to
materialization (reads re-canonicalize), but Count over a union is refused rather than
counting duplicates, and comparing a union with == is unreliable. Remove has no such
caveat: array_remove leaves the array sorted and deduplicated. Wrapper elements bind as their backing primitive (AccessRight parameters travel as text), and materialization re-runs the element's validation.
Indexing is ordinary EF configuration — no package involvement:
modelBuilder.Entity<Booking>()
.HasIndex(b => b.Tags)
.HasMethod("GIN");Contains deliberately translates as containment (@>) rather than = ANY(...), because only containment is GIN-servable — one code path, always indexable.
Set equality (==) translates to SQL =, which is order-sensitive on arrays: it means set equality exactly because every writer stores canonical form. Rows written by other tools in non-canonical order are still matched correctly by all the operators above (they ignore order and duplicates) and normalize when materialized — only == carries the canonical-writers precondition. The empty set and a NULL column stay distinct ({} vs NULL); nullability is the property's own concern.
Two boundary notes: plain T[]/List<T> properties keep their native Npgsql mapping — both can coexist in one model — and database scaffolding produces plain arrays, since opting into a set type is a model decision. The NodaTime satellite registers its five set types via the same UseValueRangesNodaTime() call; YearMonthSet persists first-of-month dates and reads validate alignment, exactly like YearMonthRange.
timerange is not built into PostgreSQL, so using TimeRange columns takes two one-line opt-ins beyond UseValueRanges():
// 1. The database needs the type — this generates
// CREATE TYPE timerange AS RANGE (SUBTYPE = time) in your migrations
// (PostgreSQL 14+ auto-creates timemultirange alongside it):
modelBuilder.HasPostgresRange("timerange", "time");
// 2. Npgsql needs permission to resolve the unmapped type on the wire:
options.UseNpgsql(connectionString, npgsql => npgsql
.UseValueRanges()
.ConfigureDataSource(dataSource => dataSource.EnableUnmappedTypes()));
// (call EnableUnmappedTypes() on your own NpgsqlDataSourceBuilder instead
// if you pass a pre-built NpgsqlDataSource to UseNpgsql)Everything else is automatic: all range and multirange operators, functions and aggregates in PostgreSQL are polymorphic (anyrange/anymultirange), so the full LINQ translation works on the custom type exactly as on the built-ins — verified against live PostgreSQL. One caveat: PostgreSQL's time admits the special value 24:00:00, which TimeOnly cannot represent; express "until end of day" as an unbounded end or an inclusive TimeOnly.MaxValue bound.
The NodaTime satellite stores YearMonthRange as a month-aligned daterange — [2024-01, 2024-03] becomes [2024-01-01, 2024-04-01) — so no custom database type is involved and every operator, bound accessor and aggregate translates and agrees with the in-memory results (upper() compensation lands on the last day of the end month, whose month is the model's inclusive upper bound). Reads validate month alignment: a daterange covering a partial month fails loudly instead of silently shifting boundaries. The one restriction: because months are coarser than the date subtype, the CreateFinite/CreateUnbounded* factories cannot be built in SQL from column values — constant and parameter ranges work as usual, and a column-dependent factory call fails translation with a clear error.
In practice the restriction only bites when a query constructs the range from a column:
// Factories over constants and locals never reach the translator — EF evaluates
// them client-side and the result renders as a month-aligned daterange literal:
// r."BillingPeriod" && '[2024-01-01,2024-06-30]'::daterange
var from = new YearMonth(2024, 1); var to = new YearMonth(2024, 6);
reservations.Where(r => r.BillingPeriod.Overlaps(YearMonthRange.CreateFinite(from, to)));
// Building the range from a column would need month arithmetic in SQL — a closed
// upper bound must expand to first-of-next-month, which the element-wise bound
// conversion cannot express. Fails with the standard EF translation error:
reservations.Where(r => YearMonthRange.CreateUnboundedEnd(r.Day.ToYearMonth())
.Contains(month)); // ⛔ InvalidOperationExceptionFor column-driven construction, fall back to a LocalDateRange built from the date column — daterange construction in SQL is fully supported there.
Most of the surface translates to SQL and gives identical answers in memory and on the server — that is the point of the library, and the live-PostgreSQL suite holds it to that. A minority evaluates client-side, always because PostgreSQL has no operator for it rather than because the translation was not written. This table is the whole picture.
Translated to SQL — usable in Where, OrderBy, Select, on columns and on parameters:
| Surface | Operations | PostgreSQL |
|---|---|---|
| Ranges | Contains, IsContainedBy, Overlaps, IsAdjacentTo |
@>, <@, &&, -|- |
IsStrictlyLeftOf/RightOf, DoesNotExtendLeftOf/RightOf |
<<, >>, &<, &> |
|
Intersect, Union, Except, Merge |
*, +, -, range_merge |
|
IsEmpty, IsUnboundedStart/End, IsInfinity, IsFinite |
isempty, lower_inf, upper_inf, and combinations |
|
LowerBound/UpperBound, LowerBoundInclusive/UpperBoundInclusive |
lower, upper, lower_inc, upper_inc |
|
CreateFinite/CreateUnboundedStart/CreateUnboundedEnd |
range constructor functions | |
RangeAgg, RangeIntersectAgg |
range_agg, range_intersect_agg |
|
RangeSet |
the same operations over multirange columns, plus Complement |
the multirange forms, and '{(,)}' - x |
| Value sets | Contains, Overlaps, IsSubsetOf, IsSupersetOf, and the proper variants |
@>, &&, <@ |
Count, IsEmpty |
cardinality |
|
Union, Remove |
array_cat, array_remove |
|
| Both families | ==/!= on a column |
=, <> |
Client-side only — these compute the right answer in memory, fail translation in a predicate, and fall back to client evaluation in a projection:
| Operation | Why it does not translate |
|---|---|
Length on any range |
The finite case would be upper(x) - lower(x), but the empty range measures 0 where PostgreSQL's subtraction yields NULL, and int4range overflows int4 before a cast can widen it. |
Values() on a discrete range |
Enumeration is generate_series, whose result is a set of rows rather than a value — it cannot appear where a scalar is expected. |
ToRangeSet() / ToInt32Set() and the other bridge conversions |
PostgreSQL converts between arrays and multiranges only through unnest and a custom aggregate. |
Clamp(value) on any range |
Expressible as GREATEST/LEAST over lower/upper, but the empty and unbounded cases have no bound to clamp to and would need a CASE per shape. |
Intersect, Except, Add on value sets |
PostgreSQL's array type has no intersection, difference, or sorted insert. |
The value set indexer, set[0] |
Array subscripting exists, but the canonical order is the CLR comparer's, not the server's. |
Two consequences worth knowing. A client-side operation inside a Where fails translation loudly rather than silently fetching the table — EF throws, and that is the intended behaviour. Inside a Select it evaluates on the rows already being returned, which is safe. And Count over a server-computed Union is refused outright rather than answered, because array_cat concatenates without deduplicating — see the note under value set columns.
The library's core promise — identical results in memory and as SQL — is enforced by three test layers:
- In-memory unit suite — every operation across the full shape matrix: all 5×5 shape combinations per binary operation, the four bound-inclusiveness permutations, discrete and continuous domains, normalization invariants, and literal round-trips.
- Translation suite — asserts the exact SQL generated for every LINQ construct via
ToQueryString(), without a database. - Live-PostgreSQL parity suite — a Testcontainers-based project executes the translated SQL against a real PostgreSQL instance and asserts agreement with the in-memory results: round-trips for every range and multirange column type, the timestamp normalization and precision rules at the Npgsql boundary, and operation-level parity for the full algebra.
The live suite is the authority on semantics, and the model bends to it rather than the other way around: it is what established the discrete upper() canonicalization compensation, PostgreSQL's directional multirange adjacency rule documented above, and the confirmation that a multirange satisfying both lower_inf and upper_inf is still not the whole domain — all before any user could trip over them. The NodaTime satellite types run through the same three layers, including the Instant sub-microsecond precision reduction and the ±infinity boundary mapping.
All three layers run in CI on every push and pull request — the badge at the top of this page is the current state of the whole suite, live database included. A fourth, repo-level layer checks the things that compile and pack cleanly and only fail once a package is installed: that every shipped version is documented, that each package ships its own README, and that the value set contracts above hold for every set type that exists.
Bug reports and pull requests are welcome — CONTRIBUTING.md covers the setup and the quality bar this package holds itself to. Security reports go privately through SECURITY.md.
Packages are published through GitHub Actions Trusted Publishing, carry Source Link metadata and symbol packages, and ship a CycloneDX SBOM per package, attached to each GitHub release.
A substantial portion of this codebase was written with AI assistance, under maintainer direction and review. CONTRIBUTING.md explains what that means in practice, and how every change is verified before it ships.
MIT — see LICENSE.