Ranges
A range is a span of values between a start and an end, such as a
booking's hours or a band of cake sizes. Range<T> holds a start and
an end of one ordered type, and either end may be none for an open
end. Two standard ranges say once, in their type, whether they hold
their end: a DateRange includes its end date, and an InstantRange
excludes its end instant.
A range's start is never after its end, a rule named
start_not_after_end. So a range built from values is proved by a
condition or built under attempt, as an object's rule across its
fields is (Rules across a type's parts).
Ranges are stored as PostgreSQL's range types (daterange,
tstzrange, numrange, and int8range for whole numbers and
durations), so their SQL forms are the range operators. A range that
holds nothing, because its start is its end, isn't stored: PostgreSQL
keeps every such range as empty, without its ends, so storing one is a
fault rather than losing where it was.
T must be a type < orders: one built on Int, Decimal, Instant,
CalendarDate or Duration. Text isn't one, because < doesn't take
it. Any other type is type.ordering_operand, reported where the type is
written or where a literal's ends give it. Range with no type argument
is type.type_argument_count.
A Range<T> holds its start and not its end, as InstantRange does and
as PostgreSQL's canonical ranges do. An open end holds everything on its
side, so a range with two open ends holds every value. Two ranges that
only meet, one's end being the other's start, don't overlap. A range
whose start is its end holds nothing, so range.overlaps is false and
range.intersection is none for it, as PostgreSQL's && and
NULLIF(a * b, 'empty') give.
| Operation | Gives | Fails | SQL form |
|---|---|---|---|
range.contains(range, value) |
Bool: value is inside, by the range's own end rule |
x @> a |
|
range.overlaps(a, b) |
Bool: the two share at least one value |
a && b |
|
range.intersection(a, b) |
Range<T>?, the shared part; none when they don't overlap |
NULLIF(a * b, 'empty') |
The three operations take a Range<T>, an InstantRange or a
DateRange, which holds its end date. The ranges in one call are all of
one range type, since each type keeps its own rule for its end: an
InstantRange and a Range<Instant> don't mix (type.mismatch). A
range an operation gives is of that same type, so range.intersection
of two InstantRanges is an InstantRange?. range.contains takes a
value built on the range's item type.
An InstantRange written as an object literal has its two fields
checked as an object's fields declared Instant are, so a value of a
named instant type, such as Moment, is a mismatch there. A value of the
type, or a field declared InstantRange, has its start and end read
through it (booking.slot.start). An InstantRange holds every instant
from its start up to, but not including, its end. So two bookings that
meet at the hour don't overlap, and one that starts and ends at the same
instant holds nothing:
type Booking = Object { bench: BenchName slot: InstantRange}type BenchName = Text { min_length: 1}type BookingTime = Instant {}type Booked = Bool {}type DoubleBooked = Bool {}/// Whether the bench is booked at `at`.function booked_at(booking: Booking, at: BookingTime) -> Booked { return Booked(range.contains(booking.slot, at))}/// Whether two bookings of one bench double_booked.function double_booked(first: Booking, second: Booking) -> DoubleBooked { return DoubleBooked(first.bench == second.bench and range.overlaps(first.slot, second.slot))}/// The time two bookings share, if any.function common_time(first: Booking, second: Booking) -> InstantRange? { return range.intersection(first.slot, second.slot)}
test "ranges include their start and exclude their end" { var morning: Booking = Booking { bench: "Oven bench" slot: InstantRange { start: "2026-10-26T09:00:00Z" end: "2026-10-26T12:00:00Z" } } var lunch: Booking = Booking { bench: "Oven bench" slot: InstantRange { start: "2026-10-26T11:00:00Z" end: "2026-10-26T13:00:00Z" } } var afternoon: Booking = Booking { bench: "Oven bench" slot: InstantRange { start: "2026-10-26T12:00:00Z" end: "2026-10-26T17:00:00Z" } } var nothing: Booking = Booking { bench: "Oven bench" slot: InstantRange { start: "2026-10-26T10:00:00Z" end: "2026-10-26T10:00:00Z" } } var lunch_overlap: InstantRange = InstantRange { start: "2026-10-26T11:00:00Z" end: "2026-10-26T12:00:00Z" } assert booked_at(morning, "2026-10-26T09:00:00Z") assert not booked_at(morning, "2026-10-26T12:00:00Z") assert double_booked(morning, lunch) assert not double_booked(morning, afternoon) // they meet at noon assert not double_booked(morning, nothing) assert common_time(morning, lunch) == lunch_overlap assert common_time(morning, afternoon) == none}
Write a Range<T> as Range { start: ..., end: ... }, giving both
ends, with none for an open one. Where a Range<T> is expected, its
ends are checked as T?. Elsewhere, its first end that isn't none
gives T. Read its ends as range.start and range.end, each a T?.
- Text. A range's canonical text is each end's canonical text joined
by
/, with an open end written.., as ISO 8601-2 writes one (2026-10-01/..). A start after its end breaksstart_not_after_end. - JSON is an object of
startandend, withnullfor an open end. - For people,
text.formatshows each end as its item type is shown, with the item type's options, joined by–, and an open end as…. A range of numbers shows each end as a number is shown (Numbers) and takes no options. - Reading text.
text.parse(raw, as: Range<T>)reads a range of a standard type, or of a representation written as itself (Range<Int>,Range<CalendarDate>). A range of a type with rules of its own, such asRange<LoafWeight>, istype.mismatch, since nothing would check those rules on its ends. - No order, no shape. A
Range<T>isn't ordered.type.standard_exists, the error for an app type with a standard type's shape, matches no shape for it, sinceInstantRangealready claims a start and an end.
Bands of cake sizes in slices, one with no upper limit:
type SliceCount = Int { min: 0}type CakeName = Text { min_length: 1}type Cake = Object { name: CakeName slices: SliceCount}type InBand = Bool {}/// The cakes sold as large: 16 slices and up, with no upper limit.function large() -> Range<SliceCount> { return Range { start: 16 end: none }}/// Whether a cake's slice count is in `band`.function in_band(cake: Cake, band: Range<SliceCount>) -> InBand { return InBand(range.contains(band, cake.slices))}/// The slice counts two bands share, if any.function common_weights(first: Range<SliceCount>, second: Range<SliceCount>) -> Range<SliceCount>? { return range.intersection(first, second)}
test "a range of any ordered type, either end open" { var medium: Range<SliceCount> = Range { start: 8 end: 16 } var small: Range<SliceCount> = Range { start: none end: 10 } var both: Range<SliceCount> = Range { start: 8 end: 10 } var rye: Cake = Cake { name: "Rye" slices: 16 } assert in_band(rye, large()) assert not in_band(rye, medium) // a range doesn't hold its end assert common_weights(medium, large()) == none // they meet at 16 assert common_weights(medium, small) == both assert range.overlaps(small, medium) assert text.from(large()) == "16/.."}
Markdown for agents: /docs/ranges.md.Built from Jadpo main at a707efb.