Type System & Guard Rails
Date and time code is hard. The Datetime library is designed to make wrong code difficult to write by restricting what each type can do.
The problem
Most date/time bugs come from the same root cause: treating dates and times as interchangeable. A local time shown on a calendar is not the same thing as an absolute point in time. Adding "one day" to a datetime can give you 23, 24, or 25 hours depending on DST. Formatting an instant as a date without specifying a timezone silently picks one for you.
These are not edge cases. They are the normal behavior of time, and they bite every codebase eventually.
The design principle
Datetime uses its type system to enforce a simple rule: if an operation would be ambiguous without a timezone, the type won't let you do it without one.
This means:
- You cannot do calendar arithmetic on an
MpInstantwithout providing a timezone. - You cannot convert an
MpLocalDateTimeto an instant without specifying which timezone it's in. MpLocalDateTimedeliberately has no arithmetic operations — you must choose the right type first.MpLocalDatehas calendar arithmetic (days, months, years) because those operations are unambiguous for pure dates.MpZonedDateTimehas full arithmetic because it carries its timezone — DST is handled automatically.
The six types
Each type represents a different concept. Choosing the right type for your use case is the first guard rail:
| Type | Represents | Use when |
|---|---|---|
MpInstant | An absolute point in time (UTC) | Timestamps, database storage, event ordering, API communication |
MpLocalDate | A calendar date (no time, no timezone) | Birthdays, holidays, billing periods, date pickers |
MpLocalTime | A time of day (no date, no timezone) | Business hours, alarm times, schedule patterns |
MpLocalDateTime | A date and time (no timezone) | Calendar events, user-facing display, data entry before timezone is known |
MpZonedDateTime | A date, time, and timezone together | Scheduling across timezones, display with offset, DST-aware arithmetic |
MpTimezone | An IANA timezone identifier | User timezone preferences, conversion context |
What each type can and cannot do
This is the core of the guard rails. Missing cells are not accidents — they are deliberate design choices:
| Capability | Instant | LocalDate | LocalTime | LocalDateTime | ZonedDateTime |
|---|---|---|---|---|---|
| Add/subtract Duration | yes | no | yes | no | yes |
| Add days/months/years | + tz | yes | no | no | yes |
| Anchor (start of month, etc.) | no | yes | no | no | yes |
| Convert to instant | is one | no | no | + tz | yes |
| Access date components | no | yes | no | yes | yes |
| Access time components | no | no | yes | yes | yes |
| Format with timezone offset | no | no | no | no | yes |
+ tz = available, but requires you to provide a timezone explicitly.
Why MpLocalDateTime has no arithmetic
This is the most surprising restriction and the one we get asked about most. Consider this question: what is "March 9, 2025 at 2:30 AM" plus one hour in New York?
The answer depends on whether DST has happened yet. On March 9, 2025, clocks in New York spring forward from 2:00 AM to 3:00 AM. So "2:30 AM + 1 hour" could be 3:30 AM (if you mean absolute time) or it could not exist at all (2:30 AM is skipped).
MpLocalDateTime has no timezone, so it cannot answer this question correctly.
Rather than guessing, the library simply doesn't offer the operation. You have two correct paths:
val dt = MpLocalDateTime.of(2025, 3, 9, 2, 30)
// Path 1: Convert to zoned first, then do arithmetic (DST-aware)
val zoned = dt.atZone(MpTimezone.of("America/New_York"))
val later = zoned.plus(1, DateTimeUnit.HOUR)
// Path 2: Work with the date part only (no DST issue)
val nextDay = dt.toDate().plusDays(1) Why MpInstant requires a timezone for calendar operations
An MpInstant is an absolute point in time — it has no concept of "day" or "month" without a
timezone.
Adding "1 day" to an instant should give you the same time tomorrow, but DST can make that 23 or 25 hours. The
library
makes you say which timezone you mean:
val instant = MpInstant.parse("2025-03-09T07:00:00Z")
val nyTz = MpTimezone.of("America/New_York")
// This adds calendar "1 day" — DST-aware, so it's 23 real hours
val tomorrow = instant.plus(1, DateTimeUnit.DAY, nyTz)
// This adds exactly 24 hours — regardless of DST
val later = instant.plus(24.hours) Duration vs DateTimeUnit — a critical difference
Kotlin offers two ways to express "one day": 1.days (a Duration) and
DateTimeUnit.DAY (a calendar unit). They look similar but mean fundamentally different things:
import kotlin.time.Duration.Companion.days
import kotlinx.datetime.DateTimeUnit
val instant = MpInstant.parse("2025-03-09T07:00:00Z")
val nyTz = MpTimezone.of("America/New_York")
// Duration: adds exactly 24 hours, always
val plus24h = instant.plus(1.days)
// Result: 2025-03-10T07:00:00Z — exactly 86,400 seconds later
// DateTimeUnit: adds one calendar day in the given timezone (DST-aware)
val plusOneDay = instant.plus(1, DateTimeUnit.DAY, nyTz)
// Result: 2025-03-10T06:00:00Z — only 23 real hours!
// Because on March 9 in New York, clocks spring forward (UTC-5 -> UTC-4),
// "same local time tomorrow" is one hour closer in UTC The difference:
plus(1.days) | plus(1, DateTimeUnit.DAY, tz) | |
|---|---|---|
| Means | Add exactly 24 hours | Add one calendar day |
| DST-aware | No — always 86,400 seconds | Yes — 23, 24, or 25 hours depending on DST |
| Timezone required | No | Yes |
| Use when | You need a precise physical duration | You need "same time tomorrow" for a user |
The same distinction applies to MpZonedDateTime:
val zoned = instant.atZone(nyTz)
// Duration: exactly 24 hours — local time may shift
val a = zoned.plus(1.days)
// DateTimeUnit: same local time tomorrow — actual duration varies
val b = zoned.plus(1, DateTimeUnit.DAY) Note that on MpZonedDateTime, DateTimeUnit operations don't require a separate timezone
parameter —
the zoned datetime already carries one.
Why MpLocalDate has calendar arithmetic without a timezone
MpLocalDate represents a pure calendar date — no time component, no timezone.
"March 15 plus one month" is always April 15, regardless of timezone.
There's no DST to worry about because there's no clock involved.
val date = MpLocalDate.of(2025, 3, 15)
// Unambiguous — pure calendar math
val nextMonth = date.plusMonths(1) // April 15
val nextYear = date.plusYears(1) // March 15, 2026
// Month-end clamping is handled: Jan 31 + 1 month = Feb 28
val jan31 = MpLocalDate.of(2025, 1, 31)
val feb = jan31.plusMonths(1) // Feb 28 (not Feb 31!) Conversion paths
The types form a directed graph of conversions. Each arrow represents a conversion that's always safe and unambiguous:
MpInstant ──atZone(tz)──────> MpZonedDateTime
│
├── toLocalDate() -> MpLocalDate
├── toLocalTime() -> MpLocalTime
└── toLocalDateTime() -> MpLocalDateTime
MpLocalDate ──atTime(time)──> MpLocalDateTime
│
├── toInstant(tz) -> MpInstant
└── atZone(tz) -> MpZonedDateTime Notice: every conversion that crosses the local/absolute boundary requires a timezone.
Going from MpLocalDateTime to MpInstant requires toInstant(timezone).
Going from MpInstant to any local type requires atZone(timezone) first.
These aren't convenience overloads we forgot — they're guard rails.
Periods and comparisons
Periods
Represent a span of time in calendar units:
import io.peekandpoke.ultra.datetime.MpDatePeriod
import io.peekandpoke.ultra.datetime.MpDateTimePeriod
// Date-only period (years, months, days)
val datePeriod = MpDatePeriod.of(years = 1, months = 2, days = 15)
val parsed = MpDatePeriod.parse("P1Y2M15D")
// Full period with time components
val fullPeriod = MpDateTimePeriod.of(
years = 0, months = 0, days = 5,
hours = 3, minutes = 30, seconds = 0
)
// Apply to a date
val date = MpLocalDate.of(2025, 1, 1)
val future = date.plus(datePeriod) // 2026-03-16 Comparisons
All types support standard comparison operators and readable infix functions:
val a = MpLocalDate.of(2025, 1, 1)
val b = MpLocalDate.of(2025, 6, 15)
// Standard operators
println(a < b) // true
println(a >= b) // false
// Readable infix
println(a.isLessThan(b)) // true
println(b.isGreaterThan(a)) // true
println(a.isLessThanOrEqualTo(b)) // true Serialization
All types are @Serializable with kotlinx.serialization and have Slumber codec support:
@Serializable
data class Event(
val name: String,
val start: MpInstant,
val date: MpLocalDate,
val timezone: MpTimezone,
)
val json = Json.encodeToString(Event.serializer(), event)