by M2Toolkit · v0.1.0

Modern dates.
Explicit time zones.

An immutable date and time toolkit for JavaScript and TypeScript. Strict parsing, DST handled explicitly.

npm install chronexa

Why chronexa

One clock model.
Instants, zones and durations.

Learn more

Chronexa keeps instants, wall-clock times, time-zone identifiers and UTC offsets apart, because mixing them up is where date bugs come from. Invalid dates are errors, and ambiguous times follow a policy you choose.

Parse, format, add, diff, convert between zones, count business days and expand recurrence rules with one small API. Framework-agnostic: no dependencies, no Node.js built-ins, and the same code runs in Node.js and the browser.

ts
import { DateTime, Duration } from "chronexa";

const NY = "America/New_York";

// Every Monday standup at 09:30 New York time, whatever the clocks do.
const start = DateTime.fromISO("2025-03-03T09:30:00", { zone: NY });

start.add({ weeks: 2 }).toISO(); // "2025-03-17T09:30:00.000-04:00"  (DST began on the 9th)
start.add({ weeks: 2 }).toZone("Asia/Kolkata").toISO(); // "2025-03-17T19:00:00.000+05:30"

Duration.from({ hours: 1, minutes: 90 }).normalize().toISO(); // "PT2H30M"

Explicit. Immutable. Dependency-free.

Invalid dates fail loudly

There is no "invalid DateTime" object. Factories return a usable value or throw a ChronexaError with a stable code; the try* variants return { ok, value | error } if you would rather not use try/catch. February 30 is an error, never March 1 or 2.

ts
import { DateTime } from "chronexa";

DateTime.fromISO("2025-02-30", { zone: "utc" });
// ChronexaError [INVALID_DATE]: Cannot parse "2025-02-30" as an ISO 8601 date/time: day 30 does not exist in 2025-02;
// invalid dates are never rolled over.

const result = DateTime.tryFromISO("2025-02-30");
if (!result.ok) {
  console.log(result.error.code); // "INVALID_DATE"
  console.log(result.error.message); // says exactly what is wrong and how to fix it
}

DateTime.fromISO("2024-01-31", { zone: "utc" }).add({ months: 1 }).toISODate();
// "2024-02-29"  add() clamps to month end (documented); set({ month: 2 }) would throw

Elapsed time is not calendar time

add({ hours: 24 }) and add({ days: 1 }) differ across a DST change, by design. Chronexa never converts between calendar and elapsed units without a relativeTo date.

ts
import { DateTime } from "chronexa";

const evening = DateTime.fromISO("2025-03-08T22:00:00", { zone: "America/New_York" });

evening.add({ hours: 24 }).toISO(); // "2025-03-09T23:00:00.000-04:00"  elapsed time
evening.add({ days: 1 }).toISO(); //   "2025-03-09T22:00:00.000-04:00"  calendar time

const a = DateTime.fromISO("2024-01-31", { zone: "utc" });
const b = DateTime.fromISO("2024-03-04", { zone: "utc" });

b.elapsed(a, "hour"); //                   792   exact elapsed time
b.diff(a, ["month", "day"]).toObject(); // { months: 1, days: 4 }
b.calendarDiff(a, "month"); //             2     boundaries crossed

DST, handled explicitly

A wall-clock time can be unique, repeated or missing. Inspect it with inspectLocalTime and choose a disambiguation policy: compatible, earlier, later or reject. Nothing guesses about time zones.

ts
import { DateTime, inspectLocalTime } from "chronexa";

const NY = "America/New_York";
const gap = { year: 2025, month: 3, day: 9, hour: 2, minute: 30 }; // clocks skip 02:00 → 03:00

inspectLocalTime(gap, NY).status; // "gap"
DateTime.fromObject(gap, { zone: NY }).toISO(); // "2025-03-09T03:30:00.000-04:00"  compatible
DateTime.fromObject(gap, { zone: NY, disambiguation: "earlier" }).toISO(); // 01:30-05:00
DateTime.fromObject(gap, { zone: NY, disambiguation: "reject" }); // throws NONEXISTENT_LOCAL_TIME

Scheduling built in

Half-open Intervals, a BusinessCalendar with your own weekend and holidays, and a recurrence engine covering a documented subset of RFC 5545. Occurrences keep their wall-clock time across DST.

ts
import { BusinessCalendar, DateTime, RecurrenceRule, holidayProvider } from "chronexa";

const cal = new BusinessCalendar({
  weekend: [6, 7],
  holidays: holidayProvider([{ type: "fixed", month: 12, day: 25 }]),
});
cal.addBusinessDays(DateTime.fromISO("2025-12-24", { zone: "utc" }), 1).toISODate(); // "2025-12-26"

const patchDay = new RecurrenceRule({
  freq: "monthly",
  start: DateTime.fromISO("2025-03-01T09:30:00", { zone: "America/New_York" }),
  byNthWeekday: [{ weekday: 2, nth: 2 }], // second Tuesday
  count: 5,
});
patchDay.all().map((d) => d.toISODate()); // 03-11, 04-08, 05-13, 06-10, 07-08

Small, safe, dependency-free

Built on the platform's Intl with no runtime dependencies and no Node built-ins. It is tree-shakeable, ships ESM and CommonJS builds, and is checked with type tests and a bundled-for-the-browser smoke test.

  • Immutable every operation returns a new object
  • Tree-shakeable import only the helpers you use
  • No Node APIs runs in browsers, workers and Node
  • Injectable clock Settings.now makes tests deterministic
  • Strict parsing never leans on Date.parse

Mistakes fail at compile time

Unit arguments are closed unions and duration keys are plural, so typos like { day: 1 } or startOf("decade") do not compile. Strict types, no any.

Playground

Try it live. Edit the input, watch the result.

Every result below is computed in your browser by the same chronexa source this page documents. Nothing is mocked.

Codezones.ts
ts
import { DateTime } from "chronexa";

type Input = { instant: string; zones: string[] };

/** One instant, shown on every wall clock in the list. */
export function run({ instant, zones }: Input) {
  const moment = DateTime.fromISO(instant, { zone: "utc" });
  return zones.map((zone) => {
    const local = moment.toZone(zone);
    return { label: zone, value: `${local.toISO()}  (${local.format("ZZZ")})` };
  });
}
Input
✓ Result
  • America/New_York

    2025-06-15T08:00:00.000-04:00 (EDT)

  • Europe/Paris

    2025-06-15T14:00:00.000+02:00 (GMT+2)

  • Asia/Kolkata

    2025-06-15T17:30:00.000+05:30 (GMT+5:30)

  • Pacific/Auckland

    2025-06-16T00:00:00.000+12:00 (GMT+12)

One instant, many wall clocks. Try a fixed offset like "+05:30" or break a zone name to see INVALID_ZONE. Read the docs →

0Zero dependencies
13.5 kBmin+gzip for DateTime, vs 22 kB for Luxon
4ways to resolve a missing or repeated hour
ESM + CJSNode.js 20+ and modern browsers

Documentation

Everything, from your first DateTime to DST-safe scheduling.

Start using chronexa

Dates you can reason about: explicit zones, strict parsing, no surprises.