How do I handle calendar edge cases like month-end and leap years in EK9?

← Date, Time, and Duration · Ref: Q550

EK9 has TWO month rules, deliberately, and which one applies depends on the types involved: Duration arithmetic is flat, while APPLYING a Duration to a Date is calendar-aware.

DURATION ARITHMETIC IS FLAT (30/360)

Every Duration operation round-trips through total seconds and re-normalises onto a strict grid - 30 days becomes one month, 12 months becomes one year:

  P1M is 30 days. P1Y is 360 days. Always.
  P1M + P1M == P2M, and P1M * 12 == P1Y.

So Duration addition is commutative, associative and free of surprises.

APPLYING A DURATION TO A DATE IS CALENDAR-AWARE

Date + Duration adds the duration's years/months/days through the real calendar and CLAMPS to the month end, then adds any whole days from the time part:

  2024-01-31 + P1M = 2024-02-29   clamped - February has no 31st
  2024-03-31 + P1M = 2024-04-30   clamped - April has no 31st
  2024-02-29 + P1Y = 2025-02-28   clamped - 2025 is not a leap year

THE ONE THING TO WATCH: STEPPING CLAMPS AT EVERY STEP

Because each step clamps, applying P1M twice to a Date is NOT the same as applying P2M once:

  2024-01-31 + P2M       = 2024-03-31   one calendar step of two months
  2024-01-31 + P1M + P1M = 2024-03-29   clamps to Feb 29, then adds a month to the 29th

Month arithmetic on a DATE is therefore not associative. That is inherent to calendars - Java's LocalDate.plusMonths and Python's relativedelta behave the same way - and not an EK9 quirk.

COMBINE THE DURATIONS, THEN APPLY ONCE

Do the Duration arithmetic first, where it IS associative, and touch the calendar only once:

  2024-01-31 + (P1M + P1M) = 2024-03-31
  2023-01-31 + (P1M + P1M) = 2023-03-31

This is EXACT, not a leap-year coincidence: P1M + P1M re-normalises to P2M, so it is literally the single two-month calendar step. Step separately only when stepwise clamping is what you actually mean - accruing a charge on the last day of each month, for instance.

LEAP YEARS

Date validates against the real calendar:

  Date(2024, 02, 29) is valid - 2024 is a leap year
  Date(2023, 02, 29) is invalid, and yields an UNSET Date rather than throwing

A Duration's own P1Y is still 360 flat days; it is the application to a Date that consults the calendar.

WHEN YOU WANT A SPECIFIC DAY

Clamping gives you the nearest valid day. When you want a particular one, build the Date from components:

  nextMonth <- Date(base.year(), base.month() + 1, 28)

See Q32 for Duration basics. See Q540 for the duration summing pitfall introduction. See Q539 for date subtraction. See Q543 for date distances and epoch arithmetic. See Q556 for Time wrapping.

Example

defines module qa.calendar.edge.cases

  defines program
    CalendarEdgeCasesDemo()
      stdout <- Stdout()

      // Leap year handling
      leapDate <- Date(2024, 02, 29)
      stdout.println(`Leap day 2024: ${leapDate}`)
      require leapDate?

      // Non-leap year: Feb 29 is invalid -> unset
      noLeap <- Date(2023, 02, 29)
      require ~noLeap?
      stdout.println(`Feb 29 2023 isSet: ${noLeap?}`)

      // Adding months to a DATE goes through the real calendar and CLAMPS to the month end
      jan31 <- 2024-01-31
      plusOneMonth <- jan31 + P1M
      plusTwoMonths <- jan31 + P2M
      plusOneOne <- jan31 + P1M + P1M
      summedFirst <- jan31 + (P1M + P1M)
      stdout.println(`Jan 31 + P1M: ${plusOneMonth}`)
      stdout.println(`Jan 31 + P2M: ${plusTwoMonths}`)
      stdout.println(`Jan 31 + P1M + P1M: ${plusOneOne}`)
      stdout.println(`Jan 31 + (P1M + P1M): ${summedFirst}`)

      // NOT associative on a Date: the first P1M clamps to Feb 29, so the second
      // P1M starts from the 29th - one P2M step and two P1M steps do NOT agree
      require plusTwoMonths <> plusOneOne
      stdout.println(`P2M step == two P1M steps: ${plusTwoMonths == plusOneOne}`)

      // A Duration added to a Duration is reduced to SECONDS on a nominal 30-day
      // month, so the Duration's own algebra IS flat, associative and predictable
      oneMonth <- P1M
      twoMonths <- P2M
      require oneMonth + oneMonth == twoMonths
      require oneMonth * 2 == twoMonths
      stdout.println(`P1M + P1M == P2M: ${oneMonth + oneMonth == twoMonths}`)

      // and on that same flat basis P1Y is 360 days
      oneYear <- P1Y
      twelveMonths <- P1M * 12
      require oneYear == twelveMonths
      stdout.println(`P1Y == P1M * 12: ${oneYear == twelveMonths}`)

      // Combining the Durations FIRST and applying once is exactly the single calendar
      // step - P1M + P1M re-normalises to P2M - and holds in a non-leap year too
      require summedFirst == plusTwoMonths
      jan31Prev <- 2023-01-31
      stdout.println(`2023: Jan 31 + P2M: ${jan31Prev + P2M}`)
      stdout.println(`2023: Jan 31 + (P1M + P1M): ${jan31Prev + (P1M + P1M)}`)
      require jan31Prev + P2M == jan31Prev + (P1M + P1M)

      // A leap day plus a calendar year has to clamp too
      leapPlusYear <- leapDate + P1Y
      stdout.println(`Feb 29 2024 + P1Y: ${leapPlusYear}`)

      // When you want a SPECIFIC day rather than a clamped one, build it from components
      baseDate <- 2024-01-31
      nextMonth <- Date(baseDate.year(), baseDate.month() + 1, 28)
      stdout.println(`Calendar next month end: ${nextMonth}`)

      // Month-end again: April has no 31st, so this clamps to the 30th
      march31 <- 2024-03-31
      clampedToApr30 <- march31 + P1M
      stdout.println(`Mar 31 + P1M: ${clampedToApr30}`)

Common mistakes

E50060 — Duration has no plus() method. EK9 uses the + operator for duration arithmetic. See ek9 -h E50060 for details.

Incorrect:

require oneMonth.plus(oneMonth) == twoMonths

Correct:

require oneMonth + oneMonth == twoMonths
Other ways to ask this
  • Does EK9 handle leap years correctly?
  • What happens when adding a month to January 31st?
  • Why is month arithmetic non-associative?
  • Why does Jan 31 + P1M + P1M differ from Jan 31 + P2M?

Coming from another language?

Java: YearMonth, LocalDate.plusMonths() clamps at the month end (Jan 31 + 1M = Feb 28) and is therefore non-associative; Period.ofMonths(1) is stored as months and applied at calculation time. Python: relativedelta(months=+1) clamps the same way. Go: AddDate(0,1,0) NORMALISES instead of clamping (Jan 31 + 1M becomes Mar 2 or Mar 3 depending on February). EK9 splits the two concerns: a Duration is flat and associative (P1M = 30 days, P1Y = 360 days), while Date + Duration is calendar-aware and clamps exactly as Java and Python do. Combine Durations first and apply once to keep the associativity.

Keywords: calendar, timezone, duration, time, month-end, clamping, summing, predictable, edge case, migrate, associative, 30 days, leap year