@verifyhash/cron-dialect
v0.1.0
Published
Zero-dependency, multi-dialect cron parser: Unix 5-field, Quartz 6/7-field, and AWS EventBridge — with the field-count and day-of-week (0/7=Sun vs 1=Sun) and ? no-specific-value differences parsed correctly instead of silently mis-read.
Maintainers
Readme
cron-dialect
A small, zero-dependency cron parser that actually knows the difference between the cron dialects people paste into it. The popular explainers (Crontab Guru and friends) understand only classic Unix 5-field cron and will silently mis-read a 6- or 7-field Quartz / Spring / AWS EventBridge expression — the fields shift, and day-of-week is numbered differently, so "every Monday" quietly becomes "every Sunday". This library parses each dialect on its own terms and refuses to guess.
This package is the parser core, plus two modules that build on the
normalized model: translate (cron → English) and schedule (nextRuns,
upcoming fire times). A UI lives in a later module.
Install
npm install @verifyhash/cron-dialectPackage name:
@verifyhash/cron-dialectis a placeholder scope shown for publish-readiness. The final published scope and name are the owner's call and may change before the first release.
Zero runtime dependencies. Ships as CommonJS; a hand-written index.d.ts gives
TypeScript callers full types out of the box.
// Single entry point — everything public is on the main module:
const {
parseCron, CronParseError, // parse.js
translate, explain, // translate.js
nextRuns, // schedule.js
fromEnglish, ACCEPTED_FORMS, // english.js
} = require('@verifyhash/cron-dialect');
parseCron('0 12 * * 1', 'unix5').fields.dayOfWeek.dayNames; // ['MON']
translate('0 12 * * 1', 'unix5', 'quartz6').expr; // '0 0 12 ? * 2'
explain('*/15 * * * *', 'unix5'); // 'Minute 0, 15, 30, 45, of every hour, every day. [unix5]'
nextRuns('*/15 * * * *', 'unix5', { fromISO: '2026-03-08T09:07:30Z', count: 1 }); // ['2026-03-08T09:15:00Z']
fromEnglish('every 15 minutes', 'quartz6').expr; // '0 */15 * * * ?'The individual modules are also reachable as subpaths
(require('@verifyhash/cron-dialect/schedule'), .../english, .../translate,
.../parse) if you only want one.
Who it's for
- Anyone writing a cron builder/validator/explainer that must support more than vanilla Unix cron.
- Developers debugging why a Quartz or AWS schedule "runs on the wrong day" — usually the 1=Sunday vs 0/7=Sunday mismatch this library makes explicit.
- Library authors who want a strict, pure (no DOM, no network, no filesystem) function they can run anywhere Node runs.
The dialects, and exactly how they differ
| dialect | fields | order | day-of-week | ? token |
|------------------|:------:|-----------------------------------------|---------------|-----------|
| unix5 | 5 | min hour dom month dow | 0–7, 0 & 7 = Sun | illegal |
| quartz6 | 6 | sec min hour dom month dow | 1–7, 1 = Sun | required on exactly one of dom/dow |
| quartz7 | 6 or 7 | sec min hour dom month dow [year] | 1–7, 1 = Sun | required on exactly one of dom/dow |
| awsEventBridge | 6 | min hour dom month dow year | 1–7, 1 = Sun | required on exactly one of dom/dow |
The two differences that bite people most:
- Day-of-week numbering.
... 1means Monday inunix5but Sunday in every Quartz/AWS dialect. The parser resolves both to a canonicaldaysarray (0 = Sunday … 6 = Saturday) plus adayNamesarray, so the real weekday is unambiguous no matter which dialect you fed it. - The
?"no specific value" token. Quartz and AWS require a?on exactly one of day-of-month / day-of-week (you cannot pin both). Classic Unix cron has no such token at all, so?there is a hard error rather than a silently ignored character.
Example
const { parseCron } = require('cron-dialect');
// Same numeric "1", two different weekdays:
parseCron('0 12 * * 1', 'unix5').fields.dayOfWeek.dayNames; // ['MON']
parseCron('0 12 ? * 1 *', 'awsEventBridge').fields.dayOfWeek.dayNames; // ['SUN']
// Quartz noon-every-day, with its leading seconds field and the required ?:
parseCron('0 0 12 * * ?', 'quartz6').fields.hour.values; // [12]
// Honest failures instead of guesses:
parseCron('0 0 12 * * ?', 'unix5'); // throws: unix5 has no "?" token
parseCron('* * * * 8', 'unix5'); // throws: dow 8 out of range (0-7)The normalized model
parseCron(expr, dialect) returns:
{
dialect: 'quartz6',
expression: '0 0 12 * * ?',
fields: {
second: { field, raw, all, any, values, min, max },
minute: { ... },
hour: { ... },
dayOfMonth: { ... },
month: { ... },
dayOfWeek: { ..., days, dayNames }, // days/dayNames only on this field
year: { ... } // only when the dialect has a year
}
}Per field:
values— the concrete integers the field matches, sorted, in that dialect's native numbering (so a Quartz Sunday is1, a Unix Sunday is0and possibly7).all—truewhen the field was a bare*.any—truewhen the field was?(valuesis then empty).days/dayNames— day-of-week only: the canonical weekday set (0 = Sunday) and its three-letter names, unifying the numbering schemes.min/max— the field's inclusive native range.
Supported field syntax: *, single values, ranges a-b, lists a,b,c,
steps */n, a/n (from a to the max, by n), and a-b/n. Month and
day-of-week accept the usual three-letter names (JAN, MON, …),
case-insensitive.
API
Every export listed here is reachable from the main module
(require('@verifyhash/cron-dialect')); the "module" column is the file that
also exposes it as a subpath import.
| export | module | signature | what it does / returns |
|--------|--------|-----------|------------------------|
| parseCron | parse.js (main) | parseCron(expr, dialect) | Parses and normalizes expr for the given dialect into the field model documented above. Throws CronParseError on anything invalid (wrong field count, out-of-range value, a token illegal in that dialect, a malformed step, an unknown dialect) — it never silently accepts garbage. |
| translate | translate.js | translate(expr, from, to) | Re-emits expr from the from dialect into the to dialect, returning { expr, warnings }. warnings is an array of { code, message } objects, one per lossy or gotcha-prone conversion (injected seconds, an injected ?, the 1=Sun↔0/7=Sun day-of-week renumbering, a dropped year field). Throws CronParseError if the source expr does not parse. |
| explain | translate.js | explain(expr, dialect) | Returns a plain-English string describing the schedule, e.g. "At 12:00, on Monday. [unix5]". The reading is dialect-correct: it resolves the weekday from the canonical dayNames, so a Quartz 2 and a Unix 1 both read as "Monday" — the numbering gotcha is neutralized in the wording itself. Throws CronParseError on an invalid expr. |
| nextRuns | schedule.js | nextRuns(expr, dialect, opts) | Returns an array of the next opts.count upcoming fire times as UTC ISO-8601 instant strings. Honors the Quartz seconds field and the dialect day-of-week numbering (it re-uses parseCron, never re-derives). Timezone-aware and DST-deterministic (see below). Throws CronParseError on an invalid expr; returns [] honestly when a finite (year-pinned) expression has no future fires. |
| fromEnglish | english.js | fromEnglish(phrase, dialect) | The reverse direction: turns a constrained English phrase into a cron expression that is correct for dialect. Returns { expr, reading } on success or { error, nearest } on an unrecognized/ambiguous phrase — it returns errors as values (never throws) and never emits a guessed expression. |
| CronParseError | parse.js (main) | (Error subclass) | The error type thrown by parseCron, translate, explain, and nextRuns on invalid input. Exported so callers can catch (e) { if (e instanceof CronParseError) … }. |
| ACCEPTED_FORMS | english.js | (string array) | The complete list of English grammar forms fromEnglish accepts (['every minute', 'every hour', … , 'on day N of the month']) — handy for building an autocomplete or a help panel without hard-coding the list.
explainvstranslatewarnings.explainbakes the gotcha-awareness into its wording (it always reads the real weekday, whatever the dialect number). The machine-readable list of what changed in a conversion lives ontranslate(...).warnings— that is the array you surface to a user as inline "heads-up, this differs" notices.
fromEnglish(phrase, dialect)
The mirror of explain (which goes cron → English). It reads a small, fixed
grammar and emits a cron expression that is correct for the requested
dialect — the seconds field, the trailing year field, the ? token, and the
0/7=Sun vs 1=Sun day-of-week numbering are all inherited from translate, never
re-derived. It is deliberately strict: anything outside the grammar is an
explicit error, never a guessed expression.
const { fromEnglish } = require('cron-dialect/english');
fromEnglish('every 15 minutes', 'unix5');
// { expr: '*/15 * * * *', reading: 'Minute 0, 15, 30, 45, ... [unix5]' }
// Same phrase, dialect-correct output — note the seconds field and the ?:
fromEnglish('every 15 minutes', 'quartz6'); // { expr: '0 */15 * * * ?', reading: ... }
fromEnglish('every 15 minutes', 'awsEventBridge'); // { expr: '*/15 * * * ? *', reading: ... }
// The 1=Sunday shift is applied automatically for Quartz/AWS:
fromEnglish('at 9am on weekdays', 'unix5'); // { expr: '0 9 * * 1-5', ... } Mon-Fri = 1-5
fromEnglish('at 9am on weekdays', 'quartz6'); // { expr: '0 0 9 ? * 2,3,4,5,6', ... } Mon-Fri = 2-6
// Honest failure — NEVER a bogus expr:
fromEnglish('sometimes in the afternoon', 'unix5');
// { error: 'could not parse "sometimes in the afternoon"', nearest: 'at HH:MM (e.g. "at 3:00pm")' }Accepted grammar (the complete list — case-insensitive; a trailing period is
ignored). An at HH:MM time may be combined with any day form, in either
word order (at 9am on weekdays == on weekdays at 9am).
| phrase | meaning | example emitted (unix5) |
|--------|---------|-------------------------|
| every minute | every minute | * * * * * |
| every hour | top of every hour | 0 * * * * |
| every day | daily at midnight | 0 0 * * * |
| every N minutes | minute step */N (N 1–59) | */15 * * * * |
| every N hours | hour step */N (N 1–23) | 0 */5 * * * |
| at HH:MM | daily at that time; optional am/pm, or midnight / noon | 0 9 * * * |
| daily at HH:MM | same as at HH:MM | 30 14 * * * |
| weekly at HH:MM | every Sunday at that time | 0 9 * * 0 |
| monthly at HH:MM | day 1 of the month at that time | 15 18 1 * * |
| on weekdays | Monday–Friday | 0 0 * * 1-5 |
| on weekends | Saturday & Sunday | 0 0 * * 0,6 |
| on <day names> | comma / and-separated names (Monday, Wed and Fri) | 0 0 * * 1,3,5 |
| on day N of the month | day-of-month N (N 1–31) | 0 0 N * * |
Day forms default to midnight (00:00) when no at time is given. Frequency
forms (every N minutes / every N hours / every minute / every hour) set
their own minute/hour and cannot be combined with an at time — doing so is
an error, not a silent guess.
Return contract.
- Success →
{ expr, reading }, wherereadingisexplain(expr, dialect)(the dialect-correct human reading of the emitted expression). - Unparseable or ambiguous →
{ error, nearest }.erroris a plain-English message;nearestis the closest accepted grammar form (a hint, honestly not a promise it means the same thing). On failure noexpris ever returned — the function never emits a bogus expression to cover an input it didn't understand.fromEnglishreturns these errors as values (it does not throw).
nextRuns(expr, dialect, { fromISO, count = 5, tz = 'UTC' })
Returns an array of the next count fire times, as UTC ISO-8601 instant
strings (2026-03-08T09:15:00Z, whole seconds). It does not re-parse
fields — it calls parseCron(expr, dialect) and walks the normalized model, so
Quartz seconds and the 1=Sun vs 0/7=Sun day-of-week numbering are honored
exactly as the parser normalizes them.
const { nextRuns } = require('cron-dialect/schedule');
nextRuns('*/15 * * * *', 'unix5', { fromISO: '2026-03-08T09:07:30Z', count: 3 });
// [ '2026-03-08T09:15:00Z', '2026-03-08T09:30:00Z', '2026-03-08T09:45:00Z' ]
// Seconds field (Quartz) fires at :30, not :00:
nextRuns('30 0 12 * * ?', 'quartz6', { fromISO: '2026-07-21T00:00:00Z', count: 1 });
// [ '2026-07-21T12:00:30Z' ]
// Same numeric "1", different weekday -> different dates:
nextRuns('0 12 * * 1', 'unix5', { fromISO: '2026-07-21T00:00:00Z', count: 1 }); // Monday
// [ '2026-07-27T12:00:00Z' ]
nextRuns('0 0 12 ? * 1', 'quartz6', { fromISO: '2026-07-21T00:00:00Z', count: 1 }); // Sunday
// [ '2026-07-26T12:00:00Z' ]fromISO— the instant to start from; only fires strictly after it are returned. Pass a fixed value for deterministic output (it defaults to the host clock only when omitted).tz— an IANA timezone name (defaultUTC). Matching is against wall-clock time in that zone; the returned instants are the corresponding UTC moments. E.g.0 12 * * *inAmerica/New_Yorkreturns...T16:00:00Zin summer (EDT, UTC-4).- An unparseable expression throws the parser's
CronParseError(never a silent[]). A finite year set entirely in the past returns[]honestly (the expression parses; there are simply no future fires).
Timezone / DST rule (pinned & deterministic). Fire times are matched in wall-clock time, then converted to UTC using the zone's offset at that instant. Across a DST transition:
- Spring-forward gap (a wall time that never happens, e.g.
02:30when02:00→03:00is skipped): that fire is omitted — not shifted to03:30, not fired at the boundary. - Fall-back overlap (a wall time that happens twice, e.g.
01:30when02:00→01:00repeats): the fire happens once, at the first (earlier, still-daylight) occurrence, and is not duplicated.
Given a fixed fromISO, nextRuns is fully deterministic and never reads the
host timezone. A UTC (or any non-DST) zone is never affected by the rule above.
Honest limits
- The Quartz
L(last),W(nearest weekday), and#(nth weekday-of-month) tokens are not yet supported and currently raise aCronParseErrorrather than being parsed. They are planned; until then the library errors honestly instead of mis-parsing them. - Reverse ranges (
5-1) are rejected rather than interpreted as wrap-around.
Errors
On anything invalid — wrong field count for the dialect, an out-of-range value,
a token illegal in that dialect (? in Unix, a missing/duplicate ? in
Quartz/AWS), a malformed step, or an unknown dialect — parseCron throws a
CronParseError (also exported). It never silently accepts garbage.
How to run the tests
cd tools/cron-dialect
npm testThat single command runs four files of hand-verified golden vectors, in order:
parse-vectors— each dialect, the day-of-week numbering differences, the?rules, and every honest-error case.translate-vectors— cross-dialecttranslate(including the injected seconds/?and the day-of-week renumbering warnings) andexplain's plain-English readings.schedule-vectors—nextRuns: quarter-hour and month-boundary walks, the Quartz seconds field, the 1=Sun vs 0=Sun date-difference case, timezone matching, and the spring-forward/fall-back DST rules.english-vectors—fromEnglish: every accepted grammar form across the dialects, and the honest{ error, nearest }failures for unparseable input.
Each file prints ok — N assertions passed and exits non-zero on any mismatch,
so npm test fails fast on the first bad file. No network, no filesystem, no
dependencies — pure Node, runnable offline.
License
MIT.
