sdxc

Type to search, or start from one of these:

@sdxc/cron

Cron schedules with zone-aware occurrences and descriptors

npm add @sdxc/cron
pnpm add @sdxc/cron
yarn add @sdxc/cron
bun add @sdxc/cron
Used by
uptime

Cron expression parsing and time-zone-aware occurrence computation, with Result failures and descriptions an app can translate.

Installation

npm add @sdxc/cron

Overview

Cron expressions are user input: someone types */15 * * * * into a form and the product has to say whether it is valid, when the next run is, and what it means. This package models that as a Schedule value object. Parsing returns a Result carrying the offending field and character index instead of throwing, so validation composes with the rest of the repository's error handling.

Every occurrence query names its time zone explicitly. A schedule is stored with the zone the user configured it in, evaluation happens in that zone, and the return value is an instant, which is what keeps a daily 09:00 job at 09:00 local when the offset changes underneath it. There is no ambient clock either: from and now are always arguments, so results are reproducible in a test.

Descriptions are structured data, never text. describe() returns shapes like { kind: "daily", at: [{ hour: 9, minute: 0 }] } and the app maps kind to an i18n key, so nothing user-facing ships from this package. Only the five standard fields are supported: seconds and the non-standard extensions (L, W, #, ?) are rejected rather than half-implemented.

Usage

Validating input

import { Schedule } from "@sdxc/cron";
import { isFailure } from "@sdxc/result";

let result = Schedule.parse("*/15 * * * *");
if (isFailure(result)) {
	// result.error.reason  -> "out-of-range"
	// result.error.field   -> "hour"
	// result.error.position -> index inside the text the user typed
	return validationError(result.error);
}

let schedule = result.data;

Computing occurrences

let from = new Date();

schedule.next({ from, timeZone: "America/New_York" }); // Date
schedule.next({ from, timeZone: "America/New_York", count: 5 }); // Date[]
schedule.prev({ from, timeZone: "America/New_York" }); // Date
schedule.matches(from, { timeZone: "America/New_York" }); // boolean

Describing a schedule

import { unwrap } from "@sdxc/result";

let descriptor = unwrap(Schedule.parse("0 9 * * 1-5")).describe();
// { kind: "weekly", weekdays: [1, 2, 3, 4, 5], at: [{ hour: 9, minute: 0 }] }

if (descriptor.kind === "expression") {
	// No concise shape fits: show the raw expression.
}

Asking whether a run is late

schedule.expectedBy(lastPing, { timeZone, grace: "5 minutes" }); // Date
schedule.isDue(lastPing, { now: new Date(), timeZone, grace: "5 minutes" }); // boolean

API

Schedule.parse(expression: string): Result<Schedule, InvalidCronExpression>

Parses a cron expression. Never throws, for any input.

Parameters:

  • expression: The expression as written, whitespace and case as typed

Returns:

  • A Success<Schedule>, or a Failure<InvalidCronExpression> naming the field and index a validation message should point at

Example:

let result = Schedule.parse("0 9 * * 1-5");

schedule.next(options): Date | Date[]

The next occurrence strictly after options.from. An instant that is itself an occurrence is treated as past, matching a daemon that has already run it.

Parameters:

  • options.from: Where the search starts, exclusive

  • options.timeZone: IANA zone the schedule is evaluated in

  • options.count: How many occurrences to collect; omit for a single Date

Returns:

  • A Date when count is omitted, or a Date[] of that length. An unknown time zone yields an invalid Date (and an empty array), never a throw

Example:

let upcoming = schedule.next({ from: new Date(), timeZone: "Europe/Madrid", count: 3 });

schedule.prev(options): Date

The last occurrence strictly before options.from, so next and prev never report the same instant for the same input. Strictly means to the millisecond: asked from 12:00:30, a schedule that fires at 12:00 reports that run, because it is behind the instant asked about even though it is inside the same minute.

Parameters:

  • options.from: Where the search starts, exclusive

  • options.timeZone: IANA zone the schedule is evaluated in

Returns:

  • A Date, or an invalid Date for an unknown zone

schedule.matches(date: Date, options): boolean

Whether the minute date falls in is one the schedule fires in. Seconds are ignored, because cron resolves to minutes.

Parameters:

  • date: The instant to test

  • options.timeZone: IANA zone whose wall clock the fields are read against

Returns:

  • true when every field matches; false for an unknown zone or an invalid date

schedule.describe(): ScheduleDescriptor

A structured description, for an app to render in the user's language. The same frozen object on every call.

Returns:

  • One of the descriptors below, or { kind: "expression" } when nothing concise fits

Example:

let descriptor = schedule.describe(); // { kind: "interval", unit: "minute", every: 15 }

schedule.toString(): string

The normalized expression: macros expanded, names resolved to numbers, values sorted and deduplicated, runs collapsed to ranges. For storage and logs, not for display.

Returns:

  • The five fields separated by single spaces

Example:

unwrap(Schedule.parse("@weekly")).toString(); // "0 0 * * 0"
unwrap(Schedule.parse("5/10 * * * *")).toString(); // "5,15,25,35,45,55 * * * *"

schedule.expectedBy(lastRun: Date, options): Date

The instant the run following lastRun must have arrived by: the next occurrence after it, plus the grace period.

Parameters:

  • lastRun: When the schedule last ran, e.g. the last ping received

  • options.timeZone: IANA zone the schedule is evaluated in

  • options.grace: A DurationInput tolerance; omit for none

Returns:

  • The deadline, or an invalid Date for an unknown zone

schedule.isDue(lastRun: Date, options): boolean

Whether a run is overdue: the occurrence following lastRun, plus its grace period, is at or before now.

Parameters:

  • lastRun: When the schedule last ran

  • options.now: The instant being judged; always explicit, never Date.now()

  • options.timeZone: IANA zone the schedule is evaluated in

  • options.grace: A DurationInput tolerance; omit for none

Returns:

  • true once the deadline has been reached, false while there is still time and for an unknown zone

InvalidCronExpression

The failure parse() returns. An Error subclass whose message is diagnostic only: the wording a user reads comes from the app, keyed on reason.

Properties:

  • expression: string - the rejected text, verbatim, so position lines up with it

  • reason: InvalidCronReason - machine-readable cause

  • field: CronFieldName | null - the field at fault, or null for the whole expression

  • position: number - zero-based index into expression

Reasons:

reasonMeaning
emptyNo fields at all
field-countNot the five standard fields
seconds-not-supportedSix fields, i.e. a seconds-first expression
unknown-macroAn @ shorthand outside the supported set
syntaxA field that is not a list of values, ranges, or steps
unknown-nameA month or weekday name that is not a known abbreviation
out-of-rangeA numeric value outside the field's bounds
reversed-rangeA range whose start is greater than its end
invalid-stepA step that is missing, not a number, or zero
impossible-dateA day-of-month and month pair no calendar year contains

Types

ScheduleDescriptor

type ScheduleDescriptor =
	| { kind: "interval"; unit: "minute" | "hour"; every: number }
	| { kind: "hourly"; minutes: readonly number[] }
	| { kind: "daily"; at: readonly TimeOfDay[] }
	| { kind: "weekly"; weekdays: readonly number[]; at: readonly TimeOfDay[] }
	| { kind: "monthly"; days: readonly number[]; at: readonly TimeOfDay[] }
	| { kind: "yearly"; months: readonly number[]; days: readonly number[]; at: readonly TimeOfDay[] }
	| { kind: "expression" };

interface TimeOfDay {
	hour: number;
	minute: number;
}

Weekdays are numbered with 0 for Sunday and months from 1 for January, the same as the cron fields, so an app can index its own translated names directly.

{ kind: "expression" } is the fallback and is expected: it covers schedules that restrict both day fields (the either-or rule below cannot be phrased as one shape), schedules with more times of day than a sentence should list, and anything else with no concise shape. Show toString() in that case.

Supported syntax

ShapeExampleNotes
Value30 9 * * *
List0,15,30,45 * * * *Sorted and deduplicated
Range0 9-17 * * *Inclusive; a reversed range is rejected
Step on a star*/15 * * * *
Step on a range0 9-17/4 * * *
Step on a value5/10 * * * *Runs from the value to the field's maximum
Month names0 0 1 JAN *, 0 0 * jan-mar *Three letters, any case
Weekday names0 9 * * MON-FRIThree letters, any case
Sunday as seven0 0 * * 7Folded onto 0
Macros@hourly @daily @weekly @monthly @yearly@annually is @yearly, @midnight is @daily

Field bounds are minute 0-59, hour 0-23, day of month 1-31, month 1-12, and day of week 0-7.

Rejected on purpose: seconds fields (a sub-minute schedule is a promise a worker runtime cannot keep), @reboot (no schedule of its own), and the non-standard L, W, # and ?. Accepting that syntax without honoring its semantics is worse than refusing it.

The day-of-month and day-of-week rule

When both day fields are restricted, a date matches if either matches. When only one is restricted, the other is open and matches everything.

// The 13th of any month, and every Friday.
Schedule.parse("0 0 13 * 5");

// Only Mondays: the day-of-month field is open, so both fields must agree.
Schedule.parse("0 0 * * 1");

Only a bare * leaves a field open. */2 names specific days, so 0 0 */2 * 1 fires on odd days of the month and on Mondays. This is also why toString() prints a day field covering every day as 1-31 or 0-6 rather than *: turning it into a star would quietly switch the rule off.

An expression whose day of month can never occur in any month it names, such as 0 0 30 2 *, is rejected at parse time with impossible-date, so occurrence queries always have an answer. With a restricted day of week the same expression is accepted, because the weekday side can still put it on the calendar.

Daylight saving time

A schedule means one of two things, and which one it means decides what happens at a transition:

  • Pinned to hours (0 9 * * *, 30 2 * * *): an appointment. It is followed on the zone's wall clock, so 09:00 stays 09:00 local and the instant moves by an hour.

  • Fires every hour (*/15 * * * *, 0 * * * *, @hourly): an interval. It is followed on absolute time, so the spacing is what is preserved.

At the two awkward moments:

SituationBehavior
Wall time happens twice (clock set back)The first pass is the occurrence, so an appointment fires once
Wall time never happens (clock set forward)The run is carried forward to the instant just after the jump
Interval through a repeated hourFires in both passes, keeping its spacing
Interval through a skipped hourLoses the runs in it, because that hour did not exist

Concretely, in America/New_York around 2026-03-08, when 02:00 EST becomes 03:00 EDT:

let daily = unwrap(Schedule.parse("0 9 * * *"));
daily.next({ from: new Date("2026-03-07T12:00:00Z"), timeZone: "America/New_York", count: 2 });
// 2026-03-07T14:00Z and 2026-03-08T13:00Z — both 09:00 local

let skipped = unwrap(Schedule.parse("30 2 * * *"));
skipped.next({ from: new Date("2026-03-07T12:00:00Z"), timeZone: "America/New_York" });
// 2026-03-08T07:30Z — 03:30 EDT, the run carried past the jump

Two consequences worth knowing:

  1. matches() answers about the wall clock, so it is false for a run carried out of a skipped hour (03:30 is not 02:30) and true for both passes of a repeated hour. next() is the authority on which instants are occurrences.

  2. A schedule inside a skipped hour still runs, an hour late, rather than being dropped for the day. For a dead man's switch, a run that silently never happens is the worse failure.

  3. That carried run is reachable from either side and from inside the gap itself, so a monitor that pings the moment the clock jumps still has its own run ahead of it rather than nothing for a day. next and prev agree on it, which is the property the whole search is checked against: walking a window forward and backward finds the same instants, in every zone in the table.

Differences from cron-parser

This package replaces cron-parser 5.6.2. A sweep of 172,428 occurrence comparisons, over 80 expressions and 22 time zones, walking both directions from anchors on both sides of every offset change between 2026 and 2028, found 533 divergent runs, and a seeded differential run of 2,000 generated expressions in UTC found one more class. Every divergence is either at a daylight saving transition or in how a step reaching the day-of-week 7 alias is expanded.

The divergences fall into five classes, and this package is the one to keep in all five:

  1. A wall time the clock skips entirely. We carry the run past the jump; cron-parser drops it and reports the next day, week, or month. Ours is correct: it is what a Unix cron daemon does, and a dead man's switch that expects nothing for a day cannot alert. Note the library agrees with us looking forward and disagrees looking back, so it cannot find again the occurrence it just produced. Worst case seen: @daily in Africa/Cairo, whose clock jumps at exactly 00:00, loses a whole day.

  2. prev() inside a repeated hour. We report the first pass, the same instant next() reports. cron-parser reports the first pass going forward and the second going back. Ours is correct: next() and prev() should agree on which instants are occurrences.

  3. The library stops advancing. In zones whose offset shifts by 30 minutes (Australia/Lord_Howe) or whose transition lands at midnight (America/Santiago), cron-parser returns the same instant from every subsequent next() or prev() call. This is not a semantic disagreement, it is a failure to make progress, and it accounts for most of the divergent runs in those zones.

  4. The library returns an instant off a minute boundary. Walking back through Pacific/Chatham, whose offset is 45 minutes off the hour, it reports times ending :14:59. Cron resolves to minutes, so no such instant can be an occurrence.

  5. A day-of-week step whose stride never lands on 7. 6-7/2 is Saturday only: the stride goes 6, then 8, which is past the field, so Sunday is never reached. cron-parser adds Sunday whenever a range ends at 7, whatever the stride, turning a Saturday schedule into a weekend one. Same for 3/3, which is Wednesday and Saturday. Ours is correct: Vixie cron expands the range into a bitmap of 07 and folds bit 7 onto bit 0 afterwards, so a stride that skips bit 7 never sets bit 0. This is the only divergence that shows up in UTC.

Everything else matched exactly, including both DST directions in the common zones, the either-or rule with steps, name and step parsing, February 29th across a non-leap century, month-length skipping, and a full month of every-five-minutes occurrences.

Three differences in what is accepted rather than computed, all of them wider here than there, so nothing that parses today stops parsing: expressions using seconds or the non-standard L, # and ? are rejected here and accepted there; a list that repeats a value (0 12 * * 1,1) is accepted and normalized here but rejected there; and @midnight is accepted here, though cron-parser rejects it despite the crontab specification listing it. The W extension is rejected by both.

src/parity.test.ts and src/differential.test.ts execute this comparison rather than replaying a transcript: cron-parser is a devDependency and is called directly, so a regression on either side fails the suite. The first names the cases a person chose; the second draws 2,000 expressions from a fixed seed and compares both directions in UTC. Both exist only until no application depends on that library, and the devDependency and the two files should be deleted together.

How the suite is built

Test expressions come from one place, src/test/corpus.ts, and the properties they are held to from src/test/properties.ts:

  • field-forms.test.ts walks every shape each field accepts — every value, every contiguous range, every step on a star, every abbreviation in every case, every two-value list — and compares the expansion with the grammar worked out independently.

  • field-rejections.test.ts does the same for what is rejected, checking the reason, the field, and the character index each failure reports.

  • invariants.test.ts asserts the occurrence properties over the generated corpus and across a table of zones spanning positive, negative, half-hour, three-quarter-hour and fixed offsets, a transition at midnight, one of thirty minutes and one of two hours.

  • differential.test.ts compares the same corpus against cron-parser in UTC.

CRON_FUZZ_ITERATIONS raises the generated corpus from its default 2,000 for a deeper local sweep; the seed is fixed, so an expression a failure names is the one the next run produces. Past roughly ten thousand, raise --testTimeout too:

CRON_FUZZ_ITERATIONS=60000 vp test run packages/cron/src/ --testTimeout=60000

Pattern: Validating a submitted expression

import { Schedule } from "@sdxc/cron";
import { isFailure } from "@sdxc/result";

let result = Schedule.parse(input.expression);
if (isFailure(result)) {
	return {
		errors: {
			expression: {
				key: `cron.error.${result.error.reason}`,
				field: result.error.field,
				position: result.error.position,
			},
		},
	};
}

// Store the normalized form, so logs and comparisons see one spelling.
await repository.save({ expression: result.data.toString(), timeZone: input.timeZone });

Pattern: Translating a descriptor

let descriptor = schedule.describe();

switch (descriptor.kind) {
	case "interval":
		return t(`schedule.interval.${descriptor.unit}`, { count: descriptor.every });
	case "hourly":
		return t("schedule.hourly", { minutes: descriptor.minutes });
	case "daily":
		return t("schedule.daily", { times: descriptor.at.map(formatTime) });
	case "weekly":
		return t("schedule.weekly", {
			days: descriptor.weekdays.map((day) => WEEKDAY_NAMES[day]),
			times: descriptor.at.map(formatTime),
		});
	case "monthly":
		return t("schedule.monthly", { days: descriptor.days, times: descriptor.at.map(formatTime) });
	case "yearly":
		return t("schedule.yearly", {
			months: descriptor.months.map((month) => MONTH_NAMES[month]),
			days: descriptor.days,
			times: descriptor.at.map(formatTime),
		});
	case "expression":
		return schedule.toString();
}

Pattern: A dead man's switch

Recording a signal and deciding whether the next one is overdue are the same two calls from opposite ends:

import { Schedule } from "@sdxc/cron";
import { isFailure } from "@sdxc/result";

let result = Schedule.parse(monitor.expression);
if (isFailure(result)) return; // A stored expression that no longer parses cannot be judged.
let schedule = result.data;

// When a signal arrives, record when the following one is due.
let nextExpectedAt = schedule.next({ from: new Date(), timeZone: monitor.timeZone }).getTime();

// On a sweep, ask whether the signal that was due has been missed.
let late = schedule.isDue(new Date(monitor.lastSignalAt), {
	now: new Date(),
	timeZone: monitor.timeZone,
	grace: monitor.grace,
});

Pattern: Counting runs in a window

count walks forward from an instant, so a window is a count plus a filter:

let end = new Date("2026-07-01T00:00:00Z");
let runs = schedule
	.next({ from: new Date("2026-06-01T00:00:00Z"), timeZone: "UTC", count: 5000 })
	.filter((date) => date < end);

Choose the count with the window in mind: an every-minute schedule produces about 44,600 runs in a month, and each one costs a walk of the calendar.

Tips

  1. Parse once - a Schedule is immutable and frozen, so parse an expression when a record is loaded and reuse the instance for the whole request.

  2. Store the normalized form - toString() gives one spelling per schedule, which makes stored expressions comparable and log lines stable.

  3. Store the zone next to the expression - the zone is never defaulted, and a schedule evaluated in the wrong one is off by hours, not minutes.

  4. Key i18n on kind, not on the expression - and always handle kind: "expression", which is a normal outcome rather than an error.

  5. Pass now from the caller - the lateness helpers take the instant to judge, which is what makes a late-monitor sweep testable without touching the clock.

  6. Check for an invalid Date - occurrence queries return one when the runtime does not know the time zone, which is the failure mode of a stale stored zone name.

  7. Reach for next(), not matches(), to decide what ran - matches() reads the wall clock, and the two can disagree inside a daylight saving transition.