SR.

Temporal Expressions: How Set Theory Can Fix Your Scheduling Nightmares

Back to blog

Temporal Expressions: How Set Theory Can Fix Your Scheduling Nightmares

Apr 6, 2025·31 min read·
Design PatternsSet TheoryTypeScriptScheduling

The Ticket That Ate Two Weeks

I once picked up a feature in the form of a ticket that read, in full: "users should be able to book recurring sessions." One sentence. I estimated two days and sipped my coffee with the confidence of someone who had never modeled time programmatically.

Then the requirements started rolling in:

  • "We need to be able to set schedules for events every Monday."
  • "Actually, every Monday and Wednesday."
  • "But not during the summer."
  • "Oh, and the first Friday of every month too."
  • "Except holidays."
  • "Can we also add..."

Two days became two weeks. My elegant scheduling function turned into a switch statement that needed therapy. Nested if blocks six levels deep, date comparisons scattered across several files, and a growing suspicion that the Gregorian calendar was designed by someone who actively hated programmers.

The part that made me snap wasn't the complexity. It was that every new rule meant editing the rules that already worked. Adding "except public holidays" meant touching the Monday logic, the Wednesday logic, and the summer logic, then re-testing all three. Each requirement made the next one more expensive.

Pressure

"Surely, there is a better way to handle this", I asked myself. Fortunately, with a bit of digging, I found out that there is, and it comes from an unexpected place: set theory. Not the intimidating kind. Just the three operations you already use every time you write a WHERE clause.

Rather than explain the finished design, I want to build it the way it actually gets built. We'll take one real schedule, add one requirement at a time, and let each requirement break the current design until we arrive somewhere that stops breaking.

The Domain: A Community Center in Kigali

Here's the thing we're modeling. A community center runs several recurring activities:

  • Umuganda, the national community service morning, on the last Saturday of every month
  • The local Ikimina savings group, which meets every Wednesday evening
  • Youth sports, football training, whatever the center adds next month

Before writing any code, it's worth being precise about four words, because they get used interchangeably and they are not the same thing:

  • An event is what happens. "Umuganda." Just an identity, no dates attached.
  • An occurrence is one specific happening of that event. "Umuganda on 29 March 2025."
  • A recurrence rule is the description of when an event happens. "The last Saturday of every month." It is not a list of dates. It is a rule that can generate or test them.
  • A schedule is the thing that knows which events follow which rules, and can answer questions about them.

Almost every scheduling bug I've seen comes from collapsing two of those into one. The most common collapse is storing occurrences when you meant to store a rule. Let's start there, because it's also the most natural first attempt.

First Attempt: Just Store the Dates

The obvious model gives the community center a list of dates per activity.

This works for about a week, then two problems surface.

The first is that the model changes every time the business does. Add a football training session and you add a field, a migration, and a new branch everywhere the center is used. The center class grows a limb per activity.

The second is worse. To store dates, you have to enumerate them, and a recurring schedule has no end. How many Umuganda dates do you generate? Two years? Ten? Whatever you pick is both too many to be honest and too few to be correct, and you have thrown away the actual knowledge, which is the rule. "Last Saturday of every month" is a sentence a person can hold in their head. A list of 120 Saturdays is not.

The first problem has an easy fix, and it's worth doing before anything clever. Rather than a field per activity, key the dates by event name. Adding football training becomes a row of data instead of a schema change:

That's genuinely better, and it's as far as a lot of production code ever gets. But look at what it didn't touch. We are still storing a list of dates, and that list is still a lossy snapshot of a rule nobody ever wrote down. The second problem survives untouched, and it's the one that matters.

Park it. We'll come back with a better weapon.

Second Attempt: Move It Off the Center

Even with the enumeration problem unsolved, there's a question worth settling first: whose job is this?

Right now it's the community center's. That's wrong for the same reason it was wrong to give it a field per activity. Knowing when things happen is a full-time responsibility, and a cooperative, a school, or a health post will all want it too. So pull it out into its own object, a schedule, and let anything that needs scheduling behavior simply hold one.

Notice what changed and what didn't. The center is clean now, and there's finally a sensible home for the behavior we're about to write. But the schedule is still holding a pile of dates. We've moved the problem into a better building without solving it.

That turns out to be the right order. Now that there's an object to talk about, we can ask what we actually want from it, and the answer tells us how to build it.

None of this progression is mine. It's from Martin Fowler's 1997 paper Recurring Events for Calendars, written after one too many parking tickets from Boston street cleaning, which ran on the first and third Monday of the month between April and October, excluding state holidays. The three models we've just walked through are his first three figures. His fourth is the one that finally kills the enumeration problem, and we'll get there in two sections.

Before Implementing: What Do We Want to Ask?

Here's a move from the paper that I now use on every design, and it's worth stealing on its own.

Before working out how a schedule is built, Fowler works out how it is used. He assumes the object already exists and asks what he wants from it:

"I forget about its internal structure, I also forget about how I set one up... I just assume its set up by magic and ask myself what I want to do."

So what do we want to ask this community center's schedule? Three questions, and they are genuinely different:

  1. "Is there Ikimina this Wednesday?" A yes or no about one date.
  2. "When is the next Umuganda?" A single date in the future.
  3. "Which activity days fall in June?" A list, bounded by a range.

In Fowler's Java that interface looked like this:

class Schedule {
    public boolean isOccurring(String eventArg, Date aDate)
    public Vector  dates       (String eventArg, DateRange during)
    public Date    nextOccurence(String eventArg, Date aDate)
};

Notice question 3 is bounded by a DateRange. That is not an accident. A recurring schedule is infinite, so any operation that returns a list has to be told where to stop. Hold onto that. It comes back to bite us later, and it shapes the solution.

Question 1 turns out to be the foundation. Answer it well and the other two can be built on top of it. So that's where we go next.

Third Attempt: Pair Each Event With a Rule

We now know what we want to ask a schedule. Time to face the problem we parked: how does it answer those questions without a list of dates?

Start from the shape of the questions. Every one of them names an event and then asks something about time. That's a seam. Instead of the schedule holding event → dates, let it hold a collection of schedule elements, where each element pairs one event with one object that owns the timing.

That second object is a temporal expression, and the split maps exactly onto the vocabulary we started with. The element holds the what. The temporal expression holds the when. All the calendar arithmetic goes behind it, and everything above just delegates.

The reason this finally kills the enumeration problem is that a temporal expression is not a list. It's the rule itself, kept as an object. "Last Saturday of every month" stays a sentence instead of decaying into 120 Saturdays. Don't store the answers. Store the question, and compute answers on demand.

Which leaves exactly one thing undefined, and it's the thing the whole article is named after. What is a temporal expression? What can you actually ask an object whose job is "the last Saturday of every month"?

The Reframe: a Rule Is a Set of Dates

Here is the insight that dissolves the whole mess.

Stop thinking of a recurrence rule as a condition to evaluate, and start thinking of it as a set of dates. "Every Monday" is the set of all Mondays. "June through August" is a set. "Public holidays" is a set. They are infinite sets, so you can't enumerate them, but you can always ask one thing about a set: is this element a member?

Which means the entire contract for a temporal expression is one method:

interface TemporalExpression {
  includes(date: Date): boolean;
}

That's it. One method, no state, no calendar library, no knowledge of what event it belongs to. It answers membership and nothing else.

With that, question 1 falls out. The schedule asks each of its elements, the element checks whether the event matches, and if it does, it forwards the date to its temporal expression.

The schedule contains no date logic. The element contains no date logic. Every bit of calendar complexity now lives behind one boolean method, in objects small enough to test in isolation.

Questions 2 and 3 are still unanswered. We'll come back for them once there's something worth iterating over.

The Atoms

So what implements includes()? Before writing a class, it's worth asking how many kinds we're going to need, because the answer is not obvious and it's the thing that decides whether this design scales.

A date is several cycles at once

Take Monday 9 June 2026. That single date is simultaneously:

  • a Monday, in the seven-day cycle that repeats forever
  • the 2nd Monday of its month, in a cycle that resets every month
  • 9 June, a position in the yearly cycle
  • the 9th, in 2026, in the second quarter, and so on

A recurrence rule is a sentence that pins some of those coordinates and leaves the rest free. That's the whole trick. "Every Wednesday" pins the weekly cycle and says nothing about the month or year. "Last Saturday of the month" pins the weekly cycle and the position within the month. "March through May" pins only the position in the year.

So we don't need one class per rule, which would be endless. We need one class per aspect of a date you might want to pin, and there are not many of those:

Aspect of the dateExpression typeRules it covers
Day of the weekDayInWeekTEevery Wednesday, weekends
Ordinal in monthDayInMonthTE2nd Friday, last Saturday, first Monday
Window in the yearRangeEveryYearTEthe rainy season, summer, the school term

Fowler arrives at the same conclusion in the paper, and is honest that it's a bet rather than a proof:

"We may not be able to cover everything that we can conceive of, at least not without creating a new class, but we may well be able to cover pretty much everything with a few classes."

Three classes it is. Note what they have in common: each is parameterized rather than specific. DayInWeekTE isn't "Wednesday," it's "a weekday you name at construction time." That's what keeps the count at three instead of fifty-two.

Pinning the weekly cycle

Ikimina meets every Wednesday. Pin the weekday, ignore everything else, which makes this the simplest expression we'll write:

// ISO-8601 weekday numbering: Monday = 1 ... Sunday = 7
function isoWeekday(date: Date): number {
  const jsDay = date.getDay(); // JavaScript: Sunday = 0 ... Saturday = 6
  return jsDay === 0 ? 7 : jsDay;
}
 
class DayInWeekTE implements TemporalExpression {
  constructor(private readonly weekday: number) {}
 
  includes(date: Date): boolean {
    return isoWeekday(date) === this.weekday;
  }
}
 
const ikimina = new DayInWeekTE(3); // Wednesday

That isoWeekday helper exists because Date.getDay() puts Sunday at 0, while every schedule config you'll ever receive from a backend, a spreadsheet, or a human uses ISO-8601 where Monday is 1. Mixing the two shifts your entire schedule by one day, and it fails silently. I've written this off-by-one twice. Now it's a named function.

Pinning the position in the month

Umuganda is harder, because it pins two coordinates at once, and the second one moves. "The last Saturday" is the 29th in March, the 26th in April, the 31st in May.

Break the question in two and it stops being frightening:

  1. Is this the right weekday? We already know how to answer that.
  2. Is it in the right week of the month? Which needs a definition of "week of the month."

For question 2, a date's week number from the start of the month is just its day number divided into blocks of seven. Days 1 to 7 are week 1, days 8 to 14 are week 2, and so on. That handles "2nd Friday."

"Last Saturday" needs the mirror image. Rather than hardcoding "the 4th, or the 5th if the month is long enough," count backwards from the end of the month using the same blocks of seven. The last seven days are week 1 from the end, whatever the month's length. February and August then behave identically, which is the entire point:

class DayInMonthTE implements TemporalExpression {
  // ordinal: 1, 2, 3, 4, or -1 for "last"
  constructor(
    private readonly weekday: number,
    private readonly ordinal: number,
  ) {}
 
  includes(date: Date): boolean {
    if (isoWeekday(date) !== this.weekday) return false;
 
    return this.ordinal > 0
      ? this.weekFromStart(date) === this.ordinal
      : this.weekFromEnd(date) === -this.ordinal;
  }
 
  private weekFromStart(date: Date): number {
    return Math.floor((date.getDate() - 1) / 7) + 1;
  }
 
  private weekFromEnd(date: Date): number {
    const daysInMonth = new Date(
      date.getFullYear(),
      date.getMonth() + 1,
      0,
    ).getDate();
    return Math.floor((daysInMonth - date.getDate()) / 7) + 1;
  }
}
 
const umuganda = new DayInMonthTE(6, -1); // last Saturday

A positive ordinal counts from the start, a negative one counts from the end, and -1 reads as "last." That arithmetic is lifted straight from the paper, where weekInMonth is ((dayNumber - 1) / 7) + 1.

Pinning a window in the year

The last atom covers seasons: the rainy season, the school term, the months a service is available. It pins a start and an end point in the yearly cycle and ignores which year it is.

The naive implementation compares months and days separately, and it has a bug that survives every test written in the northern hemisphere. Consider a season running from November to February. Its start month (11) is greater than its end month (2), so any month >= start && month <= end check matches nothing at all.

The fix is to collapse month and day into a single comparable number, then handle the wrap explicitly:

class RangeEveryYearTE implements TemporalExpression {
  constructor(
    private readonly startMonth: number, // 1 = January ... 12 = December
    private readonly startDay: number,
    private readonly endMonth: number,
    private readonly endDay: number,
  ) {}
 
  includes(date: Date): boolean {
    const value = this.asMonthDay(date.getMonth() + 1, date.getDate());
    const start = this.asMonthDay(this.startMonth, this.startDay);
    const end = this.asMonthDay(this.endMonth, this.endDay);
 
    return start <= end
      ? value >= start && value <= end // Jun 1 to Aug 31, same year
      : value >= start || value <= end; // Nov 15 to Feb 28, wraps the year
  }
 
  // June 1 becomes 601, August 31 becomes 831, so plain < and > work.
  private asMonthDay(month: number, day: number): number {
    return month * 100 + day;
  }
}
 
// Rwanda's long rainy season, roughly March through May
const rainySeason = new RangeEveryYearTE(3, 1, 5, 31);

Encoding a month and day as month * 100 + day looks like a hack, and it is one, but it's a well-behaved hack: the ordering it produces is exactly calendar order, and it needs no year. getMonth() + 1 is there because JavaScript numbers months from zero, which is the second off-by-one this article has had to defuse.

Three small classes, three aspects of a date. Individually, each is about as useful as a single Lego brick. You'd add a fourth only when a genuinely new coordinate shows up, something like "even-numbered weeks" or "the last working day of the quarter." Adding one costs nothing, because every expression is independent and nothing else in the system knows how many exist.

Now watch what happens when the requirements get greedy.

The Center Asks for More

"Ikimina should be Mondays and Wednesdays now."

We could write a DayInWeekListTE that takes an array. Then the next request arrives, "but only during the dry season," and we'd write a DayInWeekListWithinRangeTE, and by the third request we are back in the swamp, adding a class per combination.

The set framing offers something better. If each expression is a set of dates, then the ways to combine rules are the ways to combine sets, and there are only three of them.

Fowler makes the same choice, and explains why he prefers sets to booleans:

"You can also think of these as boolean operations, but I find thinking of sets of dates more natural, and difference is easier than using and and not."

Union: "This OR That"

A date matches if it belongs to any child set. This is "Mondays and Wednesdays."

Intersection: "This AND That"

A date matches only if it belongs to all child sets. This is how you layer a constraint on top of a rule, as in "Mondays, but only in the dry season."

Difference: "This BUT NOT That"

A date matches if it's in the included set and not in the excluded one. This is how exceptions work, and exceptions are where naive schedulers go to die.

Here is the whole combinator layer:

class UnionTE implements TemporalExpression {
  constructor(private readonly children: TemporalExpression[]) {}
 
  includes(date: Date): boolean {
    return this.children.some((child) => child.includes(date));
  }
}
 
class IntersectionTE implements TemporalExpression {
  constructor(private readonly children: TemporalExpression[]) {}
 
  includes(date: Date): boolean {
    return this.children.every((child) => child.includes(date));
  }
}
 
class DifferenceTE implements TemporalExpression {
  constructor(
    private readonly included: TemporalExpression,
    private readonly excluded: TemporalExpression,
  ) {}
 
  includes(date: Date): boolean {
    return this.included.includes(date) && !this.excluded.includes(date);
  }
}

Three classes, nine lines of logic, and the new requirement is now a one-liner:

const ikimina = new UnionTE([new DayInWeekTE(1), new DayInWeekTE(3)]);

One edge case worth guarding before it reaches production: on an empty children array, some() returns false and every() returns true. So an empty union matches nothing, which is reasonable, and an empty intersection matches every date that exists, which is catastrophic. If your expressions come from user-editable config, validate that composites have at least one child at parse time. Otherwise a schedule with a typo in it silently becomes "always open."

If you've ever written a SQL WHERE clause, this should feel familiar. You're basically writing WHERE (monday OR wednesday) AND dry_season AND NOT holiday, but with composable objects instead of string queries.

Trees All the Way Down

Look closely at those three classes and you'll notice something. They take TemporalExpression and they implement TemporalExpression. A union doesn't care whether its children are leaves or other combinators, because everything answers the same one-method interface.

Which means they nest. Arbitrarily.

This tree holds two schedules: "every Monday in summer, plus weekends, minus holidays" and "the first Friday of every month, minus holidays." A parent asks its children, who ask their children, until the question reaches a leaf that can actually look at a calendar. The answer bubbles back up.

The uniform-interface trick is the Composite Pattern, and that's how the nesting works mechanically. But it's worth knowing what Fowler calls the overall design, because it's a different pattern and a better description of what you've built:

"Using set expression in this way is a use of the Interpreter pattern. It is interesting to note that I didn't realize this until Ralph Johnson pointed it out to me. In my mind interpreters are for languages, and languages are complicated. This is simple, and yet the interpreter pattern still works very well, so well that it is easy to use it without realizing it, which I guess is the sign of a good pattern!"

That reframing matters more than it sounds. You have not written a scheduling feature. You have written a tiny language for describing time, where the leaves are literals, the set operations are operators, and includes(date) is the evaluator. Everything that follows in this article is a consequence of that, including the parts Fowler didn't need in 1997.

The immediate payoff is the one that fixes my original two-week disaster: you never modify an existing rule to add a new one. Need "but not in December"? Wrap the tree in a DifferenceTE. Need Tuesdays too? Add a leaf to the union. Nothing that already worked gets touched, so nothing that already worked needs re-testing. The Open/Closed Principle would be proud.

Back for Questions 2 and 3

We parked two questions earlier: "when is the next Umuganda?" and "which days fall in June?" Both need to iterate over dates rather than test one, and both run into the same wall: the set is infinite.

Fowler's answer in 1997 was the interface we saw at the start. nextOccurence returns exactly one date, and dates(event, during) demands a DateRange so the caller says where to stop. Both are ways of refusing to enumerate an infinite set.

Modern JavaScript gives us a third option that covers both cases at once. A generator produces dates one at a time, on demand, and pauses in between. The caller decides when to stop, so "the next one" and "all of them in June" become the same function used two ways.

The generator never looks beyond what you need. Ask for 5 dates and it stops after 5. Ask for 1 and it stops after 1. Compare that to "generate all dates for the next 10 years and filter," which is the approach that makes your server question its life choices.

function* occurrences(
  expression: TemporalExpression,
  from: Date,
  maxDaysToScan = 366 * 2,
): Generator<Date> {
  const cursor = new Date(from);
 
  for (let scanned = 0; scanned < maxDaysToScan; scanned++) {
    if (expression.includes(cursor)) {
      yield new Date(cursor); // copy, because the cursor keeps moving
    }
    cursor.setDate(cursor.getDate() + 1);
  }
}
 
// Question 2: "When is the next Umuganda?"
const [nextUmuganda] = occurrences(umuganda, new Date());
 
// Question 3: "Which Ikimina days fall in June?"
const juneIkimina = [];
for (const date of occurrences(ikimina, new Date(2026, 5, 1))) {
  if (date.getMonth() !== 5) break;
  juneIkimina.push(date);
}

Two details there aren't decoration. yield new Date(cursor) hands out a copy. Yield the cursor itself and every caller gets the same object, which then keeps mutating underneath them. And maxDaysToScan is a seatbelt: a schedule that matches nothing, like February 30th or an over-constrained intersection, turns an unbounded generator into an infinite loop that pins a CPU core.

Which is worth saying plainly, because it's the thing lazy evaluation does not fix. Fowler's DateRange parameter and this maxDaysToScan are the same idea wearing different clothes. Laziness removes the memory problem. It does not remove the termination problem, and the infinite set is still infinite.

Letting the Center Change Its Own Schedule

Everything so far is built in code, which means every change to the Ikimina meeting day is a pull request and a deploy. For a community center that adds a football session next month, that's absurd.

Since the expression tree is really a little language, we can give it a written form. Describe the tree as data, and let a parser build the objects at runtime:

{
  "schedule": [
    {
      "type": "INTERSECTION",
      "expressions": [
        { "type": "DAY_IN_WEEK", "day": 1 },
        {
          "type": "RANGE_EVERY_YEAR",
          "of": "START_DAY_TO_END_DAY",
          "startDate": "6-1",
          "endDate": "8-31"
        }
      ],
      "slots": 30
    }
  ]
}

Here day: 1 is Monday in ISO-8601, which is the convention the isoWeekday helper normalizes to.

The parser walks this recursively, instantiating one class per node. It is a small recursive descent parser, except instead of parsing a programming language it parses a schedule.

Worth being honest about the lineage here: this part is not from the paper. Fowler explicitly considered a text format and set it aside, writing that an interpreter which could "parse and process a range of expressions... would be quite flexible, but also pretty hard," and choosing a handful of parameterized classes instead. He was right that it's hard, and the difficulty is the subject of one of the warnings below. But the trade is often worth it, because now:

  • Schedules live in the database and change without a deploy
  • Non-developers can define them, given a decent UI
  • Environments can differ without code changes
  • A schedule can be validated before it ever runs

The Whole Thing, Traced

The center's requirements have grown to four:

  1. Umuganda: last Saturday of every month
  2. Ikimina: every Monday and Wednesday evening
  3. No activities on public holidays
  4. No Ikimina during the rainy season (March through May)

Each of those is now a mechanical translation. Name the atoms, union what should be added together, difference out what should be removed.

Now ask the question we set out to answer at the very beginning: "Is there an Ikimina meeting on June 9, 2025?" It's a Monday, outside the rainy season, and not a holiday.

Each node answers its own small yes-or-no question and passes it up. No god-function, no tangled conditionals, and adding the football session tomorrow touches none of it.

Why Not Just Use Cron?

Fair question. Cron expressions are great for "run this job at 2am every Tuesday." But they fall apart when you need:

FeatureCronTemporal Expressions
"Every Monday"0 0 * * 1DayInWeekTE(1)
"Not during summer"❌ Need external logicDifferenceTE(schedule, summer)
"1st Friday of month"0 0 * * 5 + code ⚠️DayInMonthTE(5, 1)
Composability❌ Flat strings✅ Arbitrarily nestable trees
Runtime modification❌ Requires redeploy✅ JSON config swap
"Available slots"❌ Not a concept✅ Built-in capacity

Cron tells you when to run something. Temporal expressions tell you whether a point in time belongs to a set. They solve different problems, and temporal expressions shine when scheduling rules are complex, composable, or user-defined.

What Will Bite You

I've made this pattern sound like a free lunch. It isn't. Five things cost me real time.

Timezones will hurt you more than the pattern will help you. Every method used above (getDay(), getDate(), getMonth()) reads a Date in the server's local time. A Date is an instant in UTC. "Monday" is a property of a calendar, not of an instant. Run your service in UTC while your users are in Kigali (UTC+2) and a booking made at 01:00 local on Monday is Sunday as far as your expression tree is concerned. It fails for exactly two hours a day, which is the worst possible failure rate: too rare to notice in testing, too common to survive production.

The fix is to decide, explicitly and once, that temporal expressions operate on plain calendar dates: year, month, day, no time, no zone. Convert at the boundary, before a date reaches the tree. Rwanda has no daylight saving, which hides the second half of this problem. The moment you have users in a DST region, a "9am every Wednesday" rule shifts by an hour twice a year and someone misses an appointment. Use the Temporal API (Temporal.PlainDate) or a zone-aware library. Don't hand-roll it.

Debugging a deep tree is genuinely unpleasant. includes() returns a bare boolean, so when a date doesn't match, the tree tells you nothing about which node rejected it. With five nested levels you're reduced to console-logging your way down. I bolted mine on after the third support ticket. It should have been there from the first commit.

A JSON config format is a programming language wearing a disguise. This is the difficulty Fowler predicted. The moment schedules are data, someone will want a rule you didn't build, and you'll be extending a homegrown DSL with no type checker, no editor support, and no error messages. Validate aggressively at parse time. Reject unknown type values loudly rather than skipping them, and never let a malformed node silently evaluate to true.

Set membership can't express everything, and rotations are where it stops. Fowler closes his paper with the open problems, and the sharpest one is: "How do we handle a schedule such as four weeks on two weeks off?" Think about why that's hard. Every rule in this article is a pure function of a single date, so any date can be tested in isolation. A rotation is not. Whether a given Monday is an "on" week depends on how far it is from some anchor date, which is state the interface deliberately doesn't have. You can fake it with arithmetic against a fixed epoch, but you've quietly broken the property that made the design clean. His other open problems are worth knowing too: holidays that displace an event to the following Monday rather than cancelling it, classifying working days rather than events, and events that must not land on the same day as each other. None of them are solved by set membership alone.

It's overkill for simple schedules. If your rules are "every Monday" and will never be anything else, a WHERE EXTRACT(DOW FROM date) = 1 is the right answer and this whole article is an elaborate way to make your codebase worse. This pattern earns its keep when rules are composable, user-defined, or expected to change, not when they're merely recurring.

Key Takeaways

  1. Store the rule, not the dates. A recurring schedule is infinite. The moment you enumerate it into a list, you've thrown away the knowledge and kept a snapshot.

  2. Design the interface before the implementation. Assume the object already exists, ask what you want from it, and let the answers shape the design. It's the cheapest technique in this article.

  3. Model schedules as sets of dates. The includes(date): boolean interface is deceptively powerful. It turns complex scheduling into simple set membership checks.

  4. Use set operations to compose rules. Union (∪), Intersection (∩), and Difference (\) let you combine simple rules into complex ones without if-else chains, and you never edit a working rule to add a new one.

  5. You're building a small language, not a feature. Composite gives you the nesting, Interpreter is what the result actually is, and once you see that, a config format and an evaluator are obvious next steps.

  6. Lazy evaluation prevents waste, not infinite loops. Generators let you work with infinite schedules without generating infinite dates, but every traversal still needs a bound.

  7. Operate on calendar dates, not instants. Convert timezones at the boundary, before a date ever reaches an expression. Every scheduling bug I've shipped lived in that gap.

The next time someone asks you to build a scheduling system and the requirements sound like a logic puzzle wrapped in a calendar, don't reach for nested if statements. Reach for set theory. Your future self (and your code reviewer) will thank you.

If you've built something like this, I'd like to know one thing: how did you make a deeply nested schedule explainable to the person who has to debug it at 2am?

Further Reading

Design PatternsSet TheoryTypeScriptScheduling