···30303131/**
3232 * Generate a human-readable description of a cron expression.
3333- * Returns empty string if the expression is invalid.
3333+ * Returns "Invalid cron expression" if the expression is invalid.
3434 */
3535export function describe(expression: string): string {
3636- const parsed = parse(expression);
3737- if (!parsed) return "";
3636+ let parsed: ParsedCron;
3737+ try {
3838+ parsed = parse(expression);
3939+ } catch {
4040+ return "Invalid cron expression";
4141+ }
38423943 const parts: string[] = [];
4044
+30-10
src/parser.ts
···3737 *
3838 * Note: Months are converted from cron's 1-indexed format (1-12) to
3939 * JavaScript's 0-indexed format (0-11) for internal consistency.
4040+ *
4141+ * @throws {Error} If the expression is invalid
4042 */
4141-export function parse(expression: string): ParsedCron | null {
4343+export function parse(expression: string): ParsedCron {
4244 const trimmed = expression.trim();
4343- if (!trimmed) return null;
4545+ if (!trimmed) throw new Error(`Invalid cron expression: "${expression}"`);
44464547 const parts = trimmed.split(/\s+/);
4646- if (parts.length !== 5) return null;
4848+ if (parts.length !== 5)
4949+ throw new Error(
5050+ `Invalid cron expression: "${expression}" - expected 5 fields, got ${parts.length}`,
5151+ );
47524853 const [minuteStr, hourStr, dayStr, monthStr, weekdayStr] = parts;
49545055 const minute = parseField(minuteStr, 0, 59);
5151- if (!minute) return null;
5656+ if (!minute)
5757+ throw new Error(
5858+ `Invalid cron expression: "${expression}" - invalid minute field "${minuteStr}"`,
5959+ );
52605361 const hour = parseField(hourStr, 0, 23);
5454- if (!hour) return null;
6262+ if (!hour)
6363+ throw new Error(`Invalid cron expression: "${expression}" - invalid hour field "${hourStr}"`);
55645665 const day = parseField(dayStr, 1, 31);
5757- if (!day) return null;
6666+ if (!day)
6767+ throw new Error(`Invalid cron expression: "${expression}" - invalid day field "${dayStr}"`);
58685969 const month = parseField(monthStr, 1, 12, MONTH_NAMES);
6060- if (!month) return null;
7070+ if (!month)
7171+ throw new Error(`Invalid cron expression: "${expression}" - invalid month field "${monthStr}"`);
61726273 const weekdayRaw = parseField(weekdayStr, 0, 7, WEEKDAY_NAMES);
6363- if (!weekdayRaw) return null;
7474+ if (!weekdayRaw)
7575+ throw new Error(
7676+ `Invalid cron expression: "${expression}" - invalid weekday field "${weekdayStr}"`,
7777+ );
64786579 const weekdays = weekdayRaw.map((d) => (d === 7 ? 0 : d));
6680···7488 weekdayIsWildcard: weekdayStr.trim() === "*",
7589 };
76907777- if (!hasValidDayMonthCombinations(parsed)) return null;
9191+ if (!hasValidDayMonthCombinations(parsed))
9292+ throw new Error(`Invalid cron expression: "${expression}" - impossible day/month combination`);
78937994 return parsed;
8095}
···188203189204/** Validate a cron expression */
190205export function isValid(expression: string): boolean {
191191- return parse(expression) !== null;
206206+ try {
207207+ parse(expression);
208208+ return true;
209209+ } catch {
210210+ return false;
211211+ }
192212}
+1-10
src/scheduler.ts
···3434/** Get the next execution time for a cron expression. Throws if expression or timezone is invalid, or if no match is found within iteration limit. */
3535export function nextRun(expression: string, options?: CronOptions): Date {
3636 const parsed = parse(expression);
3737- if (!parsed) throw new Error(`Invalid cron expression: "${expression}"`);
38373938 const from = options?.from || new Date();
4039 const tz = options?.timezone;
41404241 const start = tz !== undefined ? convertToTimezone(from, tz) : new Date(from);
4343- if (!start) throw new Error(`Invalid timezone: "${tz}"`);
4444-4542 start.setUTCSeconds(0, 0);
4643 start.setUTCMinutes(start.getUTCMinutes() + 1);
4744···5148/** Get the previous execution time for a cron expression. Throws if expression or timezone is invalid, or if no match is found within iteration limit. */
5249export function previousRun(expression: string, options?: CronOptions): Date {
5350 const parsed = parse(expression);
5454- if (!parsed) throw new Error(`Invalid cron expression: "${expression}"`);
55515652 const from = options?.from || new Date();
5753 const tz = options?.timezone;
58545955 const start = tz !== undefined ? convertToTimezone(from, tz) : new Date(from);
6060- if (!start) throw new Error(`Invalid timezone: "${tz}"`);
6161-6256 start.setUTCSeconds(0, 0);
6357 start.setUTCMinutes(start.getUTCMinutes() - 1);
6458···8781 options?: Pick<CronOptions, "timezone">,
8882): boolean {
8983 const parsed = parse(expression);
9090- if (!parsed) throw new Error(`Invalid cron expression: "${expression}"`);
91849285 const tz = options?.timezone;
9386 const checkDate = tz !== undefined ? convertToTimezone(date, tz) : new Date(date);
9494-9595- if (!checkDate) throw new Error(`Invalid timezone: "${tz}"`);
9687 return matches(parsed, checkDate);
9788}
9889···10899109100 for (let i = 0; i < MAX_ITERATIONS; i++) {
110101 if (matches(parsed, current)) {
111111- return tz !== undefined ? convertFromTimezone(current, tz)! : current;
102102+ return tz !== undefined ? convertFromTimezone(current, tz) : current;
112103 }
113104 advanceDate(parsed, current, dir);
114105 }
+9-6
src/timezone.ts
···88 }
99}
10101111-/** Convert a UTC date to wall-clock time in the target timezone. Returns null if timezone is invalid. */
1212-export function convertToTimezone(date: Date, timezone: string): Date | null {
1313- if (!isValidTimezone(timezone)) return null;
1111+/**
1212+ * Convert a UTC date to wall-clock time in the target timezone.
1313+ * @throws {Error} If timezone is invalid
1414+ */
1515+export function convertToTimezone(date: Date, timezone: string): Date {
1616+ if (!isValidTimezone(timezone)) throw new Error(`Invalid timezone: "${timezone}"`);
14171518 const str = date.toLocaleString("en-US", {
1619 timeZone: timezone,
···35383639/**
3740 * Convert a timezone-local date back to UTC (inverse of convertToTimezone).
3838- * Returns null if timezone is invalid.
4141+ * @throws {Error} If timezone is invalid
3942 *
4043 * Note: During DST fall-back, multiple UTC times map to the same wall-clock time.
4144 * The result is implementation-defined. Avoid scheduling during DST transition hours
4245 * for predictable behavior.
4346 */
4444-export function convertFromTimezone(date: Date, timezone: string): Date | null {
4545- if (!isValidTimezone(timezone)) return null;
4747+export function convertFromTimezone(date: Date, timezone: string): Date {
4848+ if (!isValidTimezone(timezone)) throw new Error(`Invalid timezone: "${timezone}"`);
46494750 // Target time as a comparable number (for checking if we found it)
4851 const targetTime = Date.UTC(
···846846 });
847847848848 it("should return false when day uses step but is still effectively wildcard", () => {
849849- // */1 is effectively a wildcard but parser treats it as non-wildcard
850850- // Note: The parser checks dayStr.trim() === "*" for wildcard detection
851851- // So */1 would NOT be treated as a wildcard
849849+ // */1 matches all days (1-31) but parser checks dayStr === "*" strictly,
850850+ // so dayIsWildcard is false. However weekday IS wildcard, so isOrMode is false.
852851 const parsed = parse("0 0 */1 * *");
853853- // This is actually NOT a wildcard because the check is strict === "*"
854854- // But let's verify the actual behavior
855855- // Note: This test documents current behavior
852852+ expect(isOrMode(parsed)).toBe(false);
856853 });
857854 });
858855
+67-67
test/parser.test.ts
···288288 it("should NOT support full month names (only 3-letter codes)", () => {
289289 // The parser only supports 3-letter codes: jan, feb, mar, etc.
290290 // Full names like JANUARY are not supported
291291- expect(parse("0 0 1 JANUARY *")).toBeNull();
291291+ expect(() => parse("0 0 1 JANUARY *")).toThrow();
292292 });
293293294294 it("should mix month names and numbers in comma-separated list", () => {
···346346347347 describe("error cases - empty and malformed input", () => {
348348 it("should return null for empty expression", () => {
349349- expect(parse("")).toBeNull();
349349+ expect(() => parse("")).toThrow();
350350 });
351351352352 it("should return null for whitespace-only expression", () => {
353353- expect(parse(" ")).toBeNull();
353353+ expect(() => parse(" ")).toThrow();
354354 });
355355356356 it("should return null for tab-only expression", () => {
357357- expect(parse("\t\t\t")).toBeNull();
357357+ expect(() => parse("\t\t\t")).toThrow();
358358 });
359359360360 it("should return null for newline-only expression", () => {
361361- expect(parse("\n\n")).toBeNull();
361361+ expect(() => parse("\n\n")).toThrow();
362362 });
363363364364 it("should return null for mixed whitespace-only expression", () => {
365365- expect(parse(" \t\n ")).toBeNull();
365365+ expect(() => parse(" \t\n ")).toThrow();
366366 });
367367368368 it("should return null for wrong number of fields - too few", () => {
369369- expect(parse("* * *")).toBeNull();
369369+ expect(() => parse("* * *")).toThrow();
370370 });
371371372372 it("should return null for wrong number of fields - too many", () => {
373373- expect(parse("* * * * * *")).toBeNull();
373373+ expect(() => parse("* * * * * *")).toThrow();
374374 });
375375376376 it("should return null for single field", () => {
377377- expect(parse("*")).toBeNull();
377377+ expect(() => parse("*")).toThrow();
378378 });
379379380380 it("should return null for four fields", () => {
381381- expect(parse("* * * *")).toBeNull();
381381+ expect(() => parse("* * * *")).toThrow();
382382 });
383383 });
384384385385 describe("error cases - step values", () => {
386386 it("should return null for invalid step value (zero)", () => {
387387- expect(parse("*/0 * * * *")).toBeNull();
387387+ expect(() => parse("*/0 * * * *")).toThrow();
388388 });
389389390390 it("should return null for negative step value", () => {
391391- expect(parse("*/-5 * * * *")).toBeNull();
391391+ expect(() => parse("*/-5 * * * *")).toThrow();
392392 });
393393394394 it("should return null for non-numeric step value", () => {
395395- expect(parse("*/abc * * * *")).toBeNull();
395395+ expect(() => parse("*/abc * * * *")).toThrow();
396396 });
397397398398 it("should return null for step with empty string", () => {
399399- expect(parse("/5 * * * *")).toBeNull();
399399+ expect(() => parse("/5 * * * *")).toThrow();
400400 });
401401402402 it("should return null for step at end only", () => {
403403- expect(parse("5/ * * * *")).toBeNull();
403403+ expect(() => parse("5/ * * * *")).toThrow();
404404 });
405405406406 it("should return null for invalid range format in step value", () => {
407407- expect(parse("10-20-30/5 * * * *")).toBeNull();
407407+ expect(() => parse("10-20-30/5 * * * *")).toThrow();
408408 });
409409410410 it("should return null for invalid start value in range with step", () => {
411411- expect(parse("abc-20/5 * * * *")).toBeNull();
411411+ expect(() => parse("abc-20/5 * * * *")).toThrow();
412412 });
413413414414 it("should return null for invalid end value in range with step", () => {
415415- expect(parse("10-xyz/5 * * * *")).toBeNull();
415415+ expect(() => parse("10-xyz/5 * * * *")).toThrow();
416416 });
417417 });
418418419419 describe("error cases - ranges", () => {
420420 it("should return null for invalid range (start > end)", () => {
421421- expect(parse("50-10 * * * *")).toBeNull();
421421+ expect(() => parse("50-10 * * * *")).toThrow();
422422 });
423423424424 it("should return null for out of range value in simple range", () => {
425425- expect(parse("70-80 * * * *")).toBeNull();
425425+ expect(() => parse("70-80 * * * *")).toThrow();
426426 });
427427428428 it("should return null for out of range value in step range", () => {
429429- expect(parse("70-80/5 * * * *")).toBeNull();
429429+ expect(() => parse("70-80/5 * * * *")).toThrow();
430430 });
431431432432 it("should return null for range with only dash", () => {
433433- expect(parse("- * * * *")).toBeNull();
433433+ expect(() => parse("- * * * *")).toThrow();
434434 });
435435436436 it("should return null for multiple dashes in range (malformed input)", () => {
437437 // Previously this was a parser quirk that silently accepted "1-5-10" as "1-5".
438438 // Now we return null to catch malformed input early.
439439- expect(parse("1-5-10 * * * *")).toBeNull();
439439+ expect(() => parse("1-5-10 * * * *")).toThrow();
440440 });
441441 });
442442443443 describe("error cases - out of range values", () => {
444444 it("should return null for minute out of range (60)", () => {
445445- expect(parse("60 * * * *")).toBeNull();
445445+ expect(() => parse("60 * * * *")).toThrow();
446446 });
447447448448 it("should return null for minute out of range (negative)", () => {
449449- expect(parse("-1 * * * *")).toBeNull();
449449+ expect(() => parse("-1 * * * *")).toThrow();
450450 });
451451452452 it("should return null for minute out of range (100)", () => {
453453- expect(parse("100 * * * *")).toBeNull();
453453+ expect(() => parse("100 * * * *")).toThrow();
454454 });
455455456456 it("should return null for hour out of range (24)", () => {
457457- expect(parse("* 24 * * *")).toBeNull();
457457+ expect(() => parse("* 24 * * *")).toThrow();
458458 });
459459460460 it("should return null for hour out of range (negative)", () => {
461461- expect(parse("* -1 * * *")).toBeNull();
461461+ expect(() => parse("* -1 * * *")).toThrow();
462462 });
463463464464 it("should return null for day out of range (0)", () => {
465465- expect(parse("* * 0 * *")).toBeNull();
465465+ expect(() => parse("* * 0 * *")).toThrow();
466466 });
467467468468 it("should return null for day out of range (32)", () => {
469469- expect(parse("* * 32 * *")).toBeNull();
469469+ expect(() => parse("* * 32 * *")).toThrow();
470470 });
471471472472 it("should return null for month out of range (0)", () => {
473473 // Month 0 is invalid because months are 1-12 in cron format
474474- expect(parse("* * * 0 *")).toBeNull();
474474+ expect(() => parse("* * * 0 *")).toThrow();
475475 });
476476477477 it("should return null for month out of range (13)", () => {
478478- expect(parse("* * * 13 *")).toBeNull();
478478+ expect(() => parse("* * * 13 *")).toThrow();
479479 });
480480481481 it("should return null for weekday out of range (8)", () => {
482482- expect(parse("* * * * 8")).toBeNull();
482482+ expect(() => parse("* * * * 8")).toThrow();
483483 });
484484 });
485485486486 describe("error cases - invalid value names", () => {
487487 it("should return null for invalid weekday name", () => {
488488- expect(parse("0 0 * * notaday")).toBeNull();
488488+ expect(() => parse("0 0 * * notaday")).toThrow();
489489 });
490490491491 it("should return null for invalid month name", () => {
492492- expect(parse("0 0 1 notamonth *")).toBeNull();
492492+ expect(() => parse("0 0 1 notamonth *")).toThrow();
493493 });
494494495495 it("should return null for partial month name", () => {
496496- expect(parse("0 0 1 janu *")).toBeNull();
496496+ expect(() => parse("0 0 1 janu *")).toThrow();
497497 });
498498499499 it("should return null for partial weekday name", () => {
500500- expect(parse("0 0 * * mond")).toBeNull();
500500+ expect(() => parse("0 0 * * mond")).toThrow();
501501 });
502502503503 it("should return null for completely invalid field value", () => {
504504- expect(parse("abc * * * *")).toBeNull();
504504+ expect(() => parse("abc * * * *")).toThrow();
505505 });
506506507507 it("should return null for special characters in value", () => {
508508- expect(parse("@ * * * *")).toBeNull();
508508+ expect(() => parse("@ * * * *")).toThrow();
509509 });
510510 });
511511512512 describe("error cases - comma-separated issues", () => {
513513 it("should return null for empty part between commas", () => {
514514- expect(parse("1,,3 * * * *")).toBeNull();
514514+ expect(() => parse("1,,3 * * * *")).toThrow();
515515 });
516516517517 it("should return null for trailing comma", () => {
518518- expect(parse("1,2, * * * *")).toBeNull();
518518+ expect(() => parse("1,2, * * * *")).toThrow();
519519 });
520520521521 it("should return null for leading comma", () => {
522522- expect(parse(",1,2 * * * *")).toBeNull();
522522+ expect(() => parse(",1,2 * * * *")).toThrow();
523523 });
524524525525 it("should return null for invalid value in comma list", () => {
526526- expect(parse("1,2,abc,4 * * * *")).toBeNull();
526526+ expect(() => parse("1,2,abc,4 * * * *")).toThrow();
527527 });
528528 });
529529530530 describe("day/month validation", () => {
531531 it("should return null for impossible day/month combination (Feb 31)", () => {
532532- expect(parse("0 0 31 2 *")).toBeNull();
532532+ expect(() => parse("0 0 31 2 *")).toThrow();
533533 });
534534535535 it("should return null for impossible day/month combination (Feb 30)", () => {
536536- expect(parse("0 0 30 2 *")).toBeNull();
536536+ expect(() => parse("0 0 30 2 *")).toThrow();
537537 });
538538539539 it("should return null for impossible day/month combination (Apr 31)", () => {
540540- expect(parse("0 0 31 4 *")).toBeNull();
540540+ expect(() => parse("0 0 31 4 *")).toThrow();
541541 });
542542543543 it("should return null for impossible day/month combination (Jun 31)", () => {
544544- expect(parse("0 0 31 6 *")).toBeNull();
544544+ expect(() => parse("0 0 31 6 *")).toThrow();
545545 });
546546547547 it("should return null for impossible day/month combination (Sep 31)", () => {
548548- expect(parse("0 0 31 9 *")).toBeNull();
548548+ expect(() => parse("0 0 31 9 *")).toThrow();
549549 });
550550551551 it("should return null for impossible day/month combination (Nov 31)", () => {
552552- expect(parse("0 0 31 11 *")).toBeNull();
552552+ expect(() => parse("0 0 31 11 *")).toThrow();
553553 });
554554555555 it("should allow Feb 29 (exists in leap years)", () => {
556556- expect(parse("0 0 29 2 *")).not.toBeNull();
556556+ expect(() => parse("0 0 29 2 *")).not.toThrow();
557557 });
558558559559 it("should allow day 31 with wildcard month", () => {
560560- expect(parse("0 0 31 * *")).not.toBeNull();
560560+ expect(() => parse("0 0 31 * *")).not.toThrow();
561561 });
562562563563 it("should allow wildcard day with specific month", () => {
564564- expect(parse("0 0 * 2 *")).not.toBeNull();
564564+ expect(() => parse("0 0 * 2 *")).not.toThrow();
565565 });
566566567567 it("should allow day 31 in January", () => {
568568- expect(parse("0 0 31 1 *")).not.toBeNull();
568568+ expect(() => parse("0 0 31 1 *")).not.toThrow();
569569 });
570570571571 it("should allow day 31 in March", () => {
572572- expect(parse("0 0 31 3 *")).not.toBeNull();
572572+ expect(() => parse("0 0 31 3 *")).not.toThrow();
573573 });
574574575575 it("should allow day 31 in May", () => {
576576- expect(parse("0 0 31 5 *")).not.toBeNull();
576576+ expect(() => parse("0 0 31 5 *")).not.toThrow();
577577 });
578578579579 it("should allow day 31 in July", () => {
580580- expect(parse("0 0 31 7 *")).not.toBeNull();
580580+ expect(() => parse("0 0 31 7 *")).not.toThrow();
581581 });
582582583583 it("should allow day 31 in August", () => {
584584- expect(parse("0 0 31 8 *")).not.toBeNull();
584584+ expect(() => parse("0 0 31 8 *")).not.toThrow();
585585 });
586586587587 it("should allow day 31 in October", () => {
588588- expect(parse("0 0 31 10 *")).not.toBeNull();
588588+ expect(() => parse("0 0 31 10 *")).not.toThrow();
589589 });
590590591591 it("should allow day 31 in December", () => {
592592- expect(parse("0 0 31 12 *")).not.toBeNull();
592592+ expect(() => parse("0 0 31 12 *")).not.toThrow();
593593 });
594594595595 it("should allow day 30 in April", () => {
596596- expect(parse("0 0 30 4 *")).not.toBeNull();
596596+ expect(() => parse("0 0 30 4 *")).not.toThrow();
597597 });
598598599599 it("should allow day 30 in June", () => {
600600- expect(parse("0 0 30 6 *")).not.toBeNull();
600600+ expect(() => parse("0 0 30 6 *")).not.toThrow();
601601 });
602602603603 it("should allow day 30 in September", () => {
604604- expect(parse("0 0 30 9 *")).not.toBeNull();
604604+ expect(() => parse("0 0 30 9 *")).not.toThrow();
605605 });
606606607607 it("should allow day 30 in November", () => {
608608- expect(parse("0 0 30 11 *")).not.toBeNull();
608608+ expect(() => parse("0 0 30 11 *")).not.toThrow();
609609 });
610610611611 it("should handle multiple months with mixed valid/invalid days", () => {
612612 // Days 30-31 in Feb,Apr - Feb 30-31 invalid, but Apr 30 valid
613613- expect(parse("0 0 30-31 2,4 *")).not.toBeNull();
613613+ expect(() => parse("0 0 30-31 2,4 *")).not.toThrow();
614614 });
615615616616 it("should return null when all day/month combinations are invalid", () => {
617617 // Day 31 only in Feb,Apr,Jun,Sep,Nov (all have < 31 days)
618618- expect(parse("0 0 31 2,4,6,9,11 *")).toBeNull();
618618+ expect(() => parse("0 0 31 2,4,6,9,11 *")).toThrow();
619619 });
620620621621 it("should allow with month name for valid day", () => {
622622- expect(parse("0 0 31 jan *")).not.toBeNull();
622622+ expect(() => parse("0 0 31 jan *")).not.toThrow();
623623 });
624624625625 it("should return null with month name for invalid day", () => {
626626- expect(parse("0 0 31 apr *")).toBeNull();
626626+ expect(() => parse("0 0 31 apr *")).toThrow();
627627 });
628628 });
629629 });
+7-7
test/timezone.test.ts
···469469 describe("invalid timezone strings", () => {
470470 it("should return null for completely invalid timezone", () => {
471471 const date = new Date("2026-03-15T12:00:00Z");
472472- expect(convertToTimezone(date, "Invalid/Timezone")).toBeNull();
472472+ expect(() => convertToTimezone(date, "Invalid/Timezone")).toThrow();
473473 });
474474475475 it("should return null for misspelled timezone", () => {
476476 const date = new Date("2026-03-15T12:00:00Z");
477477- expect(convertToTimezone(date, "America/New_York_City")).toBeNull();
477477+ expect(() => convertToTimezone(date, "America/New_York_City")).toThrow();
478478 });
479479480480 it("should return null for made-up timezone", () => {
481481 const date = new Date("2026-03-15T12:00:00Z");
482482- expect(convertToTimezone(date, "Mars/Colony")).toBeNull();
482482+ expect(() => convertToTimezone(date, "Mars/Colony")).toThrow();
483483 });
484484485485 it("should accept lowercase timezone (Intl is case-insensitive)", () => {
···489489490490 it("should return null for invalid timezone in convertFromTimezone", () => {
491491 const date = new Date("2026-03-15T12:00:00Z");
492492- expect(convertFromTimezone(date, "NotReal/Place")).toBeNull();
492492+ expect(() => convertFromTimezone(date, "NotReal/Place")).toThrow();
493493 });
494494 });
495495496496 describe("empty and whitespace timezone", () => {
497497 it("should return null for empty string timezone", () => {
498498 const date = new Date("2026-03-15T12:00:00Z");
499499- expect(convertToTimezone(date, "")).toBeNull();
499499+ expect(() => convertToTimezone(date, "")).toThrow();
500500 });
501501502502 it("should return null for whitespace-only timezone", () => {
503503 const date = new Date("2026-03-15T12:00:00Z");
504504- expect(convertToTimezone(date, " ")).toBeNull();
504504+ expect(() => convertToTimezone(date, " ")).toThrow();
505505 });
506506507507 it("should return null for empty string in convertFromTimezone", () => {
508508 const date = new Date("2026-03-15T12:00:00Z");
509509- expect(convertFromTimezone(date, "")).toBeNull();
509509+ expect(() => convertFromTimezone(date, "")).toThrow();
510510 });
511511 });
512512