How do I handle calendar edge cases like month-end and leap years in EK9?
← Date, Time, and Duration · Ref: Q550
EK9 uses a 30-day month approximation for Duration arithmetic. This makes month arithmetic predictable and associative, at the cost of exact calendar precision.
THE DURATION SUMMING PROBLEM (Deep Dive)
In Java and Python, month arithmetic uses true calendar months:
Java: Jan 31 + 1 month = Feb 28 (clamped) Feb 28 + 1 month = Mar 28 But: Jan 31 + 2 months = Mar 31 (different!)
This happens because each addition clamps to the month's length, and the clamping at each intermediate step loses information. Month arithmetic is NOT associative: (a + b) + c is not equal to a + (b + c). This creates subtle bugs when accumulating monthly intervals.
EK9'S APPROACH
P1M = exactly 30 days (30 x 86400 seconds). Always.
P1Y = 360 days (12 x 30). Always.
This means:
date + P1M + P1M = date + 60 days date + P2M = date + 60 days (same!)
Month arithmetic IS associative. P1M * 2 == P2M. Duration addition is commutative and associative. No surprises from intermediate clamping.
THE TRADE-OFF
EK9 durations are predictable but approximate:
Feb 1 + P1M = Mar 3 (30 days from Feb 1), not Feb 28 Jan 31 + P1M = Mar 2 (30 days from Jan 31), not Feb 28
For calendar-precise month logic, construct new dates from components:
currentMonth <- date.month() nextMonthDate <- Date(date.year(), currentMonth + 1, date.day())
This uses the calendar's own month boundaries.
LEAP YEARS
Date handles leap years correctly in its calendar:
Date(2024, 02, 29) is valid (2024 is a leap year) Date(2023, 02, 29) is invalid (produces unset)
But Duration arithmetic with P1Y adds 360 days, not 365 or 366.
DESIGN RATIONALE
Most duration use cases (scheduling, timeouts, intervals) need predictability more than calendar precision. 'Add 2 months' meaning '60 days' is always correct for billing cycles, subscription periods, and timeout calculations. True calendar month arithmetic is only needed for UI features like 'same day next month'.
See Q32 for Duration basics. See Q540 for the duration summing pitfall introduction. See Q539 for date subtraction. 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?}`) // P1M = 30 days, always jan31 <- 2024-01-31 plusOneMonth <- jan31 + P1M plusTwoMonths <- jan31 + P2M plusOneOne <- jan31 + P1M + P1M stdout.println(`Jan 31 + P1M: ${plusOneMonth}`) stdout.println(`Jan 31 + P2M: ${plusTwoMonths}`) stdout.println(`Jan 31 + P1M + P1M: ${plusOneOne}`) // Associative: P1M + P1M == P2M require plusTwoMonths == plusOneOne stdout.println(`P2M == P1M + P1M: ${plusTwoMonths == plusOneOne}`) // Duration math is predictable oneMonth <- P1M twoMonths <- P2M require oneMonth + oneMonth == twoMonths require oneMonth * 2 == twoMonths stdout.println(`P1M + P1M == P2M: ${oneMonth + oneMonth == twoMonths}`) // P1Y = 360 days oneYear <- P1Y twelveMonths <- P1M * 12 require oneYear == twelveMonths stdout.println(`P1Y == P1M * 12: ${oneYear == twelveMonths}`) // Calendar-precise month logic: construct from components baseDate <- 2024-01-31 nextMonth <- Date(baseDate.year(), baseDate.month() + 1, 28) stdout.println(`Calendar next month end: ${nextMonth}`) // Month-end dates march31 <- 2024-03-31 plus30days <- march31 + P1M stdout.println(`Mar 31 + P1M (30 days): ${plus30days}`)
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 in other languages?
Coming from another language?
Java: YearMonth, LocalDate.plusMonths() handles month-end clamping (Jan 31 + 1M = Feb 28) but is non-associative. Period.ofMonths(1) stored as months, applied at calculation time. Python: relativedelta(months=+1) same clamping behavior. Go: AddDate(0,1,0) normalizes (Jan 31 + 1M = Mar 2 or 3 depending on Feb). EK9: P1M = 30 days always, associative, predictable. Calendar-precise month logic via component construction.
Keywords: month-end, clamping, summing, predictable, edge case, migrate, associative, 30 days, leap year, calendar, timezone, duration, time