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.
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.
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
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;
}
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.
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; }
}
Edge cases
Three cases separate a correct range utility from a naive one.
Gotchas & common pitfalls
Testing checklist
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.