The Interval Is the Thing: Modelling Range Types as First-Class Domain Objects in .NET
Source: Dev.to
A complete solution: expressive range types in your domain layer, full PostgreSQL translation in your data layer - no compromises at either end
The Two-Column Trap
Almost every developer has written it at least once. An object with two date properties:
public class MemberSubscription
{
public int Id { get; set; }
public int MemberId { get; set; }
public DateTime StartDate { get; set; }
public DateTime EndDate { get; set; }
}
Enter fullscreen mode
Exit fullscreen mode
Imagine you need to answer a seemingly simple question in a booking system: “Is this subscription still active, and does it conflict with the proposed new one?” With two bare fields, that code ends up looking something like this:
// With two bare DateTime fields — the check you always end up writing
public static bool IsActive(MemberSubscription sub, DateTime at)
=> sub.StartDate at);
public static bool ConflictsWith(MemberSubscription a, MemberSubscription b)
{
// Partial overlap: a starts inside b
if (a.StartDate >= b.StartDate && a.StartDate = a.StartDate && b.StartDate = b.EndDate) return true;
// What about open-ended subscriptions? What about same-day boundaries?
// What about inclusive vs exclusive end dates? ...
return false;
}
Enter fullscreen mode
Exit fullscreen mode
It looks perfectly reasonable. But start asking questions — as Steve Smith (Ardalis) does in his essay on making the implicit explicit — and you notice how much invisible knowledge this design requires. Should EndDate ever precede StartDate? The type system doesn’t say. Can a subscription have a null end date meaning it never expires? Nothing in the model communicates that. Is a subscription that ends today still active at 11:59 PM? Ask three developers and get three answers.
The EndDate == default sentinel for open-ended subscriptions leaks into every single callsite. The half-open vs. closed boundary question (` Period.Contains(date); // boundary semantics built into the type
public bool ConflictsWith(MemberSubscription other)
=> Period.Overlaps(other.Period); // all five shape combinations handled internally
}
// Open-ended subscription — no null, no sentinel, no magic date var lifetime = new MemberSubscription(42, DateRange.CreateUnboundedEnd(DateOnly.FromDateTime(DateTime.Today)));
// Fixed-term subscription var annual = new MemberSubscription( 7, DateRange.CreateFinite( new DateOnly(2025, 1, 1), new DateOnly(2025, 12, 31) ) );
lifetime.IsActiveOn(new DateOnly(2099, 1, 1)); // true — infinite end, correctly handled annual.ConflictsWith(lifetime); // true — overlap detected correctly
Enter fullscreen mode
Exit fullscreen mode
There is no sentinel value for "never expires". There is no bespoke overlap logic to write, audit, or test. There is no hidden assumption about whether EndDate is inclusive or exclusive — the type encodes that. The invariant that a subscription must cover at least one day is enforced in the constructor, not scattered across callers. The rules are now in the design, not in the tribal knowledge.
What PostgreSQL Already Knows
PostgreSQL has understood this problem since version 9.2. Its range types — `int4range`, `int8range`, `numrange`, `daterange`, `tsrange`, `tstzrange` — let you store an interval as a single, indivisible column value and query it with a full algebra of operators: containment (`@>`), overlap (`&&`), adjacency (`-|-`), union (`+`), intersection (`*`), difference (`-`), and more.
With a `daterange` column you can express an exclusion constraint that makes overlapping bookings *structurally impossible* at the database level:
ALTER TABLE bookings ADD CONSTRAINT bookings_no_overlap EXCLUDE USING gist (resource_id WITH =, period WITH &&);
Enter fullscreen mode
Exit fullscreen mode
This does not just prevent bad data — it atomically prevents it, at the storage layer, regardless of how many concurrent application instances are running. There is no race condition to close, no retry loop to write, no Redis lock to manage. The database enforces it. As Radim Marek writes in his article [Beyond Start and End](https://boringsql.com/posts/beyond-start-end-columns/): *"The real win here is data integrity — you're making it impossible to have invalid state in your database, not just unlikely."*
PostgreSQL 14 added multirange types (`int4multirange`, `datemultirange`, etc.) that take this further: a single column can hold a set of disjoint intervals, normalised and queryable with the same operators.
All of this is genuinely powerful. The question for .NET developers is: how do you bring this expressiveness into your domain model?
What `NpgsqlRange` Actually Is
Npgsql, the .NET driver for PostgreSQL, surfaces range types through `NpgsqlRange`. It is a `readonly struct` that mirrors PostgreSQL's wire representation: two nullable bounds, a `RangeFlags` byte-enum packing inclusiveness and infiniteness into bit fields. The documentation on `LowerBound` is honest about what this means:
*The lower bound of the range. Only valid when `LowerBoundInfinite` is false.*
That is a validity precondition on a property — a runtime guard disguised as an accessor. You must check flags before touching values. The shape of the range (bounded, unbounded-start, unbounded-end, infinite, empty) is not in the type; it is in the bits.
This is the right design for a wire type. `NpgsqlRange` was built to shuttle range values between .NET and PostgreSQL and to participate in LINQ-to-SQL translation, and it does both very well. But those goals impose constraints that make it unfit for use as a domain model primitive.
The clearest sign of this is what happens when you try to use it in domain logic. Npgsql's EF Core package (`Npgsql.EntityFrameworkCore.PostgreSQL`) provides extension methods for range operations — `Contains`, `Overlaps`, `IsAdjacentTo`, `Union`, `Intersect`, `Except`, and so on. Every single one has this body:
public static bool Contains(this NpgsqlRange range, T value) => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(Contains)));
public static bool Overlaps(this NpgsqlRange a, NpgsqlRange b) => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(Overlaps)));
public static NpgsqlRange Intersect(this NpgsqlRange a, NpgsqlRange b) => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(Intersect)));
// … and every other range operation, without exception
Enter fullscreen mode
Exit fullscreen mode
These methods exist purely as LINQ expression markers — stubs that EF Core's translator recognises and rewrites into SQL operators. They are never meant to run in process. Call any of them outside of a LINQ query against an EF Core `DbSet` and you get an `InvalidOperationException` at runtime.
This is not a flaw; it is a deliberate and correct design choice for what `NpgsqlRange` is. But it leaves .NET developers without any in-memory range type. You cannot use `NpgsqlRange` to write a pure domain method. You cannot unit-test range logic without a database. You cannot pass it across application layer boundaries without importing Npgsql everywhere. It is, in Vladimir Khorikov's terms, an *impure* dependency — and as he argues in his work on [domain model purity](https://enterprisecraftsmanship.com/posts/domain-model-purity-current-time/), impure dependencies belong at the edges, not in the domain layer.
The gap is real: there is no in-memory, domain-grade range type for .NET. Until now.
Enter CodoMetis.ValueRanges
[CodoMetis.ValueRanges](https://www.nuget.org/packages/CodoMetis.ValueRanges) is a library that fills exactly that gap.
dotnet add package CodoMetis.ValueRanges
Enter fullscreen mode
Exit fullscreen mode
It provides concrete, type-safe range types covering the same six value domains as PostgreSQL's built-in range types, with a full in-memory implementation of every range and multirange operation PostgreSQL exposes. It requires .NET 10 or later and has no dependency on Npgsql, EF Core, or any database driver.
The six supported types, mirroring PostgreSQL exactly:
.NET type
PostgreSQL equivalent
Element type
Discrete?
`Int32Range`
`int4range`
`int`
Yes
`Int64Range`
`int8range`
`long`
Yes
`DecimalRange`
`numrange`
`decimal`
—
`DateRange`
`daterange`
`DateOnly`
Yes
`DateTimeRange`
`tsrange`
`DateTime`
—
`DateTimeOffsetRange`
`tstzrange`
`DateTimeOffset`
—
The supported set is intentionally fixed. Extending it to arbitrary `T` — any struct that is `IComparable` and `IEquatable` — sounds appealing but breaks down in practice. `float` and `double` have NaN, infinities, and no reliable total order for interval arithmetic. `Guid` (even v7) is orderable but the ordering has no meaningful domain semantics for range algebra. The six types in this library correspond to what PostgreSQL itself natively understands and has validated for interval semantics — a deliberate, considered baseline rather than a maximally open generic system.
The Type System: Why Not `readonly record struct`?
This is one of the most deliberate design decisions in the library, and it is worth explaining properly.
A `readonly record struct` is an excellent choice for simple value objects: stack-allocated, copy-efficient, equality based on value. But a `struct` cannot be abstract, cannot have sealed derived types, and cannot participate in polymorphism. You cannot form a discriminated union out of structs in C#.
A range type has fundamentally different *shapes*:
- A **finite** range has both a `Start` and an `End`.
- An **unbounded-start** range has only an `End`.
- An **unbounded-end** range has only a `Start`.
- An **infinite** range has neither bound — it covers the entire domain.
- An **empty** range has nothing at all — it contains no values.
These are not different states of the same data layout. They are genuinely different types with different property surfaces. If you flatten them into a single struct, you end up with nullable bounds and boolean flags — which is exactly the design of `NpgsqlRange`. The caller has to check flags before accessing any property. Invalid states are not unrepresentable; they are just poorly documented.
`CodoMetis.ValueRanges` takes the opposite approach: **invalid states are unrepresentable by construction**. The shape of a range is encoded in its static type. An `UnboundedEnd` range has no `End` property — the property simply does not exist on that type. An `EmptyRange` carries no bound information at all. There is nothing to guard against and nothing to misread.
Each concrete range type is an **abstract record** with five sealed nested variants:
public abstract record DateRange : IRange, IRangeFactory { private DateRange() { } // no external subtypes possible
public sealed record EmptyRange : DateRange, IEmptyRange;
public sealed record Finite : DateRange, IFiniteRange { ... }
public sealed record UnboundedStart : DateRange, IUnboundedStartRange { ... }
public sealed record UnboundedEnd : DateRange, IUnboundedEndRange { ... }
public sealed record Infinity : DateRange, IInfinityRange;
}
Enter fullscreen mode
Exit fullscreen mode
The private constructor makes the hierarchy closed to the outside. Because no type outside the assembly can subclass `DateRange`, the set of possible variants is knowably complete — but C# does not yet exploit this to make switch expressions exhaustive without a default arm. Full native discriminated unions with compiler-enforced exhaustiveness are on the roadmap for C# 15 / .NET 11. Until then, omitting the default arm produces a compiler warning, not an error, so you still need to handle it explicitly — typically by throwing an UnreachableException to signal that any unmatched case is a programming error, not a legitimate runtime condition.
The abstract base is a **reference type (class)** because polymorphism — virtual dispatch, subtype relationships, sealed hierarchies — is a class-level concept in C#. `record` syntax gives you structural equality and `with`-expression support on top of that, which is ideal for immutable domain objects. The result is a discriminated union that is both idiomatic and compiler-enforced.
The cost is a heap allocation per range instance. For domain objects that model intervals in a business context, this is entirely acceptable. The performance-sensitive path — high-throughput range queries against large tables — runs in PostgreSQL, not in the domain layer.
The Interface Hierarchy
The library exposes precisely scoped interfaces, making it straightforward to write generic code that handles any range shape:
Interface
What it provides
`IRange`
Base marker for all variants
`IFiniteRange`
`Start`, `End`, `StartInclusive`, `EndInclusive`
`IUnboundedStartRange`
`End`, `EndInclusive`
`IUnboundedEndRange`
`Start`, `StartInclusive`
`IEmptyRange`
Marker only — no bounds at all
`IInfinityRange`
Marker only — the entire domain
`IRangeFactory`
Static abstract factories; `NextValueAfter`/`PreviousValueBefore`
`IRangeFactory` uses C# 11's static abstract interface members to require factory methods at the type level — `CreateFinite`, `CreateUnboundedStart`, `CreateUnboundedEnd`, `Empty`, `Infinite` — without any runtime dispatch overhead. This is also what allows the set operation extension methods to produce the correct concrete type generically, without reflection.
Creating Ranges
Every type exposes four static factory methods with conventions that mirror PostgreSQL:
// Fully closed — the natural default for discrete types Int32Range closed = Int32Range.CreateFinite(1, 10); // [1, 10]
// Half-open — the natural default for continuous types DecimalRange halfOpen = DecimalRange.CreateFinite(1m, 5m); // [1, 5)
// Unbounded on the left DateRange upTo = DateRange.CreateUnboundedStart( new DateOnly(2025, 12, 31)); // (-∞, 2025-12-31]
// Unbounded on the right Int32Range fromFive = Int32Range.CreateUnboundedEnd(5); // [5, +∞)
// The whole domain Int32Range everything = Int32Range.Infinite; // (-∞, +∞)
// Explicitly empty Int32Range empty = Int32Range.Empty; // ∅
Enter fullscreen mode
Exit fullscreen mode
`CreateFinite` never throws. Pass inverted or degenerate bounds and it returns `Empty`. For discrete types, exclusive bounds are normalised to their canonical closed-interval equivalent: `(1, 5)` over integers becomes `[2, 4]`, and `(1, 2)` becomes `Empty`. This means two `Int32Range.Finite` instances representing the same set of integers are always structurally equal — record equality coincides with set equality.
Pattern Matching
Because the hierarchy is sealed within the assembly, you know statically that only five variants can ever exist — but today's C# compiler does not yet enforce this as a completeness guarantee. Omitting a variant produces a warning and a default fallback is still required. The idiomatic pattern is a default arm that throws UnreachableException, which makes intent clear: any case that reaches it is a bug in the consuming code, not a legitimate path:
string Describe(DateRange range) => range switch { DateRange.EmptyRange => “no dates”, DateRange.Finite f => $“{f.Start:yyyy-MM-dd} to {f.End:yyyy-MM-dd}”, DateRange.UnboundedStart s => $“up to {s.End:yyyy-MM-dd}”, DateRange.UnboundedEnd e => $“from {e.Start:yyyy-MM-dd} onwards”, DateRange.Infinity => “all time”, _ => throw new UnreachableException() };
Enter fullscreen mode
Exit fullscreen mode
Full native discriminated unions with compiler-enforced exhaustiveness are planned for C# 15 / .NET 11. When that ships, the `_` arm disappears and the compiler takes over — but the type design of this library is already fully aligned with that future: sealed hierarchy, private constructor, five known variants. No structural changes will be needed.
Query Operations: Full Interval Algebra In Process
All query methods are extension methods on `IRange` and execute entirely in memory — no database, no exceptions:
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 alias
var a = Int32Range.CreateFinite(1, 5); var b = Int32Range.CreateFinite(5, 10); a.Overlaps(b); // true — they share the point 5
var c = Int32Range.CreateFinite(6, 10); a.IsAdjacentTo(c); // true — NextValueAfter(5) == 6
// PostgreSQL & equivalents Int32Range.CreateFinite(1, 5) .DoesNotExtendRightOf(Int32Range.CreateFinite(1, 10)); // true
Int32Range.CreateFinite(1, 3) .IsStrictlyLeftOf(Int32Range.CreateFinite(5, 9)); // true
Enter fullscreen mode
Exit fullscreen mode
Adjacency for discrete types deserves a mention: for integers and `DateOnly`, `[1, 5]` and `[6, 10]` are adjacent because there is no integer between 5 and 6. The step size is encoded via `NextValueAfter` and `PreviousValueBefore` in `IRangeFactory`. Continuous types use the complementary-inclusiveness rule: `[1.0, 5.0]` and `(5.0, 10.0]` are adjacent because one side claims 5.0 and the other does not.
Set Operations: Returning Exact Types
Set operations are extension methods constrained to types implementing `IRangeFactory`, so they return the concrete range type, not a base interface.
**Intersection** returns `TRange` directly. The intersection of two ranges is always expressible as a single range — possibly `Empty` — so the return type tells the truth:
var a = Int32Range.CreateFinite(1, 10); var b = Int32Range.CreateFinite(5, 15);
Int32Range intersection = a.Intersect(b); // [5, 10] Int32Range none = a.Intersect(Int32Range.CreateFinite(11, 20)); // Empty
Enter fullscreen mode
Exit fullscreen mode
**Union** and **Except** return `RangeSet`, because the result genuinely may be one or two disjoint ranges, and the type says exactly that:
var x = Int32Range.CreateFinite(1, 5); var y = Int32Range.CreateFinite(7, 10);
x.Union(y); // { [1, 5], [7, 10] } — 2 elements (disjoint) x.Union(Int32Range.CreateFinite(3, 8)); // { [1, 10] } — 1 element (overlapping, merged)
var big = Int32Range.CreateFinite(1, 10); var remove = Int32Range.CreateFinite(4, 6);
big.Except(remove); // { [1, 3], [7, 10] } — 2 elements (interior cut, split in two) // boundary inclusiveness is inverted at the cut point so no value is lost or double-counted
Enter fullscreen mode
Exit fullscreen mode
RangeSet: The In-Memory Multirange
`RangeSet` is the in-memory counterpart of PostgreSQL 14+'s multirange types. It is an **immutable, always-normalised** collection of disjoint ranges, maintaining the structural invariant on every construction: elements sorted by lower bound, pairwise disjoint, pairwise non-adjacent. Empty ranges are dropped, overlapping or adjacent inputs are merged, and any `Infinity` element collapses the whole set to `RangeSet.Infinite`.
using IntSet = RangeSet;
// Adjacent integers merge automatically on construction var set = IntSet.From([ Int32Range.CreateFinite(1, 5), Int32Range.CreateFinite(6, 10), // adjacent to [1, 5] → merges Int32Range.CreateFinite(20, 30) ]); // { [1, 10], [20, 30] }
// Full set algebra with operator aliases set | Int32Range.CreateFinite(11, 19); // { [1, 30] } bridges the gap set & Int32Range.CreateFinite(5, 25); // { [5, 10], [20, 25] } set - Int32Range.CreateFinite(4, 6); // { [1, 3], [7, 10], [20, 30] }
set.Complement(); // { (-∞, 0], [11, 19], [31, +∞) }
// Structural equality — normalised sets are equal regardless of how they were built var a = IntSet.From([Int32Range.CreateFinite(1, 10)]); var b = IntSet.From([Int32Range.CreateFinite(1, 5), Int32Range.CreateFinite(6, 10)]); a.Equals(b); // true
Enter fullscreen mode
Exit fullscreen mode
`RangeSet` implements `IReadOnlyList` — enumerable, countable, indexable — and `IEquatable>`.
PostgreSQL Wire Format and JSON: Zero Integration Friction
All six range types and their `RangeSet` counterparts fully support **parsing and formatting in the PostgreSQL range literal format** — the same textual representation PostgreSQL uses on the wire:
// Parse from PostgreSQL literal var range = DateRange.Parse(“[2025-01-06,2025-01-17]”, null);
// Format back to PostgreSQL literal range.ToString(); // “[2025-01-06,2025-01-17]”
// RangeSet in multirange literal format var set = RangeSet.Parse(”{[1,5],[7,10]}”, null); set.ToString(); // ”{[1,5],[7,10]}”
Enter fullscreen mode
Exit fullscreen mode
This means ranges round-trip cleanly through any system that speaks PostgreSQL literals — migrations, raw ADO.NET, logging, and diagnostics.
For JSON, the library ships `System.Text.Json` converters for every type in the `CodoMetis.ValueRanges.Serialization` namespace. Ranges serialise as JSON strings in the same PostgreSQL literal format — compact and round-trippable. Registration is one line:
// Standalone var options = new JsonSerializerOptions().AddRangeConverters();
// ASP.NET Core builder.Services.ConfigureHttpJsonOptions(o => o.SerializerOptions.AddRangeConverters());
Enter fullscreen mode
Exit fullscreen mode
Once registered, ranges and multiranges flow through ASP.NET Core endpoints, `System.Text.Json` serialisation, and any other ecosystem that understands JSON without any additional ceremony:
// In an ASP.NET Core endpoint, this just works: app.MapGet(“/bookings/{id}/period”, (int id) => { DateRange period = /* load from domain */; return Results.Ok(period); // serialises as “[2025-01-06,2025-01-17]” });
Enter fullscreen mode
Exit fullscreen mode
The EF Core Bridge: `CodoMetis.ValueRanges.EFCore.PostgreSQL`
The companion package closes the loop between domain model and database:
dotnet add package CodoMetis.ValueRanges.EFCore.PostgreSQL
Enter fullscreen mode
Exit fullscreen mode
It does **not** replace `NpgsqlRange`. It depends on it entirely. The mapping infrastructure converts each `CodoMetis` range type to and from `NpgsqlRange` at the EF Core type-mapping boundary — from there, Npgsql's own, well-tested translators emit the correct PostgreSQL operators in SQL.
Enabling it is one line:
options.UseNpgsql(connectionString, npgsql => npgsql.UseValueRanges());
Enter fullscreen mode
Exit fullscreen mode
After that, properties of the six range types and of `RangeSet` are mapped by convention, with no value converters, comparers, or column types to configure manually:
Property type
Column type
`Int32Range`
`int4range`
`RangeSet`
`int4multirange`
`DateRange`
`daterange`
`RangeSet`
`datemultirange`
… and so on for all six types
The full range algebra also translates from LINQ to SQL:
var day = new DateOnly(2025, 6, 15);
// Translates to: b.”Period” @> @day bookings.Where(b => b.Period.Contains(day));
// Translates to: b.”Period” && @other bookings.Where(b => b.Period.Overlaps(other));
// Translates to: b.”Period” -|- @other bookings.Where(b => b.Period.IsAdjacentTo(other));
// Translates to: b.”Period” * @other bookings.Select(b => b.Period.Intersect(other));
Enter fullscreen mode
Exit fullscreen mode
The same method calls that run in memory in your domain layer translate to their exact PostgreSQL operator equivalents when used inside an EF Core query. The same code, the same semantics, two different execution environments.
This is the division of labour in practice: `NpgsqlRange` handles the wire protocol and SQL translation — exactly what it was built for. `CodoMetis.ValueRanges` handles the domain logic — exactly what it was built for. The EF Core package is the thin bridge between them.
Putting It Together: A Domain Example
Here is what it looks like in a real domain model. The domain layer has no awareness of Npgsql or EF Core:
// Domain — pure, testable, database-independent public sealed class ShiftAssignment { public EmployeeId EmployeeId { get; } public DateRange Period { get; }
public ShiftAssignment(EmployeeId employeeId, DateRange period)
{
// Pattern matching — the type communicates intent; the compiler enforces completeness
if (period is DateRange.EmptyRange)
throw new DomainException("A shift must cover at least one day.");
EmployeeId = employeeId;
Period = period;
}
public bool ConflictsWith(ShiftAssignment other)
=> Period.Overlaps(other.Period); // in-memory, no database, always works
public bool IsContiguousWith(ShiftAssignment other)
=> Period.IsAdjacentTo(other.Period) || Period.Overlaps(other.Period);
}
// Domain service — fully unit-testable without a database public static DateRange FindUncoveredPeriod( DateRange requiredWindow, IReadOnlyList assignments) { var covered = RangeSet.From( assignments.Select(a => a.Period));
var uncoveredSet = requiredWindow.Except(covered);
// Returns the part of the required window not yet covered by any assignment
return uncoveredSet.Count == 0
? DateRange.Empty
: uncoveredSet[0];
}
Enter fullscreen mode
Exit fullscreen mode
The EF Core package maps `ShiftAssignment.Period` to a `daterange` column. LINQ queries against it translate to PostgreSQL range operators. Domain logic runs identically in unit tests (no database, no Npgsql, no mocking) and in production (full PostgreSQL), with the same types and the same semantics throughout.
Source Code and Packages
Both packages are fully open source under the MIT licence:
📦 **[CodoMetis.ValueRanges on NuGet](https://www.nuget.org/packages/CodoMetis.ValueRanges)**
📦 **[CodoMetis.ValueRanges.EFCore.PostgreSQL on NuGet](https://www.nuget.org/packages/CodoMetis.ValueRanges.EFCore.PostgreSQL)**
🔭 **[Source code on GitHub](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges)**
Summary
The two-column pattern — separate `StartDate` and `EndDate` fields — is one of the most common sources of duplicated logic and subtle bugs in domain models that deal with intervals. PostgreSQL has had a better answer for over a decade in its native range types, but until now there has been no way to bring that expressiveness into the .NET domain layer without importing a database driver.
`CodoMetis.ValueRanges` fills that gap:
- A **discriminated union per range type** with five sealed variants whose shapes are encoded in their static types — making invalid states unrepresentable by construction.
**Exhaustive, compiler-enforced pattern matching** over all five variants.
**Full interval algebra in process** — containment, overlap, adjacency, directional comparisons, intersection, union, difference — all executing in memory, no database dependency.
**`RangeSet`**, the in-memory multirange, with structural normalisation, set operators, complement, and `IReadOnlyList` semantics.
**PostgreSQL wire format** parsing and formatting, and **`System.Text.Json` serialisation** out of the box, making ranges first-class citizens in ASP.NET Core endpoints and any JSON-speaking ecosystem.
**An EF Core / PostgreSQL companion package** that bridges domain types to `NpgsqlRange` at the mapping boundary and translates the full range algebra from LINQ to SQL.
**Abstract record base types** — deliberate reference types, not structs — because the polymorphic, pattern-matchable discriminated union this design requires is a class-level concept in C#.
If you work with intervals in your .NET domain model, give it a try. If it solves a problem you have been working around for a while, I would love to hear about it in the comments.
And if you find the library useful, a ⭐ on [GitHub](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges) goes a long way — it helps others find it, and it means a great deal to an independent open-source author. Feedback, issues, and ideas are equally welcome.