Working with Date Ranges and Intervals in JavaScript
How to model a date range so containment, length, and overlap stay correct across timezones and DST. Part of JavaScript Date Fundamentals.
Problem framing
A date range is a pair of endpoints, but the moment you compute with it you face three questions the endpoints alone do not answer: is the range half-open or closed, are the endpoints instants or civil dates, and what happens when a DST transition falls inside it. Get the first wrong and adjacent ranges either double-count or leave a gap; get the second wrong and a range that should span three calendar days spans two because one day was only 23 hours. Every range utility on this page treats the endpoints as an explicit [start, end) half-open interval and keeps civil ranges in Temporal.PlainDate so a day is always a day.
Ranges are everywhere in application code, and each context sharpens a different one of those three questions. A hotel booking engine asks whether a requested stay overlaps any existing reservation, and it must treat check-out day as the exclusive end so one guest's departure and the next guest's arrival can share a date without registering as a conflict. A subscription billing system slices an account's lifetime into consecutive billing periods that must tile perfectly — a one-day gap means a day of unbilled usage, and a one-day overlap means a day billed twice. An analytics dashboard lets a user pick "last 30 days" and then buckets events into that window; if the window boundaries are computed as instants in the wrong zone, events near midnight land in the wrong day and every chart is subtly off. In all three the range itself is trivial to store — two dates — and every bug lives in the arithmetic around the boundaries.
The single decision that removes most of that arithmetic is to standardise on the half-open interval [start, end): the start is included, the end is excluded. Half-open is not an aesthetic preference; it is the convention that makes ranges composable. Two half-open ranges that meet at a point tile without overlap, the length of [start, end) is exactly end − start with no off-by-one correction, and an empty range is simply one where start equals end. The rest of this guide builds containment, length, overlap, and clamping on top of that one convention, and keeps every civil range in Temporal.PlainDate so that "three days" always means three calendar days regardless of what daylight saving did in the middle.
API reference
The building blocks are comparison and difference, not new range types. Temporal.PlainDate.compare and Temporal.Instant.compare give a total order for containment tests, and .until() yields the Temporal.Duration between endpoints. Temporal deliberately ships no Interval type, and that is a feature: a range is just two values plus a convention, and once you fix the half-open convention every operation reduces to two static compare calls. Reaching for a heavyweight range library is almost always unnecessary.
The one method that repays study is compare, because it is a static function, not an instance method. You call Temporal.PlainDate.compare(a, b) and it returns -1, 0, or 1 — negative when a is earlier, zero when they are the same calendar date, positive when a is later. That three-valued result is exactly what a containment or overlap predicate needs, and because it is a total order (every pair of dates is comparable and ties are well defined) you never hit the ambiguity that plagues floating-point or string comparison. For measuring a range's length, start.until(end, { largestUnit: 'day' }) returns a signed Duration; read its .days for a plain count, or ask for { largestUnit: 'month' } to get a balanced "2 months, 5 days" answer when you need calendar-aware spans rather than a raw day tally.
Approach A: legacy Date
With legacy Date, a range is two millisecond counts and containment is numeric comparison. It works for absolute instants but silently breaks for civil ranges because a Date carries a time-of-day and a host zone.
// Legacy: numeric containment on epoch milliseconds.
const start = new Date('2024-03-01T00:00:00Z').getTime();
const end = new Date('2024-03-31T00:00:00Z').getTime();
const t = new Date('2024-03-15T12:00:00Z').getTime();
const inRange = t >= start && t < end; // half-open: end excluded
For a range of genuine instants — "was this log entry written during the incident window" — that code is correct and fast, because epoch milliseconds are a total order and >=/< express the half-open interval directly. The trouble starts the moment the range is meant to be a range of calendar dates. Write new Date('2024-03-01') and you get midnight UTC; write new Date('2024-03-01T00:00') and you get midnight in the host zone, which is a different instant. Now a "March" range built on one machine does not line up with the same range built on another, and a date that a user thinks of as "March 15" can fall outside it because the endpoints were anchored to the wrong midnight.
The deeper problem is that Date has no way to say "just a date, no time." Every Date is an instant, so a civil range is really a range of instants with a time-of-day and a zone smuggled in. If you compare a probe date that has a non-midnight time against an endpoint pinned to midnight, the time-of-day tips the comparison and a date that should be "in the range" reads as before or after it. You can paper over this by zeroing the time on every value and pinning everything to UTC, but that discipline has to be applied at every call site, and the first place it is forgotten is the first place the bug appears. The modern approach removes the failure mode at the type level rather than by convention.
Approach B: Temporal / Intl
For civil ranges, keep both endpoints and the probe as Temporal.PlainDate. Containment becomes two compare calls, and the half-open convention is explicit in the operators.
import { Temporal } from '@js-temporal/polyfill';
function contains(start: Temporal.PlainDate, end: Temporal.PlainDate, d: Temporal.PlainDate) {
// [start, end): start inclusive, end exclusive
return Temporal.PlainDate.compare(d, start) >= 0 && Temporal.PlainDate.compare(d, end) < 0;
}
Because PlainDate has no time and no zone, this function cannot be fooled by a stray time-of-day and cannot disagree between a server and a browser. compare(d, start) >= 0 says "d is start or later" (start inclusive) and compare(d, end) < 0 says "d is strictly before end" (end exclusive) — the two halves of [start, end) written literally. Swap the operators and you get the other three interval flavours: > 0 && < 0 is the fully open (start, end), >= 0 && <= 0 is the fully closed [start, end], and so on. Making the convention visible in the comparison operators is exactly what stops the next reader from guessing.
The same shape scales to the derived predicates. "Ends on or before" is one compare; "the later of two starts" is a compare feeding a ternary; "do these two ranges overlap" is the pair of comparisons covered in depth on check if two date ranges overlap. When you genuinely need an instant range — a support shift that starts 09:00 in one zone and ends 17:00 in another — use Temporal.Instant.compare or Temporal.ZonedDateTime.compare instead, and the identical predicate structure carries over unchanged. That uniformity across PlainDate, Instant, and ZonedDateTime is the reason a single generic interval type can serve every range in an application.
Production implementation
A reusable interval type validates ordering up front and exposes containment, length, and overlap. Keeping it generic over PlainDate/ZonedDateTime means the same logic serves civil and absolute ranges. Validating at construction is the key discipline: an inverted range where start is after end is almost always a bug in the caller, and catching it in the constructor turns a silent wrong answer deep inside a query into a loud error at the point the mistake was made.
import { Temporal } from '@js-temporal/polyfill';
export class DateInterval {
constructor(readonly start: Temporal.PlainDate, readonly end: Temporal.PlainDate) {
// Reject inverted ranges at construction, not deep in a query.
if (Temporal.PlainDate.compare(start, end) > 0) throw new RangeError('start after end');
}
contains(d: Temporal.PlainDate) {
return Temporal.PlainDate.compare(d, this.start) >= 0 && Temporal.PlainDate.compare(d, this.end) < 0;
}
get days() { return this.start.until(this.end, { largestUnit: 'day' }).days; }
}
From that base the two most-requested operations — intersection and gap — are a few more comparisons. The intersection of two ranges is the later of the two starts and the earlier of the two ends; if that produces start >= end, the ranges do not actually overlap and the intersection is empty. The gap between two disjoint ranges is the mirror image: the earlier range's end up to the later range's start. Both are pure functions of the endpoints, so they stay correct as long as the inputs are valid.
import { Temporal } from '@js-temporal/polyfill';
const cmp = Temporal.PlainDate.compare;
function intersection(a: DateInterval, b: DateInterval): DateInterval | null {
const start = cmp(a.start, b.start) >= 0 ? a.start : b.start; // later of the two starts
const end = cmp(a.end, b.end) <= 0 ? a.end : b.end; // earlier of the two ends
return cmp(start, end) < 0 ? new DateInterval(start, end) : null; // empty => no overlap
}
For SSR and serverless the payoff of keeping everything in PlainDate is that there is no host-zone dependency to leak: the same interval computes identically on an edge worker in one region and a browser in another, because a civil date carries no offset to disagree about. When a range must be persisted or sent over the wire, store each endpoint as its ISO toString() ('2024-03-01') and rehydrate with PlainDate.from(); never round-trip a range through a timestamptz column, which would re-attach a time and a zone and reintroduce exactly the ambiguity this type exists to avoid. If a range genuinely represents a window of instants rather than civil dates, swap the endpoint type to Temporal.Instant and store the endpoints as UTC ISO strings — the class body is unchanged because it only ever calls the static compare for that type.
Edge cases
Three cases separate a correct range utility from a naive one, and each has a concrete failure that shows up in production if it is not handled deliberately.
The empty range. When start equals end, a half-open [start, end) contains nothing — there is no date that is both >= start and < end. This is the correct and useful behaviour: an empty range is what you get when you intersect two ranges that only touch, and code that iterates or sums over ranges should treat it as a no-op rather than a special case. The bug to avoid is a closed-interval mindset that treats start == end as "one day," which then double-counts the boundary when ranges are chained.
The DST-spanning range. A civil range from March 7 to March 11 spans four calendar days even though, in a zone that springs forward in that window, one of those days is only 23 real hours. This is precisely why the endpoints are PlainDate and not Instant: start.until(end, { largestUnit: 'day' }).days counts calendar days and returns 4, whereas subtracting two instants and dividing by 86,400,000 returns a fractional 3.96 and rounds wrong. If you ever need the elapsed time of the range rather than its day count, convert the endpoints to instants in a specific zone at that point — but keep the range itself civil.
The open-ended range. A subscription that has started but not ended, or a "from January onward" filter, has one endpoint missing. Model the absent endpoint as null and treat it as negative or positive infinity in every comparison: a null start means "contains every date up to end," a null end means "contains every date from start onward." A range with both endpoints null is the whole timeline. Handling this in the containment predicate is a two-line guard, and it saves you from the alternative of inventing sentinel dates like 9999-12-31, which eventually collide with real data.
How two date ranges can relate
Before writing an overlap or merge routine it helps to name the arrangements two ranges can be in, because most range bugs are really one arrangement handled as if it were another. In interval algebra there are a handful of core relations, and under the half-open convention they collapse to clean comparisons of the four endpoints. Range A is entirely before B when A's end is at or before B's start; the mirror case is after. A meets B when A's end equals B's start — the ranges touch but, because the end is exclusive, share no day and therefore do not overlap. A overlaps B when A starts before B ends and B starts before A ends. One range is during (contained by) the other when its start is at or after and its end is at or before the outer range's. And the two are equal when both endpoints match.
Naming these makes the predicates self-documenting. "Do these bookings conflict?" is the overlap relation; "is this date inside the promotion?" is containment of a point, which is the same as a zero-length range being during the promotion; "can these two shifts be joined into one?" is meets or overlaps. Every one of them is a combination of Temporal.PlainDate.compare results, so once you can read the four endpoint comparisons you can express any relation without a library.
Merging a list of overlapping ranges
A frequent real-world task is normalising a messy list of ranges — availability blocks, reservations, log windows — into the smallest set of non-overlapping ranges that covers the same days. The standard algorithm is sort-then-sweep: sort the ranges by start, then walk them keeping a single "current" merged range, extending it whenever the next range starts on or before the current end, and emitting it and starting fresh whenever the next range starts after a gap. It runs in the time it takes to sort, and it is the engine behind free/busy calendars and de-duplicated date filters.
import { Temporal } from '@js-temporal/polyfill';
const cmp = Temporal.PlainDate.compare;
function mergeRanges(ranges: DateInterval[]): DateInterval[] {
if (ranges.length === 0) return [];
// Sort by start; ties by end keep the sweep deterministic.
const sorted = [...ranges].sort((a, b) => cmp(a.start, b.start) || cmp(a.end, b.end));
const out: DateInterval[] = [sorted[0]];
for (const r of sorted.slice(1)) {
const cur = out[out.length - 1];
// Overlaps OR touches (start <= cur.end) => extend; else start a new run.
if (cmp(r.start, cur.end) <= 0) {
if (cmp(r.end, cur.end) > 0) out[out.length - 1] = new DateInterval(cur.start, r.end);
} else {
out.push(r);
}
}
return out;
}
Inverting the same sweep yields the gaps — the free slots between busy ranges — which is how you answer "when is everyone available?" from a set of individual calendars: merge each person's busy ranges, then walk the merged list and emit the space between consecutive ranges. Two details make the gap version robust. First, clamp the walk to an outer window (a working day, a bookable horizon) so the first gap runs from the window start to the first busy range and the last gap runs from the last busy range to the window end. Second, decide up front whether a zero-length gap — two busy ranges that meet exactly — should be emitted; under the half-open convention it should not, because there is no free day between ranges that touch. Both the merge and the gap sweep are single passes after the sort, so even a calendar with thousands of entries normalises in the blink of an eye.
Gotchas & common pitfalls
The recurring range bugs all come from mixing conventions or mixing types. Each of the following has a one-line fix once you have named it.
- Inclusive-end double counting. Modelling a range as the closed
[start, end]makes consecutive ranges share their boundary day, so a report that sums "this week" and "next week" counts the shared Sunday twice. Fix: use half-open[start, end)everywhere and let the end be exclusive. - Mixing
DateandPlainDateendpoints. A range built fromDateinstants compared against aPlainDateprobe forces a coercion that drags in a time-of-day and a host zone. Fix: pick one type for the endpoints and the probe, and keep civil ranges inPlainDate. - Fractional-day length from instant subtraction. Computing a range's length as
(endMs − startMs) / 86_400_000returns a fraction whenever a DST transition falls inside it. Fix: measure withstart.until(end, { largestUnit: 'day' }).daysonPlainDateendpoints. - Sentinel dates for open ranges. Using
9999-12-31or0000-01-01to mean "no end" works until a real record sorts next to the sentinel or a formatter chokes on it. Fix: model the missing endpoint asnulland treat it as infinity in comparisons. - Unvalidated inverted ranges. Accepting a range where
startis afterendproduces an interval that contains nothing and silently drops data from every query. Fix: validate ordering at construction and throw, so the error surfaces at the caller.
Testing checklist
Range logic is boundary logic, so the tests that matter are the ones that hit the endpoints exactly. Assert that contains(start) is true (the start is included), that contains(end) is false (the end is excluded), and that a date one day before the end is still inside — those three pin the half-open convention so a later refactor cannot quietly flip it to closed. Add an inverted range to confirm the constructor throws, an empty range (start == end) to confirm it contains nothing, and two touching ranges to confirm they report no overlap. Because the interesting behaviour is DST-independent by construction, one property worth checking is that a range's day count is invariant across host zones: run the length assertion under several TZ values and confirm the number never changes, which proves the civil arithmetic never leaked an instant.
# A civil range's day count must be identical in every host zone.
for TZ in UTC America/New_York Asia/Kolkata Pacific/Auckland; do TZ=$TZ npx jest date-range; done
Frequently Asked Questions
Should date ranges be inclusive or exclusive of the end?
Prefer half-open ranges: start inclusive, end exclusive, written [start, end). Half-open ranges tile the timeline without gaps or double-counting, so [Mon, Wed) followed by [Wed, Fri) counts each day exactly once. Closed ranges force you to add or subtract a day at every boundary.
How do I keep a date range correct across a DST change?
Model civil ranges with Temporal.PlainDate rather than Date or ZonedDateTime. A PlainDate range measures calendar days, so a range that crosses a spring-forward boundary still spans the right number of days even though one real day was only 23 hours long.
How do I represent an open-ended range?
Use null for the missing endpoint and treat it as negative or positive infinity in comparisons. A subscription with a null end date is ongoing, so contains() returns true for any date at or after the start.
Why does Temporal not have a built-in Interval or Range type?
Because a range is fully described by two comparable values plus a convention, and Temporal supplies the comparable values and the comparison. Once you fix the half-open convention, containment, length, overlap, intersection, and clamping are each one or two static compare calls, so a dedicated type would add surface area without adding capability. A thin application-level interval class that validates ordering and wraps those calls is all most codebases need.
How do I compute the intersection or union of two date ranges?
The intersection is the later of the two starts and the earlier of the two ends; if that start is not strictly before that end, the ranges do not overlap and the intersection is empty. A union only forms a single range when the two ranges overlap or touch — otherwise it is two disjoint ranges — so a union function should return an array and merge only when the earlier range's end is at or after the later range's start.