Skip to content

rrulenet/recurrence

Repository files navigation

rrule.net

@rrulenet/recurrence

Temporal-first recurrence API for rules, composed schedules, and RFC 5545 parsing.

rrule.netTemporal API@rrulenet ecosystem

@rrulenet/rrule · @rrulenet/recurrence · @rrulenet/core · @rrulenet/cli

npm version JSR version Coverage MIT License

@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.

Table of Contents

Install

npm install @rrulenet/recurrence

If your runtime does not yet provide the Temporal API, install a polyfill in your application:

npm install temporal-polyfill
npm install @js-temporal/polyfill

Polyfill projects:

Getting Started

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]'
// ]

Why Recurrence

@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.

API

import {
  Recurrence,
  parse,
  rule,
  TEMPORAL_ERROR_CODES,
  TemporalApiError,
} from '@rrulenet/recurrence';

import type {
  RecurrenceJson,
  RecurrenceJsonEntry,
  RecurrenceJsonRuleInput,
} from '@rrulenet/recurrence';

Main exports:

  • Recurrence
  • parse(options)
  • rule(options)
  • TEMPORAL_ERROR_CODES
  • TemporalApiError

Type exports:

  • RecurrenceJson
  • RecurrenceJsonEntry
  • RecurrenceJsonRuleInput

Recurrence

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

Recurrence.parse(options) and parse(options)

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:

  • rruleString is required
  • start can be a Date, Temporal.Instant, or Temporal.ZonedDateTime
  • if start is a Temporal.ZonedDateTime, its timezone is inferred automatically
  • inline DTSTART information inside the string remains authoritative

The top-level parse(options) export is a convenience alias for Recurrence.parse(options).

Recurrence.rule(options) and rule(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.

Recurrence.dates(values, options?)

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.

Query Methods

All query methods return Temporal.ZonedDateTime values.

recurrence.all(iterator?)

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]'
// ]

recurrence.between(after, before, inc = false, iterator?)

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]',
//   ...
// ]

recurrence.after(date, inc = false)

const next = recurrence.after(Temporal.Instant.from('2025-01-15T12:00:00Z'));
// First occurrence strictly after the boundary by default

recurrence.before(date, inc = false)

const previous = recurrence.before(Temporal.Instant.from('2025-01-15T12:00:00Z'));
// Last occurrence strictly before the boundary by default

recurrence.first()

Return the first occurrence, or null if none exists.

const first = recurrence.first();
// Shortcut for the first occurrence in the series

recurrence.take(count)

Return 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]'
// ]

recurrence.takeAfter(date, count, inc = false)

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().

recurrence.count(limit?)

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);
// 5

recurrence.hasAny(), recurrence.isEmpty()

Quick presence checks.

if (recurrence.hasAny()) {
  console.log('Schedule has at least one occurrence');
}

recurrence.hasAnyBetween(after, before, inc = false)

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 slice

recurrence.occursAt(date)

Check 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 check

Algebra Methods

new Recurrence(input)

The 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.

Recurrence.union(...recurrences) and recurrence.union(...recurrences)

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 CET

This is a first-class algebraic composition. It is more general than the flat constructor shape.

Recurrence.intersection(...recurrences) and recurrence.intersection(...recurrences)

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.

Recurrence.difference(include, exclude) and recurrence.difference(exclude)

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]'
// ]

recurrence.includingDates(values) and recurrence.excludingDates(values)

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.

Presentation and Serialization

recurrence.toString()

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 representation

For algebraic expressions such as Recurrence.union(...) and Recurrence.difference(...), toString() throws TEMPORAL_UNSERIALIZABLE_EXPRESSION when there is no flat RFC representation.

recurrence.toText(options?)

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 AM

recurrence.isFullyConvertibleToText(options?)

Check whether the full recurrence expression can be rendered completely as text.

if (recurrence.isFullyConvertibleToText()) {
  console.log(recurrence.toText());
}

recurrence.toInput()

Return the flat constructor shape for flat recurrences.

const input = recurrence.toInput();

Like toString(), this throws for non-flat algebraic expressions.

recurrence.toJSON()

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.

Recurrence.fromJSON(json)

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);
// true

This supports both flat input-shaped recurrences and algebraic expressions such as unions, intersections, and differences.

Recurrence.isJSON(value) and Recurrence.validateJSON(value)

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.

recurrence.clone()

Create a new Recurrence with the same public structure.

const copy = recurrence.clone();
// Independent Recurrence instance with the same public structure

recurrence.equals(other)

Check structural equality through the public JSON representation.

if (recurrence.equals(otherRecurrence)) {
  console.log('Same recurrence shape');
}
// Structural equality based on the public JSON representation

recurrence.normalize() and recurrence.flatten()

Normalize 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 form

This is useful when recurrence expressions are assembled programmatically and you want a more stable shape for inspection or comparison.

Constructor Input Shape

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:

  • freq
  • start
  • tzid
  • interval
  • count
  • until
  • wkst
  • bySetPos
  • byMonth
  • byMonthDay
  • byYearDay
  • byWeekNo
  • byDay
  • byHour
  • byMinute
  • bySecond
  • byEaster
  • rscale
  • skip

Accepted date inputs:

  • Date
  • Temporal.Instant
  • Temporal.ZonedDateTime

Examples

Weekdays at 9:00, weekends at 10:00

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],
      },
    },
  ],
});

Parse an RFC string, then add explicit exceptions

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'),
]);

Build a holiday calendar from explicit dates

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]'),
]);

Intersect two broader schedules

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);

Snapshot a recurrence for inspection or transport

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);
// true

Error Handling

Public 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_OPTIONS
  • TEMPORAL_INVALID_RRULE_STRING
  • TEMPORAL_INVALID_DATE
  • TEMPORAL_INVALID_TZID
  • TEMPORAL_UNSUPPORTED_INPUT
  • TEMPORAL_TZID_CONTRADICTION
  • TEMPORAL_CONFLICTING_ZONED_DATETIMES
  • TEMPORAL_INVALID_COLLECTION_ELEMENT
  • TEMPORAL_INVALID_ENTRY
  • TEMPORAL_UNSERIALIZABLE_EXPRESSION

Development

npm install
npm test