@sdxc/cron
Cron schedules with zone-aware occurrences and descriptors
- Installs with
- @sdxc/duration@sdxc/result
- Used by
- uptime
- Source
- packages/cron
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 aFailure<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, exclusiveoptions.timeZone: IANA zone the schedule is evaluated inoptions.count: How many occurrences to collect; omit for a singleDate
Returns:
A
Datewhencountis omitted, or aDate[]of that length. An unknown time zone yields an invalidDate(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, exclusiveoptions.timeZone: IANA zone the schedule is evaluated in
Returns:
A
Date, or an invalidDatefor 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 testoptions.timeZone: IANA zone whose wall clock the fields are read against
Returns:
truewhen every field matches;falsefor 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 receivedoptions.timeZone: IANA zone the schedule is evaluated inoptions.grace: ADurationInputtolerance; omit for none
Returns:
The deadline, or an invalid
Datefor 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 ranoptions.now: The instant being judged; always explicit, neverDate.now()options.timeZone: IANA zone the schedule is evaluated inoptions.grace: ADurationInputtolerance; omit for none
Returns:
trueonce the deadline has been reached,falsewhile 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, sopositionlines up with itreason:InvalidCronReason- machine-readable causefield:CronFieldName | null- the field at fault, ornullfor the whole expressionposition:number- zero-based index intoexpression
Reasons:
reason | Meaning |
|---|---|
empty | No fields at all |
field-count | Not the five standard fields |
seconds-not-supported | Six fields, i.e. a seconds-first expression |
unknown-macro | An @ shorthand outside the supported set |
syntax | A field that is not a list of values, ranges, or steps |
unknown-name | A month or weekday name that is not a known abbreviation |
out-of-range | A numeric value outside the field's bounds |
reversed-range | A range whose start is greater than its end |
invalid-step | A step that is missing, not a number, or zero |
impossible-date | A 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
| Shape | Example | Notes |
|---|---|---|
| Value | 30 9 * * * | |
| List | 0,15,30,45 * * * * | Sorted and deduplicated |
| Range | 0 9-17 * * * | Inclusive; a reversed range is rejected |
| Step on a star | */15 * * * * | |
| Step on a range | 0 9-17/4 * * * | |
| Step on a value | 5/10 * * * * | Runs from the value to the field's maximum |
| Month names | 0 0 1 JAN *, 0 0 * jan-mar * | Three letters, any case |
| Weekday names | 0 9 * * MON-FRI | Three letters, any case |
| Sunday as seven | 0 0 * * 7 | Folded 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:
| Situation | Behavior |
|---|---|
| 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 hour | Fires in both passes, keeping its spacing |
| Interval through a skipped hour | Loses 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:
matches()answers about the wall clock, so it isfalsefor a run carried out of a skipped hour (03:30 is not 02:30) andtruefor both passes of a repeated hour.next()is the authority on which instants are occurrences.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.
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.
nextandprevagree 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:
A wall time the clock skips entirely. We carry the run past the jump;
cron-parserdrops 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:@dailyinAfrica/Cairo, whose clock jumps at exactly 00:00, loses a whole day.prev()inside a repeated hour. We report the first pass, the same instantnext()reports.cron-parserreports the first pass going forward and the second going back. Ours is correct:next()andprev()should agree on which instants are occurrences.The library stops advancing. In zones whose offset shifts by 30 minutes (
Australia/Lord_Howe) or whose transition lands at midnight (America/Santiago),cron-parserreturns the same instant from every subsequentnext()orprev()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.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.A day-of-week step whose stride never lands on
7.6-7/2is Saturday only: the stride goes 6, then 8, which is past the field, so Sunday is never reached.cron-parseradds Sunday whenever a range ends at7, whatever the stride, turning a Saturday schedule into a weekend one. Same for3/3, which is Wednesday and Saturday. Ours is correct: Vixie cron expands the range into a bitmap of0–7and 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 inUTC.
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.tswalks 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.tsdoes the same for what is rejected, checking the reason, the field, and the character index each failure reports.invariants.test.tsasserts 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.tscompares the same corpus againstcron-parserinUTC.
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.
Related Packages
@sdxc/result- theResulttype parsing returns@sdxc/duration- theDurationInputthegraceoption takes
Tips
Parse once - a
Scheduleis immutable and frozen, so parse an expression when a record is loaded and reuse the instance for the whole request.Store the normalized form -
toString()gives one spelling per schedule, which makes stored expressions comparable and log lines stable.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.
Key i18n on
kind, not on the expression - and always handlekind: "expression", which is a normal outcome rather than an error.Pass
nowfrom the caller - the lateness helpers take the instant to judge, which is what makes a late-monitor sweep testable without touching the clock.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.Reach for
next(), notmatches(), to decide what ran -matches()reads the wall clock, and the two can disagree inside a daylight saving transition.