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.

Half-open ranges tile without gaps or overlapsClosed ranges double-count the shared endpointHalf-open ranges tile without gaps or overlaps[Mon, Wed] then [Wed, Fri]Wed counted twice[Mon, Wed) then [Wed, Fri)each day counted onceModel ranges as half-open [start, end) so consecutive ranges tile the timeline exactly.

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.

Range primitivesThe methods a range utility is built fromRange primitivesTemporal.PlainDate.compare(a,b)-1 / 0 / 1 total order for containmentstart.until(end,{largestUnit})length of the interval as a Durationdate.equals(other)exact endpoint equalityMath.max/min on epochMsclamp an instant into a range

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

Legacy range containmentNumeric epoch comparison works for instants, not civil daysLegacyModernt >= start && t < endepoch ms comparisontime-of-day leaks incivil days drift by zonePlainDate.compare in [start,end)civil, zone-freea day is always a dayDST-proof

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;
}

Civil-range containmentTwo compares express a half-open interval exactlyCivil-range containmentprobe datecompare vsstart & endin [start,end)

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; }
}

A validated interval typeReject inverted ranges once, then query cheaplyA validated interval typestart, endvalidate ordercontains /days / overlapsafe queries

Edge cases

Three cases separate a correct range utility from a naive one.

Range edge casesEmpty ranges, DST-spanning ranges, and open-ended rangesRange edge casesEmpty rangestart == endcontains nothingSpans DSTone day is 23huse PlainDateOpen-endednull end = ongoingtreat as +infinity

Gotchas & common pitfalls

Range pitfallsClosed intervals and instant/civil mixingWrongRightinclusive [start, end]adjacent ranges overlaphalf-open [start, end)ranges tile cleanlymix Date and PlainDatetime-of-day skews itone type for both endscivil stays civil

Testing checklist

Range test matrixBoundary and ordering cases assert correctlyAssertions that prove the edge casecontains(start)true (inclusive)contains(end)false (exclusive)inverted rangethrowsdays across DSTwhole days

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.