Temporal-first recurrence API for rules, composed schedules, and RFC 5545 parsing.
rrule.net • Temporal API • @rrulenet ecosystem
@rrulenet/rrule ·
@rrulenet/recurrence ·
@rrulenet/core ·
@rrulenet/cli
@rrulenet/rrule: classic API · @rrulenet/recurrence: Temporal-first API · @rrulenet/core: engine · @rrulenet/cli: workflows
@rrulenet/recurrence is the Temporal-first package in the @rrulenet ecosystem. It is designed for applications that want one recurrence type, direct support for Temporal.Instant and Temporal.ZonedDateTime, first-class set algebra, and RFC 5545 parsing and serialization where possible.
Use @rrulenet/recurrence when your application boundary is already Temporal-oriented. Use @rrulenet/rrule when you want the classic rrule.js-style API.
- Install
- Getting Started
- Why Recurrence
- API
- Constructor Input Shape
- Examples
- Error Handling
- Development
npm install @rrulenet/recurrenceIf your runtime does not yet provide the Temporal API, install a polyfill in your application:
npm install temporal-polyfillnpm install @js-temporal/polyfillPolyfill projects:
import { Temporal } from 'temporal-polyfill';
import { Recurrence } from '@rrulenet/recurrence';
const recurrence = Recurrence.rule({
freq: 'DAILY',
count: 3,
byHour: [9],
start: Temporal.ZonedDateTime.from('2025-01-01T09:00:00+01:00[Europe/Paris]'),
});
console.log(recurrence.all().map((value) => value.toString()));
// [
// '2025-01-01T09:00:00+01:00[Europe/Paris]',
// '2025-01-02T09:00:00+01:00[Europe/Paris]',
// '2025-01-03T09:00:00+01:00[Europe/Paris]'
// ]@rrulenet/recurrence revolves around a single public type: Recurrence.
That matters because many real schedules are not just one rule. For example:
Every weekday at 9:00 and weekends at 10:00
This package models that directly as one recurrence expression instead of forcing a split between "rule" and "rule set". That makes the API easier to compose, easier to test, and better suited to programmatic generation by applications, CLIs, or agents.
import {
Recurrence,
parse,
rule,
TEMPORAL_ERROR_CODES,
TemporalApiError,
} from '@rrulenet/recurrence';
import type {
RecurrenceJson,
RecurrenceJsonEntry,
RecurrenceJsonRuleInput,
} from '@rrulenet/recurrence';Main exports:
Recurrenceparse(options)rule(options)TEMPORAL_ERROR_CODESTemporalApiError
Type exports:
RecurrenceJsonRecurrenceJsonEntryRecurrenceJsonRuleInput
Recurrence is the central type. It supports:
- construction from a canonical object shape
- parsing RFC strings
- creating simple rules
- creating explicit date-only recurrences
- querying occurrences
- combining, intersecting, and subtracting recurrence expressions
- text and RFC serialization
Parse an RFC 5545 recurrence string into a Recurrence.
import { Temporal } from 'temporal-polyfill';
import { Recurrence } from '@rrulenet/recurrence';
const recurrence = Recurrence.parse({
rruleString: 'RRULE:FREQ=DAILY;COUNT=2;BYHOUR=9',
start: Temporal.ZonedDateTime.from('2025-01-01T09:00:00+01:00[Europe/Paris]'),
});
console.log(recurrence.all().map((value) => value.toString()));
// [
// '2025-01-01T09:00:00+01:00[Europe/Paris]',
// '2025-01-02T09:00:00+01:00[Europe/Paris]'
// ]
// The parsed recurrence yields ZonedDateTime values in the resolved timezone.Notes:
rruleStringis requiredstartcan be aDate,Temporal.Instant, orTemporal.ZonedDateTime- if
startis aTemporal.ZonedDateTime, its timezone is inferred automatically - inline
DTSTARTinformation inside the string remains authoritative
The top-level parse(options) export is a convenience alias for Recurrence.parse(options).
Create a simple rule and get back a Recurrence.
import { Temporal } from 'temporal-polyfill';
import { rule } from '@rrulenet/recurrence';
const recurrence = rule({
freq: 'WEEKLY',
byDay: ['MO', 'WE', 'FR'],
byHour: [9],
count: 5,
start: Temporal.Instant.from('2025-01-01T08:00:00Z'),
tzid: 'Europe/Paris',
});
console.log(recurrence.first()?.toString());
// '2025-01-01T09:00:00+01:00[Europe/Paris]'
console.log(recurrence.take(3).map((value) => value.toString()));
// [
// '2025-01-01T09:00:00+01:00[Europe/Paris]',
// '2025-01-03T09:00:00+01:00[Europe/Paris]',
// '2025-01-06T09:00:00+01:00[Europe/Paris]'
// ]This is sugar for creating a Recurrence with one included rule. Use it when your schedule is a single rule and you want the shortest entry point.
Create a Recurrence from explicit dates only.
import { Temporal } from 'temporal-polyfill';
import { Recurrence } from '@rrulenet/recurrence';
const recurrence = Recurrence.dates([
Temporal.Instant.from('2025-05-01T09:00:00Z'),
Temporal.Instant.from('2025-05-08T09:00:00Z'),
], {
tzid: 'Europe/Paris',
});
console.log(recurrence.all().map((value) => value.toString()));
// [
// '2025-05-01T11:00:00+02:00[Europe/Paris]',
// '2025-05-08T11:00:00+02:00[Europe/Paris]'
// ]This is useful for holidays, one-off exceptions, or explicit include/exclude lists.
All query methods return Temporal.ZonedDateTime values.
const values = recurrence.all();
console.log(values.map((value) => value.toString()));
// [
// '2025-01-01T09:00:00+01:00[Europe/Paris]',
// '2025-01-03T09:00:00+01:00[Europe/Paris]',
// '2025-01-06T09:00:00+01:00[Europe/Paris]',
// ...
// ]You can also provide an iterator to stop early:
const firstThree = recurrence.all((value, index) => index < 3);
console.log(firstThree.map((value) => value.toString()));
// [
// '2025-01-01T09:00:00+01:00[Europe/Paris]',
// '2025-01-03T09:00:00+01:00[Europe/Paris]',
// '2025-01-06T09:00:00+01:00[Europe/Paris]'
// ]const values = recurrence.between(
Temporal.Instant.from('2025-01-01T00:00:00Z'),
Temporal.Instant.from('2025-01-31T23:59:59Z'),
true,
);
console.log(values.map((value) => value.toString()));
// [
// '2025-01-01T09:00:00+01:00[Europe/Paris]',
// '2025-01-03T09:00:00+01:00[Europe/Paris]',
// '2025-01-06T09:00:00+01:00[Europe/Paris]',
// ...
// ]const next = recurrence.after(Temporal.Instant.from('2025-01-15T12:00:00Z'));
// First occurrence strictly after the boundary by defaultconst previous = recurrence.before(Temporal.Instant.from('2025-01-15T12:00:00Z'));
// Last occurrence strictly before the boundary by defaultReturn the first occurrence, or null if none exists.
const first = recurrence.first();
// Shortcut for the first occurrence in the seriesReturn the first count occurrences.
const preview = recurrence.take(5);
console.log(preview.map((value) => value.toString()));
// [
// '2025-01-01T09:00:00+01:00[Europe/Paris]',
// '2025-01-03T09:00:00+01:00[Europe/Paris]',
// '2025-01-06T09:00:00+01:00[Europe/Paris]',
// '2025-01-08T09:00:00+01:00[Europe/Paris]',
// '2025-01-10T09:00:00+01:00[Europe/Paris]'
// ]Return the next count occurrences after a boundary.
const upcoming = recurrence.takeAfter(
Temporal.Instant.from('2025-01-05T12:00:00Z'),
3,
);
console.log(upcoming.map((value) => value.toString()));
// [
// '2025-01-06T09:00:00+01:00[Europe/Paris]',
// '2025-01-08T09:00:00+01:00[Europe/Paris]',
// '2025-01-10T09:00:00+01:00[Europe/Paris]'
// ]This is useful for product APIs, queues, and dashboards that need the next N occurrences from a moving boundary without manually looping over after().
Count occurrences. On open-ended recurrences, pass a limit to keep the query bounded.
const exact = recurrence.count();
const bounded = recurrence.count(10);
console.log(exact);
// 5
console.log(bounded);
// 5Quick presence checks.
if (recurrence.hasAny()) {
console.log('Schedule has at least one occurrence');
}Check whether at least one occurrence exists in a range.
const activeThisWeek = recurrence.hasAnyBetween(
Temporal.Instant.from('2025-01-01T00:00:00Z'),
Temporal.Instant.from('2025-01-07T23:59:59Z'),
true,
);
// Boolean check without materializing the full matching sliceCheck whether the recurrence contains an occurrence at an exact instant.
const occurs = recurrence.occursAt(Temporal.Instant.from('2025-01-03T09:00:00Z'));
// Exact instant membership checkThe constructor accepts the canonical composed shape:
import { Temporal } from 'temporal-polyfill';
import { Recurrence } from '@rrulenet/recurrence';
const recurrence = new Recurrence({
start: Temporal.ZonedDateTime.from('2026-01-01T09:00:00+01:00[Europe/Paris]'),
include: [
{
rule: {
freq: 'WEEKLY',
byDay: ['MO', 'TU', 'WE', 'TH', 'FR'],
byHour: [9],
},
},
{
rule: {
freq: 'WEEKLY',
byDay: ['SA', 'SU'],
byHour: [10],
},
},
],
exclude: [
{
dates: [Temporal.Instant.from('2026-01-03T09:00:00Z')],
},
],
});This is the most expressive entry point. Use it when you want one recurrence object that includes multiple rules, explicit dates, exclusions, or both.
Combine multiple recurrence expressions.
const weekdays = Recurrence.rule({
freq: 'WEEKLY',
byDay: ['MO', 'TU', 'WE', 'TH', 'FR'],
byHour: [9],
start: Temporal.ZonedDateTime.from('2026-01-01T09:00:00+01:00[Europe/Paris]'),
});
const weekends = Recurrence.rule({
freq: 'WEEKLY',
byDay: ['SA', 'SU'],
byHour: [10],
start: Temporal.ZonedDateTime.from('2026-01-01T09:00:00+01:00[Europe/Paris]'),
});
const combined = Recurrence.union(weekdays, weekends);
console.log(combined.toText());
// every week on weekday at 9 AM CET and every week on Saturday and Sunday at 10 AM CETThis is a first-class algebraic composition. It is more general than the flat constructor shape.
Keep only the occurrences shared by multiple recurrence expressions.
const daily = Recurrence.rule({
freq: 'DAILY',
count: 7,
start: Temporal.ZonedDateTime.from('2025-01-01T09:00:00Z[UTC]'),
});
const weekdays = Recurrence.rule({
freq: 'WEEKLY',
byDay: ['MO', 'TU', 'WE', 'TH', 'FR'],
byHour: [9],
start: Temporal.ZonedDateTime.from('2025-01-01T09:00:00Z[UTC]'),
});
const weekdayDaily = Recurrence.intersection(daily, weekdays);
console.log(weekdayDaily.all().map((value) => value.toString()));
// [
// '2025-01-01T09:00:00+00:00[UTC]',
// '2025-01-02T09:00:00+00:00[UTC]',
// '2025-01-03T09:00:00+00:00[UTC]',
// '2025-01-06T09:00:00+00:00[UTC]',
// '2025-01-07T09:00:00+00:00[UTC]'
// ]This is useful when a schedule is best expressed as the overlap between broader recurrence expressions.
Subtract one recurrence from another.
const businessDays = Recurrence.rule({
freq: 'DAILY',
count: 10,
start: Temporal.ZonedDateTime.from('2025-01-01T09:00:00Z[UTC]'),
});
const weekends = Recurrence.rule({
freq: 'WEEKLY',
byDay: ['SA', 'SU'],
byHour: [9],
start: Temporal.ZonedDateTime.from('2025-01-01T09:00:00Z[UTC]'),
});
const weekdaysOnly = businessDays.difference(weekends);
console.log(weekdaysOnly.take(3).map((value) => value.toString()));
// [
// '2025-01-01T09:00:00+00:00[UTC]',
// '2025-01-02T09:00:00+00:00[UTC]',
// '2025-01-03T09:00:00+00:00[UTC]'
// ]Return a new Recurrence with extra dates included or excluded.
const adjusted = recurrence
.includingDates([Temporal.Instant.from('2025-01-05T09:00:00Z')])
.excludingDates([Temporal.Instant.from('2025-01-02T09:00:00Z')]);
console.log(adjusted.all().map((value) => value.toString()));
// The extra date is added and the excluded date is removed, without mutating `recurrence`These methods are immutable: they do not mutate the original recurrence.
Serialize a flat recurrence to RFC-compatible lines.
const recurrence = new Recurrence({
start: Temporal.ZonedDateTime.from('1997-09-02T09:00:00-04:00[America/New_York]'),
include: [
{
rule: {
freq: 'DAILY',
count: 5,
},
},
],
});
console.log(recurrence.toString());
// DTSTART;TZID=America/New_York:19970902T090000
// RRULE:FREQ=DAILY;COUNT=5
// Flat RFC-compatible representationFor algebraic expressions such as Recurrence.union(...) and Recurrence.difference(...), toString() throws TEMPORAL_UNSERIALIZABLE_EXPRESSION when there is no flat RFC representation.
Describe a recurrence in natural language.
console.log(recurrence.toText());
// every week on weekday at 9 AM, every week on Saturday and Sunday at 10 AMCheck whether the full recurrence expression can be rendered completely as text.
if (recurrence.isFullyConvertibleToText()) {
console.log(recurrence.toText());
}Return the flat constructor shape for flat recurrences.
const input = recurrence.toInput();Like toString(), this throws for non-flat algebraic expressions.
Return a stable public JSON shape. Flat recurrences serialize as flat input-like objects; algebraic recurrences serialize as structural expressions.
const json = recurrence.toJSON();
console.log(json);
// {
// kind: 'input',
// start: '2025-01-01T09:00:00+01:00[Europe/Paris]',
// tzid: 'Europe/Paris',
// include: [
// {
// rule: {
// freq: 'WEEKLY',
// start: '2025-01-01T08:00:00Z',
// tzid: 'Europe/Paris',
// interval: 1,
// count: 5,
// byDay: ['MO', 'WE', 'FR'],
// byHour: [9],
// },
// },
// ],
// exclude: [],
// }This is the recommended representation for inspection, snapshots, transport, and structural equality checks.
Rebuild a Recurrence from a value previously produced by toJSON().
const recurrence = rule({
freq: 'WEEKLY',
byDay: ['MO', 'WE', 'FR'],
byHour: [9],
count: 5,
start: Temporal.Instant.from('2025-01-01T08:00:00Z'),
tzid: 'Europe/Paris',
});
const saved = recurrence.toJSON();
console.log(saved);
// {
// kind: 'input',
// start: '2025-01-01T09:00:00+01:00[Europe/Paris]',
// tzid: 'Europe/Paris',
// include: [
// {
// rule: {
// freq: 'WEEKLY',
// start: '2025-01-01T08:00:00Z',
// tzid: 'Europe/Paris',
// interval: 1,
// count: 5,
// byDay: ['MO', 'WE', 'FR'],
// byHour: [9],
// },
// },
// ],
// exclude: [],
// }
const rebuilt = Recurrence.fromJSON(saved);
console.log(rebuilt instanceof Recurrence);
// trueThis supports both flat input-shaped recurrences and algebraic expressions such as unions, intersections, and differences.
Validate a persisted or received JSON value before rebuilding a Recurrence.
const saved = recurrence.toJSON();
console.log(Recurrence.isJSON(saved));
// true
const validation = Recurrence.validateJSON(saved);
console.log(validation);
// { ok: true }
if (Recurrence.isJSON(saved)) {
const rebuilt = Recurrence.fromJSON(saved);
console.log(rebuilt.equals(recurrence));
// true
}Use isJSON() when you want TypeScript narrowing. Use validateJSON() when an API boundary needs a non-throwing result that can expose the validation error.
Create a new Recurrence with the same public structure.
const copy = recurrence.clone();
// Independent Recurrence instance with the same public structureCheck structural equality through the public JSON representation.
if (recurrence.equals(otherRecurrence)) {
console.log('Same recurrence shape');
}
// Structural equality based on the public JSON representationNormalize nested unions and intersections into a simpler structural form.
const normalized = Recurrence.union(
Recurrence.union(a, b),
c,
).normalize();
// Nested unions/intersections are flattened into a simpler structural formThis is useful when recurrence expressions are assembled programmatically and you want a more stable shape for inspection or comparison.
type RecurrenceInput = {
start?: Date | Temporal.Instant | Temporal.ZonedDateTime | null;
tzid?: string | null;
include: RecurrenceEntry[];
exclude?: RecurrenceEntry[];
};
type RecurrenceEntry =
| { rule: RecurrenceRuleInput }
| { dates: (Date | Temporal.Instant | Temporal.ZonedDateTime)[] };Rule fields supported by RecurrenceRuleInput include:
freqstarttzidintervalcountuntilwkstbySetPosbyMonthbyMonthDaybyYearDaybyWeekNobyDaybyHourbyMinutebySecondbyEasterrscaleskip
Accepted date inputs:
DateTemporal.InstantTemporal.ZonedDateTime
import { Temporal } from 'temporal-polyfill';
import { Recurrence } from '@rrulenet/recurrence';
const recurrence = new Recurrence({
start: Temporal.ZonedDateTime.from('2026-01-01T09:00:00+01:00[Europe/Paris]'),
include: [
{
rule: {
freq: 'WEEKLY',
byDay: ['MO', 'TU', 'WE', 'TH', 'FR'],
byHour: [9],
},
},
{
rule: {
freq: 'WEEKLY',
byDay: ['SA', 'SU'],
byHour: [10],
},
},
],
});import { Temporal } from 'temporal-polyfill';
import { Recurrence } from '@rrulenet/recurrence';
const recurrence = Recurrence.parse({
rruleString: 'RRULE:FREQ=DAILY;COUNT=5;BYHOUR=9',
start: Temporal.ZonedDateTime.from('2025-01-01T09:00:00Z[UTC]'),
}).excludingDates([
Temporal.Instant.from('2025-01-03T09:00:00Z'),
]);import { Temporal } from 'temporal-polyfill';
import { Recurrence } from '@rrulenet/recurrence';
const holidays = Recurrence.dates([
Temporal.ZonedDateTime.from('2025-05-01T00:00:00+02:00[Europe/Paris]'),
Temporal.ZonedDateTime.from('2025-05-08T00:00:00+02:00[Europe/Paris]'),
]);import { Temporal } from 'temporal-polyfill';
import { Recurrence } from '@rrulenet/recurrence';
const everyDay = Recurrence.rule({
freq: 'DAILY',
count: 10,
start: Temporal.ZonedDateTime.from('2025-01-01T09:00:00Z[UTC]'),
});
const weekdays = Recurrence.rule({
freq: 'WEEKLY',
byDay: ['MO', 'TU', 'WE', 'TH', 'FR'],
byHour: [9],
start: Temporal.ZonedDateTime.from('2025-01-01T09:00:00Z[UTC]'),
});
const weekdayOccurrences = Recurrence.intersection(everyDay, weekdays);import { Temporal } from 'temporal-polyfill';
import { Recurrence } from '@rrulenet/recurrence';
const recurrence = new Recurrence({
start: Temporal.ZonedDateTime.from('2026-01-01T09:00:00+01:00[Europe/Paris]'),
include: [
{
rule: {
freq: 'WEEKLY',
byDay: ['MO', 'TU', 'WE', 'TH', 'FR'],
byHour: [9],
},
},
{
rule: {
freq: 'WEEKLY',
byDay: ['SA', 'SU'],
byHour: [10],
},
},
],
});
const snapshot = recurrence.toJSON();
// Plain JSON value that can be stored in a DB or sent over the network
const rebuilt = Recurrence.fromJSON(snapshot);
// Full Recurrence instance rebuilt from the saved JSON
const stable = recurrence.normalize().toJSON();
// Useful when a program has built nested unions/intersections and you want a simpler saved shape
console.log(snapshot);
// {
// kind: 'input',
// start: '2026-01-01T09:00:00+01:00[Europe/Paris]',
// tzid: 'Europe/Paris',
// include: [
// { rule: { freq: 'WEEKLY', start: '2026-01-01T08:00:00Z', tzid: 'Europe/Paris', interval: 1, count: null, byDay: ['MO', 'TU', 'WE', 'TH', 'FR'], byHour: [9] } },
// { rule: { freq: 'WEEKLY', start: '2026-01-01T08:00:00Z', tzid: 'Europe/Paris', interval: 1, count: null, byDay: ['SA', 'SU'], byHour: [10] } },
// ],
// exclude: [],
// }
console.log(rebuilt instanceof Recurrence);
// truePublic API errors are thrown as TemporalApiError.
import { TEMPORAL_ERROR_CODES, TemporalApiError, Recurrence } from '@rrulenet/recurrence';
try {
Recurrence.parse({ rruleString: ' ' });
} catch (error) {
if (error instanceof TemporalApiError) {
console.log(error.code === TEMPORAL_ERROR_CODES.INVALID_RRULE_STRING);
}
}Available error codes:
TEMPORAL_INVALID_OPTIONSTEMPORAL_INVALID_RRULE_STRINGTEMPORAL_INVALID_DATETEMPORAL_INVALID_TZIDTEMPORAL_UNSUPPORTED_INPUTTEMPORAL_TZID_CONTRADICTIONTEMPORAL_CONFLICTING_ZONED_DATETIMESTEMPORAL_INVALID_COLLECTION_ELEMENTTEMPORAL_INVALID_ENTRYTEMPORAL_UNSERIALIZABLE_EXPRESSION
npm install
npm test