Skip to content

Expansion

expand() materializes the occurrence sequence for an RRuleOptions into an array. iterate() is a synchronous generator for memory-efficient streaming over the same sequence.

import { expand } from 'rrule-ts'
function expand(options: RRuleOptions, limitOrOpts?: number | ExpandOptions): RRuleDtstart[]

The second argument is either a plain number (maximum occurrences) or an ExpandOptions object.

interface ExpandOptions {
/** Maximum number of occurrences to return. */
limit?: number
/** Only return occurrences at or after this value (inclusive by default). */
after?: RRuleDtstart
/** Only return occurrences at or before this value (inclusive by default). */
before?: RRuleDtstart
/** Whether after/before bounds are inclusive. Defaults to true. */
inclusive?: boolean
}

The return type mirrors the dtstart type on the options object:

dtstart typeReturn element type
Temporal.PlainDateTemporal.PlainDate
Temporal.PlainDateTimeTemporal.PlainDateTime
Temporal.InstantTemporal.Instant
Temporal.ZonedDateTimeTemporal.ZonedDateTime
import { parse, expand } from 'rrule-ts'
import { Temporal } from 'temporal-polyfill'
// Bounded rule via COUNT: no limit needed
const result = parse('RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR;COUNT=6')
if (!result.ok) throw new Error(result.error)
const dates = expand({
...result.value,
dtstart: Temporal.PlainDate.from('2025-01-06'), // Monday
})
// Returns 6 PlainDate values: 2025-01-06, 2025-01-08, 2025-01-10, 2025-01-13, 2025-01-15, 2025-01-17
// Unbounded rule: use limit
const next10 = expand({ freq: 'DAILY', dtstart: Temporal.PlainDate.from('2025-01-01') }, 10)
// ExpandOptions with after/before window
const window = expand(
{ freq: 'DAILY', dtstart: Temporal.PlainDate.from('2025-01-01') },
{
after: Temporal.PlainDate.from('2025-03-01'),
before: Temporal.PlainDate.from('2025-03-31'),
limit: 31,
}
)

iterate() is a synchronous generator. It yields one occurrence at a time and is suitable for large or infinite sequences where you want to stop early:

import { iterate } from 'rrule-ts'
import { Temporal } from 'temporal-polyfill'
const gen = iterate({
freq: 'DAILY',
dtstart: Temporal.PlainDate.from('2025-01-01'),
})
for (const date of gen) {
if (date.year > 2025) break
console.log(date.toString())
}

iterate() requires dtstart to be set. It throws a descriptive error if dtstart is absent.

RFC 5545 Table 1 specifies whether each BY* modifier expands (adds candidates to the period set) or limits (filters existing candidates). The behavior depends on FREQ.

BY* modifierRoleNotes
BYMONTHExpandGenerates dates in each listed month of the year
BYWEEKNOExpandGenerates dates in each listed ISO week of the year
BYYEARDAYExpandGenerates the listed ordinal days of the year
BYMONTHDAYExpandGenerates the listed days of each expanded month
BYDAYExpandExpands weekdays or ordinal weekdays within the period
BYHOURExpandExpands each date into the listed hours
BYMINUTEExpandExpands each hour into the listed minutes
BYSECONDExpandExpands each minute into the listed seconds
BYSETPOSFilterSelects positions from the final candidate set
BY* modifierRoleNotes
BYMONTHLimitSkips months not in the list
BYWEEKNON/ANot applicable for MONTHLY (validation error)
BYYEARDAYN/ANot applicable for MONTHLY (validation error)
BYMONTHDAYExpandGenerates the listed days within each month
BYDAYExpandExpands weekdays or ordinal weekdays within the month
BYHOURExpandExpands each date into the listed hours
BYMINUTEExpandExpands each hour into the listed minutes
BYSECONDExpandExpands each minute into the listed seconds
BYSETPOSFilterSelects positions from the monthly candidate set
BY* modifierRoleNotes
BYMONTHLimitSkips days outside listed months
BYDAYExpandExpands into the listed weekdays within each week
BYHOURExpandExpands each date into the listed hours
BYMINUTEExpandExpands each hour into the listed minutes
BYSECONDExpandExpands each minute into the listed seconds
BYSETPOSFilterSelects positions from the weekly candidate set
BY* modifierRoleNotes
BYMONTHLimitSkips days outside listed months
BYMONTHDAYLimitSkips days whose day-of-month is not listed
BYDAYLimitSkips days whose weekday is not listed (no ordinals)
BYYEARDAYN/ANot applicable for DAILY per RFC 5545 Table 1
BYHOURExpandExpands each day into the listed hours
BYMINUTEExpandExpands each hour into the listed minutes
BYSECONDExpandExpands each minute into the listed seconds
BYSETPOSFilterSelects positions from the daily candidate set

For sub-daily frequencies, BYMONTH, BYYEARDAY, BYMONTHDAY, and BYDAY are all Limit modifiers. BYHOUR, BYMINUTE, and BYSECOND filter the advancing time value, while coarser BY* parts act as day-level gates.

FREQBase cadenceinterval default
YEARLYOnce per calendar year1
MONTHLYOnce per calendar month1
WEEKLYOnce per WKST-anchored week1
DAILYOnce per calendar day1
HOURLYOnce per hour1
MINUTELYOnce per minute1
SECONDLYOnce per second1

interval multiplies the base cadence. FREQ=WEEKLY;INTERVAL=2 produces one occurrence every two weeks, anchored on the week that contains dtstart.

BYSETPOS: selecting positions within the period

Section titled “BYSETPOS: selecting positions within the period”

BYSETPOS is applied after all other BY* modifiers have assembled the candidate set for each period. A positive value selects from the start (1-indexed), a negative value from the end.

import { expand } from 'rrule-ts'
import { Temporal } from 'temporal-polyfill'
// Last weekday of every month
const lastWeekday = expand({
freq: 'MONTHLY',
byDay: [
{ weekday: 'MO', ordinal: undefined },
{ weekday: 'TU', ordinal: undefined },
{ weekday: 'WE', ordinal: undefined },
{ weekday: 'TH', ordinal: undefined },
{ weekday: 'FR', ordinal: undefined },
],
bySetPos: [-1],
count: 3,
dtstart: Temporal.PlainDate.from('2025-01-01'),
})
// 2025-01-31, 2025-02-28, 2025-03-31
// Second-to-last day of every month
const secondToLast = expand({
freq: 'MONTHLY',
byMonthDay: [-2],
count: 3,
dtstart: Temporal.PlainDate.from('2025-01-01'),
})
// 2025-01-30, 2025-02-27, 2025-03-30

A WeekdayNum with ordinal set selects a specific numbered occurrence of that weekday within the period. Positive ordinals count from the start; negative ordinals count from the end.

import { expand } from 'rrule-ts'
import { Temporal } from 'temporal-polyfill'
// Second Tuesday of every month
const secondTuesday = expand({
freq: 'MONTHLY',
byDay: [{ weekday: 'TU', ordinal: 2 }],
count: 3,
dtstart: Temporal.PlainDate.from('2025-01-01'),
})
// 2025-01-14, 2025-02-11, 2025-03-11
// Last Friday of every month
const lastFriday = expand({
freq: 'MONTHLY',
byDay: [{ weekday: 'FR', ordinal: -1 }],
count: 3,
dtstart: Temporal.PlainDate.from('2025-01-01'),
})
// 2025-01-31, 2025-02-28, 2025-03-28
// 20th Monday of every year (ordinal BYDAY in YEARLY context)
const twentieth = expand({
freq: 'YEARLY',
byDay: [{ weekday: 'MO', ordinal: 20 }],
count: 2,
dtstart: Temporal.PlainDate.from('2025-01-01'),
})

When multiple BY* modifiers coexist, their rules intersect rather than union:

  • BYMONTHDAY=15 and BYDAY=MO together select only Mondays that fall on the 15th.
  • BYWEEKNO=10 and BYDAY=WE select only Wednesdays in ISO week 10.
  • BYYEARDAY=100 and BYMONTH=4 select only year-day 100 if it falls in April.
// Monthly rule: only Mondays on the 15th (rare; empty months are skipped)
expand({
freq: 'MONTHLY',
byMonthDay: [15],
byDay: [{ weekday: 'MO', ordinal: undefined }],
count: 5,
dtstart: Temporal.PlainDate.from('2024-01-01'),
})
import { expand } from 'rrule-ts'
import { Temporal } from 'temporal-polyfill'
const start = Temporal.PlainDate.from('2025-01-01')
// Every 2 weeks on Tuesday and Thursday
expand({
freq: 'WEEKLY',
interval: 2,
byDay: [
{ weekday: 'TU', ordinal: undefined },
{ weekday: 'TH', ordinal: undefined },
],
count: 4,
dtstart: start,
})
// 2025-01-02, 2025-01-09 ... actually anchored to start's week
// Every year in March on the first Monday
expand({
freq: 'YEARLY',
byMonth: [3],
byDay: [{ weekday: 'MO', ordinal: 1 }],
count: 3,
dtstart: start,
})
// Every day at 9:00 and 17:00
expand({
freq: 'DAILY',
byHour: [9, 17],
byMinute: [0],
count: 6,
dtstart: Temporal.PlainDateTime.from('2025-01-01T09:00:00'),
})
// 2025-01-01T09:00, 2025-01-01T17:00, 2025-01-02T09:00, ...