Skip to content

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 →