strictdatetime
v1.4.0
Published
Strict, immutable, zero-dependency date and time utilities for JavaScript and TypeScript
Maintainers
Readme
strictdatetime
Strict, immutable date and time utilities for JavaScript and TypeScript with zero runtime dependencies.
npm install strictdatetimeimport {
addCalendarToZonedDateTime,
createCalendarDuration,
parsePlainDateTime,
resolveZonedDateTime,
toZonedDateTimeString,
} from "strictdatetime";
const meeting = resolveZonedDateTime(
parsePlainDateTime("2026-03-07T12:00:00.000"),
"America/New_York",
);
const tomorrow = addCalendarToZonedDateTime(
meeting,
createCalendarDuration({ years: 0, months: 0, weeks: 0, days: 1 }),
);
toZonedDateTimeString(tomorrow);
// 2026-03-08T12:00:00.000-04:00[America/New_York]Design
- Pure named functions and frozen structural records.
- Separate exact elapsed-time and calendar wall-time arithmetic.
- Strict typed parsers. Native
Date.parsestrings are never accepted implicitly. - Millisecond precision; non-zero finer precision is rejected.
- IANA zones through the host runtime's
Intldata, plus fixed-offset zones. - ESM and CommonJS builds with shared TypeScript declarations.
- No runtime or peer dependencies.
Values
The public records are Instant, PlainDate, PlainTime, PlainDateTime, ZonedDateTime,
ExactDuration, CalendarDuration, InstantInterval, and ZonedDateTimeInterval. Every function
validates exact enumerable keys. Runtime identity, prototypes, and instanceof are not used for
values. So a PlainDateTime is not a PlainDate; plainDateOf and plainTimeOf take its date or
time part, for example plainDateOf(projectInstant(ms, zone)) for the local calendar date of an
instant.
ZonedDateTime stores only an epoch millisecond and a zone identifier. Local fields and offsets are
derived from current host time-zone data.
Intervals and boundaries
InstantInterval and ZonedDateTimeInterval are half-open ranges [start, end). The end is
excluded, so [a, b) and [b, c) are adjacent rather than overlapping, and start === end is an
empty range. A reversed range rejects, and both endpoints of a zoned range must resolve to the same
normalized zone.
Containment, overlap, and duration are decided on the elapsed timeline, so a zoned day that crosses a daylight-saving change measures 23 or 25 hours rather than 24. Values in a different zone can still be compared against a range.
startOfPlainDateTimeUnit, endOfPlainDateTimeUnit, and their ZonedDateTime counterparts
truncate to year, month, week, day, hour, minute, or second. The end is exclusive:
the end of 2026-05-04 is 2026-05-05T00:00:00.000, which is what makes
zonedDateTimeUnitInterval cover the unit exactly. Weeks start on Monday unless weekStart says
otherwise (ISO numbering, Sunday is 7).
Zoned boundaries are the first real instant of the unit. Local midnight does not exist on every
calendar day: Santiago skips it on 2026-09-06, so that day starts at 01:00-03:00. Havana repeats
it on 2026-11-01, so that day starts at the first 00:00, at -04:00. Adjacent zoned units never
overlap and never leave a gap. A boundary has one correct answer, so the disambiguation option is
validated but has no effect here (since 1.4.0; before, a skipped midnight rejected by default, and
"earlier" returned a time on the previous day).
zonedDateTimeFromPlainDate(date, zone) gives the first instant of a calendar date by the same
rule, for example to turn a date picked in a form into "from the start of that day, locally". A
date the zone skips entirely (Pacific/Apia on 2011-12-30) fails with NONEXISTENT_TIME.
instantIntervalIntersection and zonedDateTimeIntervalIntersection return the shared part of two
ranges, or undefined when they share no instant. instantIntervalGap and
zonedDateTimeIntervalGap return the range strictly between two disjoint ranges, or undefined
when they overlap or merely touch; argument order does not matter. Because [a, b) and [b, c)
meet at a point both exclude, touching ranges intersect in nothing and have no gap rather than
producing an empty range at b. The zoned results carry the zone of the left argument.
instantIntervalDifference and zonedDateTimeIntervalDifference return the part of the left range
the right one does not cover, ordered by start. Removing the middle of a range splits it, so the
result holds two ranges; removing an end or nothing gives one, and removing everything gives none.
Subtracting a touching range removes nothing, since the two share no instant.
mergeInstantIntervals and mergeZonedDateTimeIntervals reduce a list to the fewest ranges
covering the same instants, ordered by start. Touching ranges merge, since [a, b) and [b, c)
cover exactly [a, c) and keeping them apart would describe one span with two records. Empty
ranges cover no instant and drop out, so merging only empty ranges returns an empty list. Every
zoned range passed to mergeZonedDateTimeIntervals must already share one zone; merging across
zones would leave the zone of the result depending on sort order, so it rejects instead. Convert
with withTimeZone first.
clampInstant and clampZonedDateTime take inclusive minimum and maximum bounds, not an
interval record. clampZonedDateTime keeps the zone of the clamped value. minimumInstant,
maximumInstant, and their zoned counterparts order by elapsed time and keep the first value on a
tie.
Parsing profiles
parseInstant: uppercase RFC 3339-style ISO timestamp withZor a numeric offset.parsePlainDate,parsePlainTime,parsePlainDateTime: ISO calendar fields without a zone. Seconds may be left out (14:30,2026-03-08T02:30), which is what an HTMLdatetime-localortimeinput produces; a fraction then is not allowed. The instant and zoned profiles require seconds.parseZonedDateTime: ISO date-time, required numeric offset, and exactly one[IANA/Zone]or fixed-offset annotation.parseRfc5322DateTime: the date in an emailDateheader, such asMon, 16 Feb 2026 10:00:00 -0500, returned as anInstant. It follows the current RFC 5322 grammar: a numeric zone, a four-digit year from 1900, optional seconds, and an optional day of the week that must match the date. Folded lines are unfolded, and comments after the zone, such as(UTC), are ignored. The obsolete forms of RFC 5322 section 4.3 fail withINVALID_RFC5322. The one exception is the obsolete zone names (GMT,EST, and others):{ allowObsoleteZones: true }accepts them, which also reads the HTTP date formatSun, 06 Nov 1994 08:49:37 GMT.
The zoned grammar is an intentional RFC 9557 subset. Calendar, critical, unknown, and duplicate
annotations are rejected. Leap seconds and 24:00 are not supported.
Time-zone behavior
Ambiguous times, nonexistent times, calendar overflow, and offset mismatch reject by default.
Callers may explicitly select Temporal-aligned policy names such as earlier, later, compatible,
or constrain.
Named-zone operations support years 1970–9999. Plain, UTC, and fixed-offset values support years 0000–9999. Zone rules come from the host's ICU/tzdb version, so different runtimes can disagree on historical or future political rules. The package does not bundle or pin a zone database.
Errors
Domain failures throw DateTimeError with a stable code. When ESM and CommonJS are loaded in the
same process, use isDateTimeError(error) and error.code; cross-build instanceof is not a
supported contract.
Supported environments
- Node.js 22 or newer.
- Current Chromium, Firefox, and WebKit engines covered by the pinned Playwright release.
License
MIT © 2026 Erik Karwatowski
