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.

We've seen production bugs where "tomorrow" was 23 hours away because of a DST transition, and where a date range was off by one because someone compared instants without accounting for timezone offsets. This library exists because we got tired of fixing those.

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 MpInstant without providing a timezone.
  • You cannot convert an MpLocalDateTime to an instant without specifying which timezone it's in.
  • MpLocalDateTime deliberately has no arithmetic operations — you must choose the right type first.
  • MpLocalDate has calendar arithmetic (days, months, years) because those operations are unambiguous for pure dates.
  • MpZonedDateTime has 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)
We could have added arithmetic to MpLocalDateTime and documented "be careful near DST transitions." But documentation gets skipped. Compiler errors don't.

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
This is the kind of bug that only shows up twice a year, on the exact days DST transitions happen, and only for users in affected timezones. The type system makes you choose explicitly.

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)