Eight steps through the library. Parsing can fail, so every program below is declared raise; a real program catches Refused at the edge. The output under each block is what the program actually prints.
01 Install and first look
Add the module, then hand a string to @moondate.parse. It reads whichever of the four shapes the text is written in and gives that shape back.
first.mbt
fn main raise {
let stamp = @moondate.parse("2026-09-22T14:30:00+08:00")
println(stamp)
}
output2026-09-22T14:30:00+08:00
A timestamp written in the text comes back written the same way. That is the whole promise of the parser: nothing is invented, and nothing that matters is lost.
When parsing fails it raises Refused, whose message says which field and which range. A date the calendar does not have is refused rather than rolled over.
refused.mbt
fn main {
try {
let _ = @moondate.parse("2026-02-29")
println("accepted")
} catch {
e => println(e.to_string())
}
}
outputday is 29, outside 1..28
02 The four shapes
A moment is a Zoned instant, a Plain wall clock, a Day, or a Clock. The text says which: an offset makes it an instant, a date and time without one is a wall clock, a bare date is a day, and a bare time is a clock.
shapes.mbt
fn main raise {
println(@moondate.parse("2026-09-22T14:30:00Z")) // an instant
println(@moondate.parse("2026-09-22T14:30:00")) // a wall clock
println(@moondate.parse("2026-09-22")) // a date
println(@moondate.parse("14:30:00")) // a time
println(@moondate.parse("20260922T143000+0800")) // the basic form
println(@moondate.parse("2020-01-01[+09:00]")) // the offset in the name
}
Only Zoned names an instant. The other three are deliberately incomplete: turning a wall clock into an instant needs a zone nobody has supplied, so the library will not guess one.
Two spellings of the same grammar are read as well. The basic form drops the separators — 20260922T143000+0800 — and a bracketed offset stands in for the one beside it, so 2020-01-01[+09:00] is midnight nine hours ahead. A bracketed name that is a place is different: [America/New_York] alone says which zone but not which side of a transition the clock was on, so it is refused unless the offset is written beside it.
03 Five ways to write a moment
The same instant can be written five ways, and moondate speaks all of them in both directions. Whatever it writes, strptime, parse_layout or parse_pattern reads back to the same moment.
Style
Write
Read
From
% directives
strftime
strptime
C23, POSIX, glibc, Python, chrono, C++20
Go layout
layout
parse_layout
Go's time package
LDML pattern
pattern
parse_pattern
Unicode UTS #35, ICU, Intl
ECMAScript ISO
isoformat · to_iso_string
parse
ECMA-262, Temporal
The first style is the % directives every Unix has.
write.mbt
fn main raise {
let m = @moondate.parse("2026-09-22T14:30:05.123456+08:00")
println(m.strftime("%Y-%m-%d"))
println(m.strftime("%a %A %b %B"))
println(m.strftime("%H:%M:%S.%f %z"))
println(m.strftime("%G-W%V-%u"))
let back = @moondate.strptime("22/09/2026", "%d/%m/%Y")
println(back)
}
output2026-09-22
Tue Tuesday Sep September
14:30:05.123456 +0800
2026-W39-2
2026-09-22
The second is Go's reference-time layout, where the template is Go's own reference moment and each recognisable piece names a field.
layout.mbt
fn main raise {
let m = @moondate.parse("2026-09-22T14:30:05.123456+08:00")
println(m.layout(@moondate.RFC3339))
println(m.layout(@moondate.RFC1123))
println(m.layout("Monday, 02-Jan-06 15:04:05 MST"))
}
The third is the Unicode LDML pattern: a run of a letter names a field, its length is the width, and text in single quotes is literal.
pattern.mbt
fn main raise {
let m = @moondate.parse("2026-09-22T14:30:05.123456+08:00")
println(m.pattern("EEEE, MMMM d, yyyy 'at' HH:mm"))
println(m.pattern("yyyy-MM-dd'T'HH:mm:ssXXX"))
}
outputTuesday, September 22, 2026 at 14:30
2026-09-22T14:30:05+08:00
The rest of the pattern letters answer in the widths they name. Y and w are the week year and its week — a week starts on Monday and the first week is the one that holds 1 January — b and B name the day period, c is the standalone weekday, and a fifth letter asks for the narrow form of an era, a quarter or a weekday.
letters.mbt
fn main raise {
let m = @moondate.parse("2026-09-22T14:30:05.123456+08:00")
println(m.pattern("Y-'W'ww c"))
println(m.pattern("B"))
println(m.pattern("GGGGG QQQQQ"))
}
output2026-W39 2
in the afternoon
A 3
The fourth is the ECMAScript shapes: to_iso_string is always the UTC instant with milliseconds, to_utc_string is the HTTP-style GMT form, and isoformat picks its own precision and either writes the offset or leaves it off.
ecma.mbt
fn main raise {
let m = @moondate.parse("2026-09-22T14:30:05.123456+08:00")
println(m.to_iso_string())
println(m.to_utc_string())
println(m.isoformat(fraction=3, offset=false))
}
The % set is the union of the six lineages, with the conflicts settled in the table: %f is Python's six digits (chrono's nine are %9f or %N), %Z writes UTC or UTC±HH:MM for a fixed offset, and %+ is chrono's form. The vectors come from each language's own implementation and are committed under src/; CI only replays them.
Flags sit between the per cent sign and the letter: - drops the padding, _ pads with spaces, 0 pads with zeros, a number sets the width, and ^ and # change the case of a name. The C99 selectors E and O are kept as well: %Ec is %c, and the two ISO 8601 spellings of an offset are %Ez and %Oz.
flags.mbt
fn main raise {
let m = @moondate.parse("2026-09-22T14:30:05.123456+08:00")
println(m.strftime("%#B %10Y %-d"))
println(m.strftime("%Ez %Oz"))
println(m.strftime("%Ec"))
}
A Date knows its weekday, its day of the year, its ISO week date, and how many days it is from the epoch. Adding days carries cleanly; adding months lands on a day the month actually has.
calendar.mbt
fn main raise {
let d = @moondate.Date::new(2026, 9, 22)
println(d.weekday()) // Sunday is 0
println(d.yearday())
println(d.isocalendar())
println(d.plus_days(10))
println(d.plus_months(1))
}
output2
265
(2026, 39, 2)
2026-10-02
2026-10-22
The last day of January plus one month has nowhere to land on a short February, so it clamps to the end of the month it reaches:
An instant has seconds since 1970, and can be read in another zone without changing which moment it is. A wall clock answers None to both questions, because it is not an instant until someone says where it is.
instants.mbt
fn main raise {
let m = @moondate.parse("2026-09-22T14:30:00Z")
println(m.epoch())
match m.astimezone(@moondate.Offset(480)) {
Some(east) => println(east)
None => println("not an instant")
}
println(@moondate.Moment::of_epoch(1790087400L))
println(@moondate.parse("2026-09-22T14:30:00").epoch())
}
There is no now(): reading the clock is I/O, and this package has none. A caller that wants the current moment passes its own clock's reading to Moment::of_epoch.
06 Named zones
An offset is not a zone. The same -04:00 is New York in July and nowhere in particular in January, and only a database can tell you which. It lives in tzdata, a package of its own so that a program which formats an HTTP date does not have to carry it.
named.mbt
fn main raise {
let tz = @tzdata.load("America/New_York")
let moment = tz.moment(1790058605L)
println(moment)
println(moment.strftime("%H:%M %Z %z"))
println(moment.pattern("VV"))
println(moment.layout(@moondate.RFC1123))
}
The moment carries the name and the offset in force at that instant, so it goes on being a value: it compares, formats and moves to another zone with no database in reach. What it cannot do is cross a transition on its own — Tz::moment is how it comes back for that.
A wall clock is the harder question. Twice a year one hour never happens and one happens twice, so a zone answers with which instants the clock names, and the caller says what to do about it:
fold.mbt
fn main raise {
let tz = @tzdata.load("America/New_York")
let spring = tz.read(
@moondate.Date::new(2026, 3, 8),
@moondate.Time::new(2, 30),
)
println(spring)
println(spring.epoch())
let autumn = tz.read(
@moondate.Date::new(2026, 11, 1),
@moondate.Time::new(1, 30),
)
println(autumn)
println(autumn.epoch(fold=Later))
}
outputnever, between 1772951400 and 1772955000
1772955000
twice, at 1793511000 and 1793514600
1793514600
fold=Compatible, the default, is the answer most callers mean: the first of two repeated hours and the second of two skipped ones. Earlier and Later pick by instant, and Reject refuses rather than picking.
The same two questions in one call: @tzdata.parse reads the place from the text, asks the database what that clock means, and takes fold for the hour that has none. @moondate.parse cannot do this on its own — it has no database — so a place name alone is the one text it refuses.
text.mbt
fn main raise {
println(@tzdata.parse("2026-11-01T01:30:00[America/New_York]"))
println(@tzdata.parse("2026-11-01T01:30:00[America/New_York]", fold=Later))
}
If the zone is already a TZif file on disk, the reader is in zone and needs no database: @zone.Tz::parse(bytes) takes the file's bytes and gives back the same Tz. A file written before 2038 still answers for 2100, because the POSIX rule in its footer is read rather than expanded.
Fetching those bytes is the one thing in the library that opens a file, and it is a package of its own for that reason: @zoneinfo. system() is the zone the machine is set to — TZ when it names one, /etc/localtime when it does not — and named and open are the other two ways to reach one.
machine.mbt
fn main raise {
let here = @zoneinfo.system()
println(here.name())
println(here.moment(1790058605L).strftime("%H:%M %Z"))
let shanghai = @zoneinfo.named("Asia/Shanghai")
println(shanghai.moment(1790058605L).strftime("%H:%M %Z"))
}
TZ is read the way a C library reads it: empty is UTC, a name is a file in the directory, a path is that file, and anything else is a POSIX rule, which is how a container states a zone without carrying one. A machine that names none of those is an error rather than UTC — guessing UTC is how a program silently writes timestamps eight hours out.
A moment carries a zone it can write but not cross, because a transition is a question only the rules can answer. ZonedDateTime, in zone, keeps the Tz itself, so it can: move it to another zone and the instant does not move, only the reading; add a duration and the instant moves by it, whatever a day happens to be. It is built from a wall clock with fold deciding the two hours, or from an instant it already names.
zoned.mbt
fn main raise {
let ny = @tzdata.load("America/New_York")
let sh = @tzdata.load("Asia/Shanghai")
let now = @zone.ZonedDateTime::from_unix(1790058605L, zone=ny)
println(now.isoformat())
println(now.into(sh).isoformat())
let spring = @zone.ZonedDateTime::of(2026, 3, 8, hour=2, minute=30, zone=ny)
println(spring.isoformat())
let autumn = @zone.ZonedDateTime::of(2026, 11, 1, hour=1, minute=30, zone=ny, fold=@zone.Later)
println(autumn.isoformat())
let step = now.plus(@moondate.Span::new(days=1))
println(step.isoformat())
}
Reading a wall clock back through the zone is the round trip: now.moment() gives the moment the zone writes for the instant, the offset in force beside the name, so the value and the text agree. A field edit takes an offset policy — Prefer keeps the instant the old offset named when it still is, Ignore goes straight to the rules, and Use keeps the old offset whether or not the zone still has it.
07 Durations
A Span is a length of time, to the nanosecond, with no calendar attached. Add one to a moment and it carries across midnight; add one to a bare time and it wraps within the day.
spans.mbt
fn main raise {
let s = @moondate.Span::new(days=1L, hours=2L, minutes=3L)
println(s.parts())
println(@moondate.Span::new(millis=1500L).total_seconds())
let m = @moondate.parse("2026-09-22T23:30:00")
println(m.plus(@moondate.hour))
println(m.plus(@moondate.Span::new(hours=-24L)))
println(@moondate.parse("23:30:00").plus(@moondate.hour))
}
A span is not a number of months. A month is not a fixed length, so adding one is a calendar operation (Date::plus_months) rather than arithmetic.
08 HTTP and ASN.1
The same instant has a different spelling on an HTTP header and inside a certificate. Both readers apply the two-digit-year window their own specification names — and those windows differ.
wire.mbt
fn main raise {
let m = @moondate.http("Sun, 06 Nov 1994 08:49:37 GMT")
println(m)
println(m.http_text())
println(@moondate.asn1("491231235959Z"))
}
output1994-11-06T08:49:37Z
Sun, 06 Nov 1994 08:49:37 GMT
2049-12-31T23:59:59Z
HTTP reads IMF-fixdate and the two obsolete forms a recipient must still accept, but writes only IMF-fixdate. ASN.1's UTCTime puts 49 and under in the 2000s; HTTP's rule puts 69 and under there instead.
09 Schedules with cron
@cron.parse takes five fields as crontab(5) defines them, or six with seconds in front. next answers with the first moment strictly after a given one, or None when the expression names no day that exists.
schedule.mbt
fn main raise {
let now = @moondate.parse("2026-09-22T14:30:00Z")
println(@cron.parse("0 9 * * MON-FRI").next(now))
println(@cron.parse("@daily").next(now))
println(@cron.parse("0 0 30 2 *").next(now))
}
When both day fields are restricted they are or-ed, which is Vixie cron's rule: the thirteenth or any Friday, not Friday the thirteenth.
or-rule.mbt
fn main raise {
let both = @cron.parse("0 0 13 * 5")
println(both.matches(@moondate.parse("2026-09-25T00:00:00Z"))) // a Friday
println(both.matches(@moondate.parse("2026-10-13T00:00:00Z"))) // the thirteenth
let day_only = @cron.parse("0 0 13 * *")
println(day_only.matches(@moondate.parse("2026-09-25T00:00:00Z")))
}
outputtrue
true
false
That is the whole library in a pass. The API reference lists every type and method, and the playground runs the same code in the browser.