Cron Expression Explainer — TypeScript source
Translate any 5-field cron expression into plain English, build one field-by-field, and preview the next time it will fire. Supports steps, ranges, lists, and named days/months. Runs 100% in your browser.
This is the TypeScript implementation — the same logic the interactive tool runs, in a shareable, citable form.
// Pure 5-field cron parser, natural-language explainer, builder, and next-run
// calculator. Zero deps. Deterministic. Times are interpreted as UTC so results
// are unambiguous and DST-independent (the caller controls the instant).
export type CronFieldName =
| 'minute'
| 'hour'
| 'day-of-month'
| 'month'
| 'day-of-week';
export interface CronFieldInfo {
field: CronFieldName;
/** Raw field value as written in the expression. */
value: string;
/** Human-readable description of what this field matches. */
meaning: string;
}
export interface CronExplanation {
valid: boolean;
/** Full natural-language description, or '' when invalid. */
description: string;
/** One entry per field (empty when invalid). */
fields: CronFieldInfo[];
/** Present only when `valid` is false. */
error?: string;
}
export interface BuildCronOptions {
minute?: string;
hour?: string;
dom?: string;
month?: string;
dow?: string;
}
interface FieldMeta {
name: CronFieldName;
label: string;
min: number;
max: number;
named: boolean;
/** When true, the max value wraps to min (dow: 7 → 0 / Sunday). */
wrapMax: boolean;
}
interface ParsedField {
meta: FieldMeta;
raw: string;
values: number[];
wildcard: boolean;
}
const MONTH_NAMES = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December',
];
const DOW_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
const MONTH_TOKENS: Record<string, number> = {
JAN: 1, FEB: 2, MAR: 3, APR: 4, MAY: 5, JUN: 6,
JUL: 7, AUG: 8, SEP: 9, OCT: 10, NOV: 11, DEC: 12,
};
const DOW_TOKENS: Record<string, number> = {
SUN: 0, MON: 1, TUE: 2, WED: 3, THU: 4, FRI: 5, SAT: 6,
};
const FIELDS: FieldMeta[] = [
{ name: 'minute', label: 'minute', min: 0, max: 59, named: false, wrapMax: false },
{ name: 'hour', label: 'hour', min: 0, max: 23, named: false, wrapMax: false },
{ name: 'day-of-month', label: 'day-of-month', min: 1, max: 31, named: false, wrapMax: false },
{ name: 'month', label: 'month', min: 1, max: 12, named: true, wrapMax: false },
{ name: 'day-of-week', label: 'day-of-week', min: 0, max: 7, named: true, wrapMax: true },
];
function monthName(m: number): string {
return MONTH_NAMES[m - 1];
}
function dowName(d: number): string {
return DOW_NAMES[d % 7];
}
function pad2(n: number): string {
return String(n).padStart(2, '0');
}
function range(lo: number, hi: number): number[] {
const out: number[] = [];
for (let i = lo; i <= hi; i++) out.push(i);
return out;
}
function parseIntStrict(s: string, label: string): number {
const t = s.trim();
if (!/^\d+$/.test(t)) throw new Error(`${label}: invalid number "${s}"`);
return Number(t);
}
/** Replace named tokens (JAN..DEC / SUN..SAT) with their numeric values. */
function normalize(value: string, meta: FieldMeta): string {
let v = value.trim().toUpperCase();
if (!meta.named) return v;
const tokens = meta.name === 'month' ? MONTH_TOKENS : DOW_TOKENS;
for (const [tok, num] of Object.entries(tokens)) {
v = v.split(tok).join(String(num));
}
return v;
}
/** Expand one field value into the explicit set of numbers it matches. */
function expandField(value: string, meta: FieldMeta): { values: number[]; wildcard: boolean } {
const norm = normalize(value, meta);
if (norm === '') throw new Error(`${meta.label}: empty field`);
if (norm === '*') return { values: range(meta.min, meta.max), wildcard: true };
const out = new Set<number>();
for (const term of norm.split(',')) {
if (term === '') throw new Error(`${meta.label}: empty list item`);
const slashIdx = term.indexOf('/');
let base = term;
let step = 1;
if (slashIdx !== -1) {
base = term.slice(0, slashIdx);
const stepStr = term.slice(slashIdx + 1);
step = parseIntStrict(stepStr, meta.label);
if (step <= 0) throw new Error(`${meta.label}: step must be a positive number`);
}
let lo: number;
let hi: number;
if (base === '*') {
lo = meta.min;
hi = meta.max;
} else if (base.includes('-')) {
const dashIdx = base.indexOf('-');
lo = parseIntStrict(base.slice(0, dashIdx), meta.label);
hi = parseIntStrict(base.slice(dashIdx + 1), meta.label);
} else {
lo = parseIntStrict(base, meta.label);
// "A/step" runs from A to the field max; a bare "A" is a single value.
hi = slashIdx !== -1 ? meta.max : lo;
}
if (lo > hi) throw new Error(`${meta.label}: range start ${lo} is greater than end ${hi}`);
if (lo < meta.min) throw new Error(`${meta.label}: value ${lo} is below minimum ${meta.min}`);
if (hi > meta.max) throw new Error(`${meta.label}: value ${hi} is above maximum ${meta.max}`);
for (let v = lo; v <= hi; v += step) {
out.add(meta.wrapMax && v === meta.max ? meta.min : v);
}
}
return { values: [...out].sort((a, b) => a - b), wildcard: false };
}
function parseExpr(expr: string): { ok: true; parts: ParsedField[] } | { ok: false; error: string } {
const tokens = expr.trim().split(/\s+/).filter(Boolean);
if (tokens.length !== 5) {
return { ok: false, error: `Expected 5 fields (minute hour day-of-month month day-of-week), got ${tokens.length}` };
}
const parts: ParsedField[] = [];
for (let i = 0; i < 5; i++) {
try {
const expanded = expandField(tokens[i], FIELDS[i]);
parts.push({ meta: FIELDS[i], raw: tokens[i], ...expanded });
} catch (e) {
return { ok: false, error: e instanceof Error ? e.message : `Invalid ${FIELDS[i].name} field` };
}
}
return { ok: true, parts };
}
function isContiguous(values: number[]): boolean {
for (let i = 1; i < values.length; i++) {
if (values[i] - values[i - 1] !== 1) return false;
}
return true;
}
/** Describe a single value in the field's vocabulary. */
function singleValue(n: number, meta: FieldMeta): string {
switch (meta.name) {
case 'minute': return `minute ${n}`;
case 'hour': return `hour ${n}`;
case 'day-of-month': return `day ${n} of the month`;
case 'month': return monthName(n);
case 'day-of-week': return dowName(n);
}
}
/**
* Describe a parsed field as a human phrase (no leading preposition).
* `raw` is consulted to distinguish step syntax (star/N or A-B/N) from lists.
*/
function describeField(p: ParsedField): string {
const { meta, raw, values } = p;
if (p.wildcard) {
switch (meta.name) {
case 'minute': return 'every minute';
case 'hour': return 'every hour';
case 'day-of-month': return 'every day of the month';
case 'month': return 'every month';
case 'day-of-week': return 'every day of the week';
}
}
// Step syntax: report as "every N …".
if (raw.includes('/') && values.length >= 1) {
const step = parseIntStrict(raw.slice(raw.indexOf('/') + 1), meta.label);
const start = values[0];
const unitPlural =
meta.name === 'day-of-month' ? 'days of the month'
: meta.name === 'day-of-week' ? 'days of the week'
: `${meta.name}s`;
if (start === meta.min) {
return `every ${step} ${unitPlural}`;
}
return `every ${step} ${unitPlural} starting at ${singleValue(start, meta)}`;
}
if (values.length === 1) return singleValue(values[0], meta);
if (isContiguous(values)) {
const a = values[0];
const b = values[values.length - 1];
if (meta.name === 'month') return `${monthName(a)} through ${monthName(b)}`;
if (meta.name === 'day-of-week') return `${dowName(a)} through ${dowName(b)}`;
const unitPlural =
meta.name === 'day-of-month' ? 'days' : `${meta.name}s`;
return `${unitPlural} ${a} through ${b}`;
}
// Explicit list.
if (meta.name === 'month') return values.map(monthName).join(', ');
if (meta.name === 'day-of-week') return values.map(dowName).join(', ');
if (meta.name === 'minute') return `minutes ${values.join(', ')}`;
if (meta.name === 'hour') return `hours ${values.join(', ')}`;
return `days ${values.join(', ')} of the month`;
}
/** Prepend a preposition, but never before a phrase that already starts with "every". */
function prepend(prefix: string, phrase: string): string {
return phrase.startsWith('every') ? phrase : `${prefix} ${phrase}`;
}
function timeClause(minute: ParsedField, hour: ParsedField): string {
const mAll = minute.wildcard;
const hAll = hour.wildcard;
const mSingle = !mAll && minute.values.length === 1;
const hSingle = !hAll && hour.values.length === 1;
if (mAll && hAll) return 'Every minute';
if (mAll && hSingle) return `Every minute of hour ${hour.values[0]}`;
if (mSingle && hAll) return `At minute ${minute.values[0]} of every hour`;
if (mSingle && hSingle) return `At ${pad2(hour.values[0])}:${pad2(minute.values[0])}`;
// Mixed: describe each non-wildcard field.
const clauses: string[] = [];
if (!hAll) clauses.push(describeField(hour));
if (!mAll) clauses.push(describeField(minute));
const s = clauses.join(', ');
return s.charAt(0).toUpperCase() + s.slice(1);
}
function composeDescription(parts: ParsedField[]): string {
const [minute, hour, dom, month, dow] = parts;
const clauses: string[] = [timeClause(minute, hour)];
if (!dom.wildcard) clauses.push(prepend('on', describeField(dom)));
if (!month.wildcard) clauses.push(prepend('in', describeField(month)));
if (!dow.wildcard) clauses.push(prepend('on', describeField(dow)));
return clauses.join(', ');
}
/**
* Parse and explain a 5-field cron expression in plain English.
*
* @example
* explainCron('30 14 * * *').description // 'At 14:30'
*/
export function explainCron(expr: string): CronExplanation {
const res = parseExpr(expr);
if (!res.ok) {
return { valid: false, description: '', fields: [], error: res.error };
}
const fields: CronFieldInfo[] = res.parts.map((p) => ({
field: p.meta.name,
value: p.raw,
meaning: describeField(p),
}));
return {
valid: true,
description: composeDescription(res.parts),
fields,
};
}
/**
* Assemble a 5-field cron expression from user-friendly per-field specs.
* Each field defaults to `*` when empty/omitted; invalid fields throw.
*
* @example
* buildCron({ minute: '30', hour: '14' }) // '30 14 * * *'
*/
export function buildCron(opts: BuildCronOptions): string {
const specs: Array<{ meta: FieldMeta; value?: string }> = [
{ meta: FIELDS[0], value: opts.minute },
{ meta: FIELDS[1], value: opts.hour },
{ meta: FIELDS[2], value: opts.dom },
{ meta: FIELDS[3], value: opts.month },
{ meta: FIELDS[4], value: opts.dow },
];
const out: string[] = [];
for (const s of specs) {
const v = (s.value ?? '').trim();
if (v === '') {
out.push('*');
continue;
}
expandField(v, s.meta); // validates; throws on bad input
out.push(v);
}
return out.join(' ');
}
/**
* Next time the expression fires, strictly after `after`, evaluated in UTC.
* Implements standard Vixie-cron day matching: when BOTH day-of-month and
* day-of-week are restricted, a match on either suffices; otherwise both must
* match. Returns null if no firing occurs within ~2 years.
*/
export function nextRun(expr: string, after: Date): Date | null {
const res = parseExpr(expr);
if (!res.ok) return null;
const [minute, hour, dom, month, dow] = res.parts;
const mSet = new Set(minute.values);
const hSet = new Set(hour.values);
const domSet = new Set(dom.values);
const monSet = new Set(month.values);
const dowSet = new Set(dow.values);
const domWild = dom.wildcard;
const dowWild = dow.wildcard;
// Start at the top of the minute following `after` (UTC).
const cur = new Date(
Date.UTC(after.getUTCFullYear(), after.getUTCMonth(), after.getUTCDate(), after.getUTCHours(), after.getUTCMinutes(), 0, 0),
);
cur.setUTCMinutes(cur.getUTCMinutes() + 1);
const limit = cur.getUTCFullYear() + 3; // hard stop ~3 years out
while (cur.getUTCFullYear() < limit) {
if (!monSet.has(cur.getUTCMonth() + 1)) {
cur.setUTCMonth(cur.getUTCMonth() + 1, 1);
cur.setUTCHours(0, 0, 0, 0);
continue;
}
const domOk = domSet.has(cur.getUTCDate());
const dowOk = dowSet.has(cur.getUTCDay());
const dayOk = domWild || dowWild ? domOk && dowOk : domOk || dowOk;
if (!dayOk) {
cur.setUTCDate(cur.getUTCDate() + 1);
cur.setUTCHours(0, 0, 0, 0);
continue;
}
if (!hSet.has(cur.getUTCHours())) {
cur.setUTCHours(cur.getUTCHours() + 1, 0, 0, 0);
continue;
}
if (!mSet.has(cur.getUTCMinutes())) {
cur.setUTCMinutes(cur.getUTCMinutes() + 1, 0, 0);
continue;
}
return new Date(cur.getTime());
}
return null;
}
Also available in 13 other languages
Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →