cron-lab
v1.0.0
Published
Evaluate, schedule and explain cron expressions. No dependencies, TypesScript
Maintainers
Readme
Cron lab
A comprehensive TypeScript package for working with cron expressions — parse, build, validate, describe in 18 languages, compute occurrences with full timezone support, analyse schedules, and run a scheduling daemon.
Table of contents
Installation
# Install the package
npm install cron-labThe package has no runtime dependencies. TypeScript 5+ and a Node.js 18+ runtime are required.
Quick start
import {
parseCron,
CronBuilder,
isValid,
isReachable,
matchesDate,
nextOccurrence,
nextOccurrences,
getOccurrencesInRange,
normalizeCron,
getFrequency,
compareCron,
diffCron,
describeCron,
createCronDaemon,
} from 'cron-lab';
// 1. Parse
const expr = parseCron('0 9 * * 1-5', { timezone: 'Europe/Paris' });
// 2. Build programmatically
const built = CronBuilder.create()
.atMinute(0).atHour(9).onWeekdays()
.inTimezone('Europe/Paris')
.build();
// 3. Validate
isValid('0 9 * * 1-5'); // true
isValid('60 * * * *'); // false
isReachable(parseCron('0 0 31 2 *')); // { reachable: false, reason: 'impossible-month-day-combo' }
// 4. Test a date
matchesDate(expr, new Date());
// 5. Compute occurrences
nextOccurrence(expr, new Date());
nextOccurrences(expr, new Date(), 5);
getOccurrencesInRange(expr, new Date('2024-04-01'), new Date('2024-04-30'));
// 6. Describe in any language
describeCron(expr, 'fra'); // "À 09:00, du lundi au vendredi"
describeCron(expr, 'eng'); // "At 09:00, from Monday to Friday"
describeCron(expr, 'jpn'); // "09:00に, 月曜日から金曜日まで"
// 7. Analyse
getFrequency(expr);
compareCron(parseCron('0 9 * * *'), expr);
diffCron(parseCron('0 9 * * *'), expr, new Date('2024-04-20'), new Date('2024-04-28'));
// 8. Daemon
const daemon = createCronDaemon();
daemon.register('report', expr);
daemon.on('fire', (id) => console.log(`Running: ${id}`)); // Will print "Running: report" at the expected time
daemon.start();Modules
Parser
parseCron(expression, options?)
Parses a 5-field cron expression or a @ alias into a structured CronExpression object.
function parseCron(expression: string, options?: ParseCronOptions): CronExpressionParseCronOptions
| Property | Type | Description |
|---|---|---|
| timezone | string | IANA timezone identifier (e.g. "Europe/Paris"). Validated immediately — throws RangeError if unknown. |
Throws Error for invalid syntax (wrong field count, out-of-range values, malformed step/range).
Examples
parseCron('* * * * *');
parseCron('0 9 * * 1-5');
parseCron('*/15 8,20 * jan-mar mon,fri');
parseCron('@daily');
parseCron('@hourly');
parseCron('0 9 * * 1-5', { timezone: 'America/New_York' });Supported @ aliases
| Alias | Equivalent | Meaning |
|---|---|---|
| @yearly / @annually | 0 0 1 1 * | Once a year, Jan 1 at midnight |
| @monthly | 0 0 1 * * | Once a month, 1st at midnight |
| @weekly | 0 0 * * 0 | Once a week, Sunday at midnight |
| @daily / @midnight | 0 0 * * * | Once a day at midnight |
| @hourly | 0 * * * * | Once an hour, at :00 |
Builder
CronBuilder provides a fluent, type-safe API for constructing cron expressions programmatically — no risk of syntax errors.
CronBuilder.create()
Creates a new builder initialised to * * * * *.
CronBuilder.from(expr)
Creates a builder pre-populated from an existing CronExpression. Useful for deriving modified copies.
Minute setters
| Method | Expression | Description |
|---|---|---|
| atMinute(0) | 0 … | Specific minute(s) |
| atMinute(0, 15, 30, 45) | 0,15,30,45 … | Multiple minutes |
| everyMinutes(15) | */15 … | Every N minutes |
| minuteRange(0, 30) | 0-30 … | Minute range |
| everyMinute() | * … | Reset to wildcard |
Hour setters
| Method | Expression | Description |
|---|---|---|
| atHour(9) | … 9 … | Specific hour(s) |
| atHour(8, 20) | … 8,20 … | Multiple hours |
| everyHours(6) | … */6 … | Every N hours |
| hourRange(9, 17) | … 9-17 … | Hour range |
| everyHour() | … * … | Reset to wildcard |
Day-of-month setters
| Method | Expression | Description |
|---|---|---|
| onDay(1) | … 1 … | Specific day(s) |
| onDay(1, 15) | … 1,15 … | Multiple days |
| everyDays(5) | … */5 … | Every N days |
| dayRange(1, 15) | … 1-15 … | Day range |
| everyDayOfMonth() | … * … | Reset to wildcard |
Month setters
| Method | Expression | Description |
|---|---|---|
| inMonth(1) | … 1 … | Specific month(s) |
| inMonth(3, 6, 9, 12) | … 3,6,9,12 … | Multiple months |
| everyMonths(3) | … */3 … | Every N months |
| monthRange(3, 9) | … 3-9 … | Month range |
| everyMonth() | … * … | Reset to wildcard |
Day-of-week setters
| Method | Expression | Description |
|---|---|---|
| onWeekday(1) | … 1 | Specific weekday (0=Sun…6=Sat) |
| onWeekday(1, 3, 5) | … 1,3,5 | Multiple weekdays |
| onWeekdays() | … 1-5 | Monday–Friday |
| onWeekend() | … 0,6 | Saturday and Sunday |
| weekdayRange(1, 5) | … 1-5 | Weekday range |
| everyDayOfWeek() | … * | Reset to wildcard |
Timezone
builder.inTimezone('Europe/Paris') // attach IANA timezone (throws if unknown)
builder.inLocalTime() // remove timezone (use system local time)Presets
CronBuilder.create().daily() // 0 0 * * *
CronBuilder.create().hourly() // 0 * * * *
CronBuilder.create().weekly() // 0 0 * * 0
CronBuilder.create().monthly() // 0 0 1 * *
CronBuilder.create().yearly() // 0 0 1 1 *Generic setter
builder.set('minute', 0) // value
builder.set('minute', [0, 15, 30, 45]) // list of values
builder.set('minute', { step: 15 }) // step from *
builder.set('minute', { step: 5, from: 0 }) // step from value
builder.set('dayOfWeek', { range: [1, 5] }) // rangeTerminal methods
builder.build() // → CronExpression
builder.toRawString() // → "0 9 * * 1-5"
builder.toString() // same as toRawString()Full example
const expr = CronBuilder.create()
.everyMinutes(15)
.hourRange(9, 17)
.onWeekdays()
.inMonth(1, 2, 3)
.inTimezone('America/New_York')
.build();
// → "*/15 9-17 * 1,2,3 1-5" anchored to America/New_YorkValidator
isValid(expression)
Returns true if the expression is syntactically valid. Never throws.
isValid('0 9 * * 1-5'); // true
isValid('@daily'); // true
isValid('60 * * * *'); // false — minute out of range
isValid('* * * *'); // false — only 4 fieldsmatchesDate(expr, date)
Returns true if date (to the minute, seconds ignored) satisfies the expression.
const expr = parseCron('0 9 * * 1-5');
matchesDate(expr, new Date('2024-04-22T09:00:30')); // true — Mon 09:00
matchesDate(expr, new Date('2024-04-22T09:01:00')); // false — 09:01
matchesDate(expr, new Date('2024-04-20T09:00:00')); // false — SaturdayisReachable(expr)
Determines whether the expression can ever fire on a real calendar date, by checking for impossible day-of-month / month combinations.
function isReachable(expr: CronExpression): ReachabilityResultisReachable(parseCron('0 0 31 2 *'));
// → { reachable: false, reason: 'impossible-month-day-combo',
// message: 'Day 31 never occurs in February' }
isReachable(parseCron('0 0 29 2 *'));
// → { reachable: true } — Feb 29 exists on leap years
isReachable(parseCron('0 0 31 4 *'));
// → { reachable: false } — April has only 30 days
isReachable(parseCron('0 0 31 1,3 *'));
// → { reachable: true } — January and March both have 31 daysReachabilityResult
| Property | Type | Description |
|---|---|---|
| reachable | boolean | Whether a valid date exists |
| reason | UnreachableReason? | Machine-readable reason code |
| message | string? | Human-readable explanation |
Scheduler
All functions honour expr.timezone when present — see Timezone support.
nextOccurrence(expr, from?, searchWindowMs?)
Returns the first occurrence strictly after from (default to now), or null if none is found within the search window (default: 1 year).
nextOccurrence(parseCron('0 9 * * 1-5'), new Date('2024-04-19T10:00:00'));
// → Mon Apr 22 2024 09:00:00
// Narrow the window to 24 hours
nextOccurrence(parseCron('0 9 * * 1-5'), new Date('2024-04-22T10:00:00'), 24 * 60 * 60 * 1000);
// → Tue Apr 23 2024 09:00:00previousOccurrence(expr, from?, searchWindowMs?)
Returns the last occurrence strictly before from (default to now), or null if none is found.
previousOccurrence(parseCron('0 9 * * 1-5'), new Date('2024-04-22T10:00:00'));
// → Mon Apr 22 2024 09:00:00
previousOccurrence(parseCron('0 9 * * 1-5'), new Date('2024-04-22T08:30:00'));
// → Fri Apr 19 2024 09:00:00nextOccurrences(expr, from, count, searchWindowMs?)
Returns the next count occurrences after from in ascending order.
nextOccurrences(parseCron('0 9 * * 1-5'), new Date('2024-04-19T10:00:00'), 3);
// → [Mon Apr 22, Tue Apr 23, Wed Apr 24] all at 09:00previousOccurrences(expr, from, count, searchWindowMs?)
Returns the last count occurrences before from in descending order.
previousOccurrences(parseCron('0 9 * * 1-5'), new Date('2024-04-22T10:00:00'), 3);
// → [Mon Apr 22, Fri Apr 19, Thu Apr 18] all at 09:00getOccurrencesInRange(expr, start, end)
Returns all occurrences in the closed interval [start, end].
getOccurrencesInRange(
parseCron('0 9 * * 1-5'),
new Date('2024-04-22T00:00:00'),
new Date('2024-04-26T23:59:59'),
);
// → [Mon Apr 22, Tue Apr 23, Wed Apr 24, Thu Apr 25, Fri Apr 26] at 09:00Analyzer
normalizeCron(expr)
Returns the canonical 5-field string — @ aliases expanded, text aliases resolved, leading zeros stripped.
normalizeCron(parseCron('@daily')); // "0 0 * * *"
normalizeCron(parseCron('00 09 * * 1-5')); // "0 9 * * 1-5"
normalizeCron(parseCron('0 9 * jan mon')); // "0 9 * 1 1"getFrequency(expr)
Analyses the firing frequency of an expression over a 4-week sample window.
Note: this feature is still experimental, the results are not perfect and depends a lot on the analyze window size
function getFrequency(expr: CronExpression): FrequencyInfoFrequencyInfo
| Property | Type | Description |
|---|---|---|
| minIntervalMinutes | number | Shortest gap between any two consecutive occurrences |
| avgIntervalMinutes | number | Average gap over the 4-week window |
| occurrencesPerDay | number | Average occurrences per day |
| label | FrequencyLabel | "minutely" · "hourly" · "daily" · "weekly" · "monthly" · "yearly" · "rare" |
| warnings | FrequencyWarning[] | Advisory warnings (see below) |
Warnings
| Code | Triggered when |
|---|---|
| impossible-date | dayOfMonth + month combo can never occur on a real date |
| dow-dom-or-semantics | Both dayOfMonth and dayOfWeek restricted — OR semantics may surprise |
| no-occurrences | No occurrence found in the 4-week sample window |
| very-high-frequency | Minimum interval is less than 1 minute |
getFrequency(parseCron('*/15 * * * *'));
// → { minIntervalMinutes: 15, occurrencesPerDay: 96, label: 'minutely', warnings: [] }
getFrequency(parseCron('0 0 31 2 *'));
// → { ..., warnings: [{ code: 'impossible-date', message: '…' }] }
getFrequency(parseCron('0 0 1 * 1'));
// → { ..., warnings: [{ code: 'dow-dom-or-semantics', message: '…' }] }compareCron(exprA, exprB)
Compares two expressions over a 4-week sample window.
function compareCron(exprA: CronExpression, exprB: CronExpression): CompareResultCompareResult
| Property | Type | Description |
|---|---|---|
| equivalent | boolean | Both produce identical occurrences |
| relation | string | "identical" · "a-subset-of-b" · "b-subset-of-a" · "disjoint" · "overlap" |
| sharedCount | number | Occurrences present in both |
| onlyInA | number | Occurrences only in A |
| onlyInB | number | Occurrences only in B |
compareCron(parseCron('0 9 * * *'), parseCron('0 9 * * 1-5'));
// → { equivalent: false, relation: 'b-subset-of-a', onlyInA: 8, onlyInB: 0, sharedCount: 20 }mergeCron(exprA, exprB)
Merges two expressions into a single one that fires on every occurrence of both. The result is a superset — it may fire on additional instants when a clean field-level merge isn't possible.
normalizeCron(mergeCron(parseCron('0 9 * * 1'), parseCron('0 9 * * 5')));
// → "0 9 * * 1,5"
normalizeCron(mergeCron(parseCron('0 8 * * *'), parseCron('0 17 * * *')));
// → "0 8,17 * * *"getOverlaps(exprs)
Detects every minute within a 4-week window where two or more expressions fire simultaneously.
function getOverlaps(exprs: Map<string, CronExpression>): OverlapGroup[]getOverlaps(new Map([
['backup', parseCron('0 2 * * *')],
['report', parseCron('0 2 * * 1')], // overlaps with backup every Monday
['cleanup', parseCron('0 3 * * *')],
]));
// → [{ at: <Mon 02:00>, ids: ['backup', 'report'] }, …]diffCron(exprA, exprB, start, end)
Lists every occurrence within [start, end] that belongs to one expression but not the other, sorted chronologically.
function diffCron(
exprA: CronExpression,
exprB: CronExpression,
start: Date,
end: Date,
): DiffEntry[]DiffEntry
| Property | Type | Description |
|---|---|---|
| at | Date | The occurrence date |
| source | "a" \| "b" | Which expression owns it |
diffCron(
parseCron('0 9 * * *'), // every day
parseCron('0 9 * * 1-5'), // weekdays only
new Date('2024-04-20'),
new Date('2024-04-28'),
);
// → [
// { at: Sat Apr 20 09:00, source: 'a' },
// { at: Sun Apr 21 09:00, source: 'a' },
// { at: Sat Apr 27 09:00, source: 'a' },
// { at: Sun Apr 28 09:00, source: 'a' },
// ]Daemon
The daemon manages a registry of named cron expressions and emits a "fire" event at each occurrence. There is no callback passed to start — all job dispatch goes through on("fire", listener).
createCronDaemon()
function createCronDaemon(): CronDaemonCronDaemon methods
| Method | Description |
|---|---|
| register(id, expr) | Add or replace an entry; next occurrence is computed immediately |
| unregister(id) | Remove an entry; timer adjusts if it was the next scheduled one |
| pause(id) | Suspend an entry — it stays in the registry but is skipped |
| resume(id) | Unsuspend an entry; next occurrence is recomputed |
| start() | Start the daemon (idempotent — second call while running is a no-op) |
| stop() | Cancel the timer and clear history |
| on(event, listener) | Subscribe to an event; returns an unsubscribe function |
| history(n?, id?) | Last N fire records, newest first |
| entries() | Read-only array of registered entries with nextAt |
Lifecycle
const daemon = createCronDaemon();
daemon.register('backup', parseCron('0 2 * * *'));
daemon.register('report', parseCron('0 9 * * 1-5', { timezone: 'Europe/Paris' }));
const offFire = daemon.on('fire', (id) => runJob(id));
const offError = daemon.on('error', (id, err) => console.error(id, err));
daemon.start();
// Dynamic management while running
daemon.pause('report');
daemon.resume('report');
daemon.unregister('backup');
daemon.register('new-job', parseCron('@hourly'));
// Inspect
daemon.entries(); // all registered entries with nextAt and paused status
daemon.history(10, 'report'); // last 10 fire records for "report"
// Shutdown — clears timer and history
daemon.stop();
// Detach listeners
offFire();
offError();Events
| Event | When | detail argument |
|---|---|---|
| "fire" | At each scheduled occurrence | — |
| "error" | When a "fire" listener throws | The caught error |
Each "fire" listener is called independently — a throwing listener emits "error" but does not prevent the remaining "fire" listeners from running on the same tick.
CronHistoryEntry
| Property | Type | Description |
|---|---|---|
| id | CronId | Entry identifier |
| firedAt | Date | Timestamp of the fire event |
| durationMs | number | Combined wall-clock time of all "fire" listeners |
History is capped at 1 000 entries and cleared when stop() is called.
Describer
Renders a CronExpression as a human-readable sentence in one of 18 languages.
describeCron(expr, locale)
function describeCron(expr: CronExpression, locale: string|CronLocalization): stringThrows if locale is not one of the supported ISO-639-1 or ISO-639-2 codes.
const expr = parseCron('0 9 * * 1-5');
describeCron(expr, 'fra'); // "À 09:00, du lundi au vendredi"
describeCron(expr, 'eng'); // "At 09:00, from Monday to Friday"
describeCron(expr, 'deu'); // "Um 09:00 Uhr, von Montag bis Freitag"
describeCron(expr, 'spa'); // "A las 09:00, de lunes a viernes"
describeCron(expr, 'jpn'); // "09:00に, 月曜日から金曜日まで"
describeCron(expr, 'ara'); // "الساعة 09:00, من الاثنين إلى الجمعة"Supported locales
| Code | Language | Code | Language |
|---|---|---|---|
| afr, af | Afrikaans | jpn, jp | Japanese |
| ara, ar | Arabic | nld, nl | Dutch |
| ben, bn | Bengali | por, pt | Portuguese |
| deu, de | German | rus, ru | Russian |
| ell, el | Greek | spa, sp | Spanish |
| eng, en | English | urd, ur | Urdu |
| epo, eo | Esperanto | zho, zh | Chinese (Simplified) |
| fra, fr | French | heb, he | Hebrew |
| hin, hi | Hindi | ita, it | Italian |
Custom locales
Supply your own CronLocalization object to support any language:
import { describeCron, CronLocalization } from './index';
const MY_LOCALE: CronLocalization = { /* … */ };
describeCron(expr, MY_LOCALE);The built-in locale instances (FR_LOCALE, EN_LOCALE, DE_LOCALE, …) and the full LOCALES registry are exported for reference or extension.
Timezone support
When a CronExpression carries a timezone property, all occurrence calculations interpret the five cron fields as wall-clock times in that timezone. The Date objects returned are always standard UTC-based values — only the interpretation of the fields changes.
// "Fire at 09:00 Paris time every weekday"
const expr = parseCron('0 9 * * 1-5', { timezone: 'Europe/Paris' });
// Winter (UTC+1): fires at 08:00 UTC
// Summer (UTC+2): fires at 07:00 UTC
const next = nextOccurrence(expr, new Date());The timezone is respected by matchesDate, nextOccurrence, previousOccurrence, getOccurrencesInRange, getFrequency, compareCron, diffCron, and the daemon.
Setting a timezone via the builder:
CronBuilder.create()
.atMinute(0).atHour(9).onWeekdays()
.inTimezone('America/New_York')
.build();Valid values are any IANA identifiers recognised by the runtime's Intl implementation: "UTC", "Europe/Paris", "America/New_York", "Asia/Tokyo", etc.
Type reference
CronExpression
interface CronExpression {
raw: string; // original expression string
timezone?: string|undefined; // optional IANA timezone
minute: CronField; // 0–59
hour: CronField; // 0–23
dayOfMonth: CronField; // 1–31
month: CronField; // 1–12
dayOfWeek: CronField; // 0–7 (0 and 7 = Sunday)
}CronField
Discriminated union of five variants:
type CronField =
| { type: 'all' } // *
| { type: 'value'; values: number[] } // 1,5,10
| { type: 'range'; from: number; to: number } // 1-5
| { type: 'step'; base: '*' | number; step: number } // */15 or 2/3
| { type: 'list'; items: CronField[] } // 1,3-5,*/2FrequencyInfo
interface FrequencyInfo {
minIntervalMinutes: number;
avgIntervalMinutes: number;
occurrencesPerDay: number;
label: FrequencyLabel;
warnings: FrequencyWarning[];
}CompareResult
interface CompareResult {
equivalent: boolean;
relation: 'identical' | 'a-subset-of-b' | 'b-subset-of-a' | 'disjoint' | 'overlap';
sharedCount: number;
onlyInA: number;
onlyInB: number;
}ReachabilityResult
interface ReachabilityResult {
reachable: boolean;
reason?: 'impossible-month-day-combo';
message?: string;
}Cron syntax
┌─────────── minute (0–59)
│ ┌───────── hour (0–23)
│ │ ┌─────── day of month (1–31)
│ │ │ ┌───── month (1–12)
│ │ │ │ ┌─── day of week (0–7, 0 and 7 = Sunday)
│ │ │ │ │
* * * * *| Syntax | Example | Meaning |
|---|---|---|
| Wildcard | * | Every value |
| Single value | 5 | At 5 only |
| List | 1,3,5 | At 1, 3, and 5 |
| Range | 1-5 | From 1 to 5 inclusive |
| Step on wildcard | */15 | Every 15 units |
| Step on value | 2/3 | Every 3 units starting at 2 |
| Step on range | 1-30/5 | Every 5 units from 1 to 30 |
| Combination | 0,*/15 | At 0 and every 15 units |
Month names (case-insensitive): jan feb mar apr may jun jul aug sep oct nov dec
Day names (case-insensitive): sun mon tue wed thu fri sat
Day-of-month and day-of-week interaction
When both dayOfMonth and dayOfWeek are restricted (neither is *), standard cron applies a logical OR: the job fires if either condition is satisfied. When only one is restricted, it acts as the sole filter. getFrequency emits a dow-dom-or-semantics warning when it detects this situation.
