Skip to content

Interval Operations

  • The core functions, which every industry uses.

GMT has two families of interval functions. The half-open interval/ module (intervalsOverlap, intersectIntervals, subtractIntervals, sumIntervals, …) takes { start, end } records of instant strings. The positional functions in plain/, utc/, zoned/ and unix/ take separate strings. Both families read an interval as half-open [start, end). Every output shown here is what the function returns.

HIGH

Passing the last day of a period as a Date interval's end

A positional Date interval is half-open like every other interval: end is the first day after the period, not the last day in it. An end of 2024-06-30 leaves 30 June out, so it does not touch an interval starting on 1 July. Add one day to the last day.

Wrong

intervalAbutsDate("2024-01-01", "2024-06-30", "2024-07-01", "2024-12-31");
// expected true — actually false: 30 June lies in neither interval

Right

import { addDate, intervalAbutsDate } from "@northguild/gmt";
const end = addDate("2024-06-30", { days: 1 }); // "2024-07-01"
intervalAbutsDate("2024-01-01", end, "2024-07-01", "2025-01-01"); // true
HIGH

Expecting touching intervals to overlap

Both interval families are half-open: an interval's end is not inside it, so two intervals that touch share no instant. They abut, their intersection is null, and their union joins them.

Wrong

intervalsOverlapUtc("2024-01-01T09:00:00Z", "2024-01-01T17:00:00Z", "2024-01-01T17:00:00Z", "2024-01-01T18:00:00Z");
// expected true — actually false

Right

import { intervalAbutsUtc } from "@northguild/gmt";
intervalAbutsUtc("2024-01-01T09:00:00Z", "2024-01-01T17:00:00Z", "2024-01-01T17:00:00Z", "2024-01-01T18:00:00Z");
// true — the first ends where the second starts
MEDIUM

Stepping one unit around a cut by hand

intervalDifference* and intervalXor* cut exactly at the removal's start and end, as subtractIntervals does. Adding or subtracting a nanosecond (or a day, or an epoch unit) to the pieces leaves a gap that sumIntervals counts as missing time.

Wrong

// The pieces already meet the lunch break exactly; do not nudge them
const morningEnd = "2024-01-01T11:59:59.999999999Z";

Right

import { intervalDifferenceUtc } from "@northguild/gmt";
intervalDifferenceUtc("2024-01-01T09:00:00Z", "2024-01-01T17:00:00Z", "2024-01-01T12:00:00Z", "2024-01-01T13:00:00Z");
// [{ start: "2024-01-01T09:00:00Z", end: "2024-01-01T12:00:00Z" },
// { start: "2024-01-01T13:00:00Z", end: "2024-01-01T17:00:00Z" }]
HIGH

Passing plain dates or zoneless datetimes to interval/

interval/ endpoints are instants: an offset (Z or ±HH:MM) is required. A plain date or a local datetime has no position on the timeline until a zone is named, so every function returns its sentinel for it.

Wrong

intervalsOverlap({ start: "2024-01-01", end: "2024-01-02" }, { start: "2024-01-01T12:00:00", end: "2024-01-03T00:00:00" });
// false — both are invalid input, not a real answer

Right

import { floorToZone, intervalsOverlap } from "@northguild/gmt";
// Build local-day edges as instants first
const day = {
start: floorToZone("2024-01-01T12:00:00Z", "day", "America/New_York"), // "2024-01-01T05:00:00Z"
end: floorToZone("2024-01-02T12:00:00Z", "day", "America/New_York"), // "2024-01-02T05:00:00Z"
};
MEDIUM

Treating [] from mergeIntervals or subtractIntervals as an error

[] is a legitimate result — a fully covered subtraction, or a list of only empty intervals — and it is also the invalid-input sentinel. Check the inputs with isValidInterval when the difference matters.

Wrong

const left = subtractIntervals(shift, removals);
if (left.length === 0) throw new Error("bad input"); // wrong when the removals cover the shift

Right

import { isValidInterval, subtractIntervals } from "@northguild/gmt";
if (!isValidInterval(shift) || !removals.every(isValidInterval)) {
// genuinely invalid input
}
const left = subtractIntervals(shift, removals); // [] now means fully covered
MEDIUM

Expecting sumIntervals to return days

sumIntervals returns covered time with hours as the largest unit — exactly Temporal.Instant.prototype.until with largestUnit hour. An instant has no calendar, so a day is not a fixed length and is never used.

Wrong

sumIntervals([{ start: "2024-01-01T00:00:00Z", end: "2024-01-03T01:30:00Z" }]);
// expected "P2DT1H30M"

Right

sumIntervals([{ start: "2024-01-01T00:00:00Z", end: "2024-01-03T01:30:00Z" }]); // "PT49H30M"
sumIntervals([{ start: "2024-03-10T05:00:00Z", end: "2024-03-11T04:00:00Z" }]); // "PT23H" — New York's spring-forward day
sumIntervals([]); // "PT0S"
MEDIUM

Expecting splitIntervalByUnit slices to be equal

splitIntervalByUnitDate cuts fixed amount × unit steps from the start, so the final slice is whatever remains and can be shorter. For n pieces of equal length, use intervalDivideEquallyDate.

Wrong

splitIntervalByUnitDate("2024-03-15", "2024-03-20", "day", 2);
// [{ start: "2024-03-15", end: "2024-03-17" }, { start: "2024-03-17", end: "2024-03-19" },
// { start: "2024-03-19", end: "2024-03-20" }] — the last slice is 1 day

Right

intervalDivideEquallyDate("2024-03-15", "2024-03-20", 2);
// [{ start: "2024-03-15", end: "2024-03-18" }, { start: "2024-03-18", end: "2024-03-20" }]
HIGH

Using intervalCount where you meant a length

intervalCount* counts the calendar units the half-open interval touches; intervalLength* measures how long it is. They agree only when the interval starts on a unit boundary and is a whole number of units long.

Wrong

intervalCountDateTime("2024-01-01T23:59:00", "2024-01-02T00:01:00", "day");
// 2 — used as a duration, but the interval is two minutes long

Right

import { intervalLengthDateTime } from "@northguild/gmt";
intervalLengthDateTime("2024-01-01T23:59:00", "2024-01-02T00:01:00", "day");
// 0.001388888888888889
MEDIUM

Expecting a zero-length interval to count the unit it sits in

intervalCount* counts the units the half-open [start, end) touches. A zero-length interval holds no instant, so it counts 0 in every unit, even mid-month. Before 1.16.0 it counted 1 when it sat mid-unit.

Wrong

intervalCountDate("2024-01-15", "2024-01-15", "month"); // expecting 1

Right

intervalCountDate("2024-01-15", "2024-01-15", "month"); // 0 — an empty interval touches no month
MEDIUM

Passing an options object to intervalFromDuration*

intervalFromDuration* takes the value, an ISO 8601 duration and an anchor of start or end. Calendar units resolve against the value itself — there is no relativeTo option. A negative duration returns null rather than an inverted interval.

Wrong

intervalFromDurationDate("2024-03-15", "P1M", { relativeTo: "2024-03-15" });

Right

import { intervalFromDurationDate } from "@northguild/gmt";
intervalFromDurationDate("2024-03-15", "P1M", "start"); // { start: "2024-03-15", end: "2024-04-15" }
intervalFromDurationDate("2024-01-31", "P1M", "start"); // { start: "2024-01-31", end: "2024-02-29" }
intervalFromDurationDate("2024-03-15", "-P10D", "start"); // null
MEDIUM

Reading intervalOverlappingDays* as an intersection object

intervalOverlappingDays* returns a number: how many distinct calendar dates hold an instant of the half-open intersection. Touching intervals share no date. For the shared span itself, use intervalIntersection* or intersectIntervals.

Wrong

const shared = intervalOverlappingDaysDate("2024-01-01", "2024-07-01", "2024-04-01", "2025-01-01");
shared.start; // undefined — shared is 91

Right

intervalOverlappingDaysDate("2024-01-01", "2024-07-01", "2024-07-01", "2025-01-01"); // 0 — touching
intervalIntersectionDate("2024-01-01", "2024-07-01", "2024-04-01", "2025-01-01");
// { start: "2024-04-01", end: "2024-07-01" }
MEDIUM

Calling list functions with flat arguments, or without a suffix

mergeIntervals*, intervalXorAll* and every interval/ list function take one array of { start, end } records. The positional list functions carry a type suffix — there is no bare intervalXorAll.

Wrong

intervalXorAll(a, b);

Right

import { intervalXorAllDate, mergeIntervals } from "@northguild/gmt";
intervalXorAllDate([a, b, c]); // positional, Date values
mergeIntervals([x, y, z]); // half-open, instant strings