DEV3 Technologies home

Cron expression parser with next run times

Parse a five-field cron expression with lists, ranges and steps, then compute its upcoming run times in plain TypeScript.

React / Next.jsUtilitiescronschedulerparsertypescript

Code

type Spec = { min: number; max: number };
const FIELDS: Spec[] = [{ min: 0, max: 59 }, { min: 0, max: 23 }, { min: 1, max: 31 }, { min: 1, max: 12 }, { min: 0, max: 7 }];

// One field -> lookup table indexed by value.
function parseField(src: string, { min, max }: Spec): boolean[] {
  const on = new Array<boolean>(max + 1).fill(false);
  for (const part of src.split(',')) {
    const [range = '', stepText] = part.split('/');
    const step = stepText === undefined ? 1 : Number(stepText);
    if (!Number.isInteger(step) || step < 1) throw new Error(`Bad step in "${part}"`);
    let from = min;
    let to = max;
    if (range !== '*') {
      const [a, b] = range.split('-');
      from = Number(a);
      to = b === undefined ? (stepText === undefined ? from : max) : Number(b);
    }
    if (!Number.isInteger(from) || !Number.isInteger(to) || from < min || to > max || from > to) {
      throw new Error(`Value out of range in "${part}"`);
    }
    for (let v = from; v <= to; v += step) on[v] = true;
  }
  return on;
}

export function parseCron(expr: string) {
  const parts = expr.trim().split(/\s+/);
  if (parts.length !== 5) throw new Error('Expected 5 fields: minute hour day month weekday');
  const [minute, hour, dom, month, dow] = parts.map((p, i) => parseField(p, FIELDS[i]!)) as boolean[][];
  if (dow![7]) dow![0] = true; // 7 is Sunday too
  return { minute: minute!, hour: hour!, dom: dom!, month: month!, dow: dow!, domAny: parts[2] === '*', dowAny: parts[4] === '*' };
}

export function nextRuns(expr: string, from = new Date(), count = 5): Date[] {
  const c = parseCron(expr);
  const out: Date[] = [];
  const d = new Date(from);
  d.setSeconds(0, 0);
  d.setMinutes(d.getMinutes() + 1);
  const limit = d.getTime() + 5 * 366 * 864e5;

  while (out.length < count && d.getTime() < limit) {
    const dayOk = c.domAny && c.dowAny ? true
      : c.domAny ? c.dow[d.getDay()]!
      : c.dowAny ? c.dom[d.getDate()]!
      : c.dom[d.getDate()]! || c.dow[d.getDay()]!; // both restricted: either matches
    if (!c.month[d.getMonth() + 1]) { d.setMonth(d.getMonth() + 1, 1); d.setHours(0, 0, 0, 0); continue; }
    if (!dayOk) { d.setDate(d.getDate() + 1); d.setHours(0, 0, 0, 0); continue; }
    if (!c.hour[d.getHours()]) { d.setHours(d.getHours() + 1, 0, 0, 0); continue; }
    if (!c.minute[d.getMinutes()]) { d.setMinutes(d.getMinutes() + 1); continue; }
    out.push(new Date(d));
    d.setMinutes(d.getMinutes() + 1);
  }
  return out;
}

How it works

What it solves

Cron strings are compact and easy to get wrong. Showing the next five run times beside an input field lets users confirm what */15 9-17 * * 1-5 really means before saving a schedule, and a dependency-free parser makes that possible in any TypeScript project.

How it works

parseField converts one field into a boolean lookup array. It splits on commas, then handles *, a-b ranges, and /step suffixes, validating every number against the field's minimum and maximum. Day-of-week accepts 7 as Sunday and folds it to 0. parseCron returns the five arrays plus two flags recording whether the day-of-month and day-of-week fields were restricted.

nextRuns walks forward from a start date, but jumps rather than stepping minute by minute: if the month does not match it skips to the first of the next month, if the day does not match it moves to the next midnight, and if the hour does not match it moves to the next hour. Only when everything else matches does it test minutes. Standard cron semantics apply for days: when both day fields are restricted, a date matches if either one does.

A search window of about five years stops impossible expressions such as the 31st of February from looping forever.

Adapting it

  • Add @daily style aliases by mapping them to five-field strings first.
  • Add a seconds field by extending the arrays and the inner loop.
  • Use Intl.DateTimeFormat with a time zone to display results elsewhere.

Pitfalls

  • Calculations use the local time zone of the runtime.
  • Names such as MON or JAN are not supported here.

How to use it

  1. Save as lib/cron.ts (pure TypeScript, no packages).
  2. const runs = nextRuns('*/15 9-17 * * 1-5', new Date(), 5) returns five Date objects.
  3. Wrap parseCron in a try/catch to show validation messages in your form.
  4. Format the dates with toLocaleString in your UI.

Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.

Related snippets