Skip to content

Cron

Backoff and spacing schedules recur relative to when the last run finished. Some jobs instead need to fire at specific points on the calendar — “every weekday at 9:00”, “at 4 AM on the 8th through 14th of each month”. The Cron module describes those points using UNIX cron expressions, and Schedule.cron turns one into a Schedule you can drive with Effect.repeat.

import { Cron, Effect, Schedule } from "effect"
// "second minute hour day-of-month month day-of-week"
// → at 09:00:00, Monday through Friday.
const businessDaysAt9 = Schedule.cron("0 0 9 * * 1-5", "Europe/Rome")
const sendDigest = Effect.gen(function* () {
yield* Effect.log("sending the daily digest")
}).pipe(Effect.repeat(businessDaysAt9))

Schedule.cron accepts the expression as a string (with an optional time zone) or a pre-built Cron value. The resulting schedule’s Output is a Duration — the gap it slept until the next matching instant — and its Error channel is Cron.CronParseError, since an invalid expression surfaces as a typed failure rather than a thrown exception.

When you’d rather not hand-write an expression, Cron.make builds a Cron from explicit numeric constraints. An empty (or, for seconds/tz, omitted) field means “no constraint” — i.e. “match any value”, exactly like *.

import { Cron, DateTime } from "effect"
// 04:00 on the 8th–14th of every month, in the Europe/Rome time zone.
const cron = Cron.make({
seconds: [0],
minutes: [0],
hours: [4],
days: [8, 9, 10, 11, 12, 13, 14],
months: [], // any month
weekdays: [], // any weekday
tz: DateTime.zoneMakeNamedUnsafe("Europe/Rome")
})

Cron.parse validates an expression and returns a ResultResult.Success with the Cron, or Result.Failure with a CronParseError. Use it when the expression comes from configuration or user input and might be malformed.

import { Cron, Result } from "effect"
const parsed = Cron.parse("0 0 4 8-14 * *", "UTC")
if (Result.isSuccess(parsed)) {
// parsed.success is a Cron
} else {
// parsed.failure is a CronParseError with a .message
}

If you control the expression and want to fail fast on a typo, Cron.parseUnsafe returns the Cron directly and throws on invalid input — convenient for module-level constants.

Each of the six fields accepts the same grammar:

FormExampleMeaning
Any*every value in the field’s range
Single9exactly 9
Range8-148 through 14 inclusive
List1,151 and 15
Step*/15every 15th value (0, 15, 30, …)
Range+step0-30/10every 10th value in 0–30 (0, 10, 20, 30)

The field ranges are: seconds 0-59, minutes 0-59, hours 0-23, day-of-month 1-31, month 1-12, weekday 0-6 (0 is Sunday). The month and weekday fields also accept three-letter aliases — JANDEC and SUNSAT (case-insensitive).

import { Cron } from "effect"
// Every 15 minutes, business hours, weekdays — using aliases.
Cron.parseUnsafe("0 */15 9-17 * * MON-FRI")

A Cron is useful even outside a Schedule. These helpers take a DateTime.Input (a Date, epoch millis, or DateTime):

import { Cron } from "effect"
const cron = Cron.parseUnsafe("0 0 4 8-14 * *", "UTC")
// Does this instant satisfy the schedule?
Cron.match(cron, new Date("2025-01-08T04:00:00Z")) // => true
// When does it next fire on or after a given instant? (returns a Date)
Cron.next(cron, new Date("2025-01-01T00:00:00Z")) // => 2025-01-08T04:00:00.000Z
// Iterate matching instants lazily.
const it = Cron.sequence(cron, new Date("2025-01-01T00:00:00Z"))
it.next().value // => 2025-01-08T04:00:00.000Z
it.next().value // => 2025-01-09T04:00:00.000Z

Because Schedule.cron returns an ordinary Schedule, it composes like any other. Cap a cron job to a fixed number of runs, or tap its output for logging:

import { Duration, Effect, Schedule } from "effect"
const limited = Schedule.cron("0 0 9 * * 1-5", "Europe/Rome").pipe(
Schedule.both(Schedule.recurs(10)), // run at most 10 times
Schedule.tapOutput((slept: Duration.Duration) =>
Effect.logDebug(`woke after ${Duration.toMillis(slept)}ms`)
)
)

To verify cron-driven code without waiting for the calendar, advance simulated time with TestClock — see Clock.

Everything the Cron module exports. A Cron value is immutable, hashable, and implements Equal, so two crons with the same field restrictions compare equal by value.

Builds a Cron from explicit field constraints. minutes, hours, days, months, and weekdays are required iterables of numbers; seconds (defaults to [0]) and tz (a DateTime.TimeZone) are optional. An empty iterable for a field means “match any value” — the same as *. Values may be passed in any order; they are sorted and de-duplicated internally.

import { Cron, DateTime } from "effect"
// 09:30 every Monday, in New York local time.
const cron = Cron.make({
seconds: [0],
minutes: [30],
hours: [9],
days: [], // any day-of-month
months: [], // any month
weekdays: [1], // Monday
tz: DateTime.zoneMakeNamedUnsafe("America/New_York")
})
Cron.match(cron, new Date("2025-06-02T13:30:00Z")) // => true (Mon 09:30 EDT)

Parses a five- or six-field cron expression into a Result<Cron, CronParseError>. The optional second argument is a time zone, either a DateTime.TimeZone or an IANA string like "Europe/Rome". A five-field expression has seconds prepended as 0 automatically.

import { Cron, Result } from "effect"
Cron.parse("0 0 4 8-14 * *", "UTC")
// => Result.Success(Cron { hours: {4}, days: {8..14}, tz: Some(UTC) })
Cron.parse("nope")
// => Result.Failure(CronParseError: "Invalid number of segments in cron expression")

Like parse, but returns the Cron directly and throws on invalid input. Use it for trusted, literal expressions (e.g. module-level constants).

import { Cron } from "effect"
Cron.parseUnsafe("0 0 9 * * *", "America/New_York")
// => Cron { hours: {9}, tz: Some(America/New_York) }
// Cron.parseUnsafe("invalid") // throws CronParseError

Type guard that narrows an unknown value to Cron.

import { Cron } from "effect"
const cron = Cron.parseUnsafe("0 0 9 * * *")
Cron.isCron(cron) // => true
Cron.isCron({}) // => false
Cron.isCron("0 0 9 * * *") // => false

Type guard that narrows an unknown value to CronParseError — handy when inspecting a caught defect or a generic failure channel.

import { Cron, Result } from "effect"
const result = Cron.parse("totally invalid")
if (Result.isFailure(result)) {
Cron.isCronParseError(result.failure) // => true
}
Cron.isCronParseError(new Error("nope")) // => false

Returns true when a given instant satisfies the schedule. Seconds, minutes, hours, and months are matched directly; the day-of-month/weekday pair follows cron’s inclusive rule (either may match when both are constrained). The instant is interpreted in the cron’s time zone, if any.

import { Cron } from "effect"
const cron = Cron.parseUnsafe("0 0 4 8-14 * *", "UTC")
Cron.match(cron, new Date("2025-01-08T04:00:00Z")) // => true
Cron.match(cron, new Date("2025-01-08T05:00:00Z")) // => false (wrong hour)
Cron.match(cron, new Date("2025-01-07T04:00:00Z")) // => false (wrong day)

Returns the next matching Date strictly after the supplied instant (or after “now” when omitted). Because the search is strict, passing a matching instant yields the following occurrence.

import { Cron } from "effect"
const cron = Cron.parseUnsafe("0 0 4 8-14 * *", "UTC")
Cron.next(cron, new Date("2025-01-01T00:00:00Z"))
// => 2025-01-08T04:00:00.000Z
Cron.next(cron, new Date("2025-01-08T04:00:00Z")) // already a match
// => 2025-01-09T04:00:00.000Z

Returns the previous matching Date strictly before the supplied instant (or before “now” when omitted). The search is strict, mirroring next.

import { Cron } from "effect"
const cron = Cron.parseUnsafe("0 0 4 8-14 * *", "UTC")
Cron.prev(cron, new Date("2025-01-20T00:00:00Z"))
// => 2025-01-14T04:00:00.000Z

Returns an infinite IterableIterator<Date> of matching instants, each strictly after the previous. Combine with Array.from({ length }) or a for…of with a break to take a finite slice.

import { Cron } from "effect"
const cron = Cron.parseUnsafe("0 0 9 * * 1-5") // 09:00, weekdays
const iterator = Cron.sequence(cron, new Date("2025-01-01T00:00:00Z"))
const next3 = Array.from({ length: 3 }, () => iterator.next().value)
// => [
// 2025-01-01T09:00:00.000Z, // Wed
// 2025-01-02T09:00:00.000Z, // Thu
// 2025-01-03T09:00:00.000Z // Fri
// ]

An Equivalence over Cron that compares the six field restrictions (seconds, minutes, hours, days, months, weekdays). It does not compare the optional time zone, and ordering of the input values is irrelevant.

import { Cron } from "effect"
const a = Cron.parseUnsafe("0 0,30 9 1,15 * 1-5")
const b = Cron.parseUnsafe("0 30,0 9 15,1 * 1-5") // values reordered
Cron.Equivalence(a, b) // => true

Checks whether two Cron values have the same field restrictions (ignoring time zone). Available in both data-last (curried) and data-first forms.

import { Cron } from "effect"
const a = Cron.parseUnsafe("0 0 9 1,15 * 1-5")
const b = Cron.parseUnsafe("0 0 9 1,15 * 1-5")
Cron.equals(a, b) // => true
Cron.equals(a)(b) // => true (curried)

The tagged error (_tag: "CronParseError") produced when parsing fails. It carries a human-readable message and the offending input string. Cron.parse returns it in the failure channel of its Result, and Schedule.cron surfaces the very same type in its Error channel — so a malformed expression there is a typed failure you can Effect.catch, never a thrown exception.

import { Cron, Result } from "effect"
const result = Cron.parse("0 99 9 * * *") // minute out of range
if (Result.isFailure(result)) {
const error = result.failure
error._tag // => "CronParseError"
error.message // => "Expected a value between 0 and 59"
error.input // => "99"
}
import { Cron, Effect, Schedule } from "effect"
// The CronParseError reaches the effect's error channel, not a throw.
const program = Effect.void.pipe(
Effect.repeat(Schedule.cron("not a cron")),
Effect.catchTag("CronParseError", (e: Cron.CronParseError) =>
Effect.logError(`bad cron: ${e.message}`)
)
)

The model interface. A Cron exposes six ReadonlySet<number> fields plus an optional time zone. Empty sets denote unconstrained fields (the * case), so inspect schedules through the public helpers rather than assuming every allowed value is stored.

import { Cron } from "effect"
declare const cron: Cron.Cron
cron.seconds // ReadonlySet<number>
cron.minutes // ReadonlySet<number>
cron.hours // ReadonlySet<number>
cron.days // ReadonlySet<number> (day-of-month, 1–31)
cron.months // ReadonlySet<number> (1–12)
cron.weekdays // ReadonlySet<number> (0–6, Sunday = 0)
cron.tz // Option.Option<DateTime.TimeZone>