hd-lang

#Time

Status: standard library specification draft.

This chapter defines the part of std.time that lib/std writes in ordinary hd over the language tier:

  • the Duration type, its public API, its arithmetic, and its Display text;
  • the duration suffixes ms, s, min, and h;
  • impl Parse for Duration, which reads Go-style duration text, and impl Parse for Timestamp, which reads RFC 3339 text;
  • the host capability trait Clock, the Timestamp and Instant types it returns, and the helpers now, monotonic, elapsed, and sleep!;
  • ManualClock, the deterministic Clock provider;
  • the UTC Date of a Timestamp, RFC 3339 and ISO 8601 text, the Timestamp.format layouts, and ParseTimeError;
  • the serialization opt-in of Duration, Timestamp, and Instant.

The language tier keeps the suffix mechanism (Literal Suffixes):

Item Why it stays in the language tier
@num_suffix, std.ops.NumSuffix the compiler recognizes the marker by its qualified name
a suffixed literal as a call expr.suffix.user-call, a language rule, turns 250ms into ms(250)
suffix name lookup and typing Literal Suffix Names and Suffixed Literals hold for every suffix
Duration? in the test signature it names it as its timeout type (Test Cases); the stdlib-tier it_each, it_prop, and it_prop_with name it too

The duration suffixes are ordinary functions marked @num_suffix, so nothing in the language tier names ms, s, min, or h. What the timeout option does is stdlib tier as well: Test Timeout.

#Duration

  1. std-time.suffix.std.durationA std.time.Duration is a whole number of milliseconds, held in an i64.
  2. std-time.duration.non-negativeA Duration is never negative: no call builds one below zero, and Duration holds zero milliseconds at the least. It is modeled on Rust's std::time::Duration.
  3. std-time.suffix.std.duration-apiThe public API of Duration is Duration::milliseconds(n: i64), Duration::seconds(n: i64), d.as_milliseconds() -> i64, d.checked_sub(rhs: Duration) -> Duration?, and d.saturating_sub(rhs: Duration) -> Duration.
  4. std-time.duration.negative-countDuration::milliseconds(n) and Duration::seconds(n) with a negative n panic at run time, in every build. Panic: explicit-panic.
  5. std-time.duration.no-negDuration does not implement std.ops.Neg, so -5s, which is -(5s), is rejected by the type check: type-mismatch.
  6. std-time.prelude.time-suffixesstd.time declares Duration and the duration suffixes ms, s, min, and h, which code imports, as in use std.time.{Duration, s}.
  7. std-time.duration.eq-ordDuration implements Eq and Ord, which compare its milliseconds.
  8. std-time.duration.valueDuration is a struct, a value with private fields, so copying one copies its milliseconds.
use std.time.{Duration, s}

fn remaining(limit: Duration, used: Duration) -> Duration:
    limit.saturating_sub(used)  # zero when used is the longer

Why. A wait, a timeout, or a backoff can then never be negative, so sleep! needs no rule for one. A signed difference is a question of order, which checked_sub and since answer with an absent value or zero, as Rust's Duration and Instant do.

#Duration Arithmetic

Durations add and subtract, and a duration moves a timestamp:

use std.time.{Duration, Timestamp, ms, s}

fn total(first: Duration, second: Duration) -> Duration:
    first + second - 500ms

fn later(start: Timestamp) -> Timestamp:
    start + 2s
  1. std-time.duration.addDuration implements std.ops.Add with Out = Duration. a + b holds the sum of the milliseconds of a and b.
  2. std-time.duration.sub-differenceDuration implements std.ops.Sub with Out = Duration. When a is not shorter than b, a - b holds the milliseconds of a minus those of b.
  3. std-time.duration.sub.underflowa - b with a shorter than b panics at run time, in every build. Panic: explicit-panic.
  4. std-time.duration.checked-suba.checked_sub(b) is .Some(a - b) when a is not shorter than b, and .None otherwise. It never panics.
  5. std-time.duration.saturating-suba.saturating_sub(b) is a - b when a is not shorter than b, and zero otherwise. It never panics.
  6. std-time.duration.add-overflowA sum that does not fit in i64 milliseconds panics at run time. In a debug or test build this is checked i64 arithmetic. Panic: integer-overflow. A release build wraps the sum, and the wrapped value is negative, so the result panics as std-time.duration.negative-count says.
  7. std-time.timestamp.addTimestamp implements std.ops.Add[Duration] with Out = Timestamp. t + d is the timestamp whose milliseconds are those of t plus those of d.
  8. std-time.timestamp.add.overflowA t + d whose milliseconds do not fit in i64 panics at run time in a debug or test build, and wraps in a release build. Panic: integer-overflow.

Note. Timestamp has no Sub: t.since(earlier) is the duration between two timestamps, by std-time.timestamp.since-saturating.

#Duration Display

A duration displays as Go's time.Duration does, with hd's unit names. That is hours, minutes, and seconds, as in 1h2min3.5s, or milliseconds under one second, as in 500ms:

use std.time.{Duration, min, s}

fn waited() -> string:
    "waited ${90s}, then ${60min}"  # "waited 1min30s, then 1h0min0s"
  1. std-time.duration.textDuration implements Display with the algorithm of Go's time.Duration.String, using the unit names h, min, s, and ms.
  2. std-time.duration.text.componentsA duration of one second or more displays as components. Each is a whole count followed by its unit with no space: hours h, then minutes min, then seconds s.
  3. std-time.duration.text.firstThe first component is the largest unit whose count is not zero.
  4. std-time.duration.text.lowerEvery component after the first is written down to s, even when its count is zero, as in 1h0min5s and 1min0s.
  5. std-time.duration.text.fractionThe seconds component carries the remaining milliseconds as a decimal fraction without trailing zeros, as in 3.5s and 1.005s. Whole seconds have no point.
  6. std-time.duration.text.sub-secondA duration under one second that is not zero displays as its milliseconds, then ms, as in 500ms.
  7. std-time.duration.text.zeroA zero duration displays as 0s.
  8. std-time.duration.text.max-valueThe longest duration displays as 2562047788015h12min55.807s. Displaying a duration never overflows.
Milliseconds Text
0 0s
500 500ms
1500 1.5s
1005 1.005s
2000 2s
60000 1min0s
90000 1min30s
3600000 1h0min0s
3605000 1h0min5s
3723500 1h2min3.5s

Why. Go's text is the most widely read duration format, so a log line needs no explanation. Writing every component below the first, as Go writes 1h0m0s, keeps one spelling per duration and a shape that depends only on its largest unit.

#Duration Parsing

impl Parse for Duration reads the text form of Go's time.ParseDuration, so "1h30m".parse::[Duration]() calls it:

use std.time.{Duration, ParseTimeError}

fn timeout_of(text: string) -> Result[Duration, ParseTimeError]:
    text.parse::[Duration]()  # "1h30m" is 5400000 milliseconds

The text is an optional +, then one or more components. A component is a number and a unit:

Part Form
sign +, optional, once at the start
number one or more digits, then optionally . and one or more digits
unit h, min, m, s, ms, us, µs, μs, or ns
  1. std-time.duration.parsestd.time declares impl Parse for Duration with type Error = ParseTimeError, and no inherent parse function. text.parse::[Duration]() returns .Ok of the duration that text names, or .Err of the first error.
  2. std-time.duration.parse.text-grammartext must hold the optional +, then one or more components, each with the parts above in order, and nothing else. No space is allowed anywhere.
  3. std-time.duration.parse.unitsh is 3600 seconds. min and m are 60 seconds. s is one second, and ms is a millisecond. us, µs (U+00B5), and μs (U+03BC) are a microsecond. ns is a nanosecond.
  4. std-time.duration.parse.components-sumThe duration is the sum of its components. A component's value is its number times its unit, with the fraction exact.
  5. std-time.duration.parse.exactA component whose value is not a whole number of milliseconds gives Inexact. Nothing is rounded, so 1.5ms and 500us are errors, and 2000us and 1.005s are not.
  6. std-time.duration.parse.zero-textThe text 0, with an optional + and no unit, is the zero duration. No other number is valid without a unit.
  7. std-time.duration.parse.range-maxA component or a running total that does not fit in i64 milliseconds gives OutOfRange. The longest duration is in range, so 9223372036854775807ms parses.
  8. std-time.duration.parse.no-signA - is not a duration character: no duration is negative, so -5s gives InvalidCharacter at 0, as does -0.
  9. std-time.duration.parse.round-tripFor every duration d, d.to_string().parse::[Duration]() is .Ok(d).
  10. std-time.duration.parse.no-panicThe Parse impl of Duration never panics.
Text parse gives the milliseconds
1h30m 5400000
1h0min5s 3605000
250ms 250
1.5s 1500
1.5h 5400000
2000us 2
0 0

Why. Go's text is what config files and flags already hold. min is accepted next to Go's m so that the text Display writes parses back. A unit finer than the resolution is allowed when the value is whole, and rejected otherwise, so a duration never changes silently.

#Duration Parse Errors

A failed duration parse reports a ParseTimeError, the one error type of all time text, at a byte position:

  1. std-time.duration.parse-error.too-shortText that ends where a number is needed gives TooShort at the text's length. That is empty text, a + alone, or a . with no digit after it.
  2. std-time.duration.parse-error.invalid-characterA character that is not a digit, where a number must start, gives InvalidCharacter at that character, as does a non-digit after a .. So a - gives it at its own position.
  3. std-time.duration.parse-error.missing-unitA number that is followed by the end of the text, or by a ., gives MissingUnit at the end of the number.
  4. std-time.duration.parse-error.unknown-unitThe characters after a number, up to the next digit or ., that are not a unit give UnknownUnit at their start.
  5. std-time.duration.parse-error.range-positionInexact and OutOfRange are at the first digit of the component.
  6. std-time.duration.parse-error.orderThe text is read from the left, one component at a time. In a component the number and unit errors come first, then OutOfRange, then Inexact, and the first error wins.
Text parse gives
"" .Err(TooShort(0))
"+" .Err(TooShort(1))
"-5s" .Err(InvalidCharacter(0))
"1" .Err(MissingUnit(1))
"1x" .Err(UnknownUnit(1))
"1 s" .Err(UnknownUnit(1))
".5s" .Err(InvalidCharacter(0))
"1.5ms" .Err(Inexact(0))
"1h2.5ms" .Err(Inexact(2))
"9223372036854775808ms" .Err(OutOfRange(0))

#Duration Suffixes

The standard library declares these suffixes in std.time:

Rule Suffix Meaning
std-time.suffix.std.msMilliseconds ms Duration of that many milliseconds
std-time.suffix.std.sSeconds s Duration of that many seconds
std-time.suffix.std.minMinutes min Duration of that many minutes
std-time.suffix.std.hHours h Duration of that many hours
  1. std-time.suffix.std.fnEach is a suffix function that takes one i64 and returns the standard std.time.Duration, as in @num_suffix pub fn ms(count: i64) -> Duration.
  2. std-time.suffix.std.only-fourThese four are the only standard suffixes. std declares no ns, us, m, d, byte-size, or string suffix.
  3. std-time.suffix.std.call-importNone is a prelude name; ordinary calls import them, as in use std.time.{ms, s}.
  4. std-time.suffix.std.call-overflowA standard suffix call whose result does not fit in i64 milliseconds panics at run time in a debug or test build, as in h(10_000_000_000_000_000). It wraps in a release build, as i64 arithmetic does. Panic: integer-overflow.
use std.time.{Duration, ms, s}

enum Tier(limit: Duration):
    Fast -> Tier(limit=250ms)
    Slow -> Tier(limit=5s)

Why. Every duration suffix returns Duration, so 5s and 250ms have one type and mix freely.

See also: Literal Suffixes, Test Timeout.

#Clock

Clock is the host capability trait that reads time and waits:

pub trait Clock:
    fn now(self) -> Timestamp
    fn monotonic(self) -> Instant
    fn sleep!(&mut self, duration: Duration) -> void
  1. std-time.clock.decl-refstd.time declares the host capability trait Clock with the methods above. Code imports it, as in use std.time.Clock.
  2. std-time.clock.nownow returns the current wall-clock time as a Timestamp.
  3. std-time.clock.monotonicmonotonic returns the current reading of a clock that never goes backwards, as an Instant.
  4. std-time.clock.sleepsleep!(duration) completes once duration has passed on the clock.
  5. std-time.clock.sleep.zerosleep! with a zero duration completes without waiting.
  6. std-time.clock.plain-readsnow and monotonic are plain calls. Only sleep! suspends.
  7. std-time.clock.mut-ref.reference-valuessleep! takes &mut self, so Clock is a mutable requirement trait, $.use(Clock) is a &mut dyn Clock by req.ref.use.reference-values, and a provider may advance its own time.

Why. A clock read is a value from the host, as an environment read is, so it needs no driver. Replay records it at the boundary either way. Waiting is the one operation that suspends.

#Timestamps And Instants

  1. std-time.timestamp.declstd.time declares Timestamp, a point in UTC time held as whole milliseconds since the Unix epoch in an i64, with private fields.
  2. std-time.timestamp.api-millisecondsThe public API of Timestamp is Timestamp::from_unix_milliseconds(milliseconds: i64), t.unix_milliseconds() -> i64, t.since(earlier: Timestamp) -> Duration, t.checked_since(earlier: Timestamp) -> Duration?, and the two methods of Dates: date and to_rfc3339. Reading RFC 3339 text is impl Parse for Timestamp.
  3. std-time.timestamp.unix-millisecondst.unix_milliseconds() returns the milliseconds since the Unix epoch that t holds, so Timestamp::from_unix_milliseconds(m).unix_milliseconds() is m for every m.
  4. std-time.timestamp.since-saturatingt.since(earlier) is the time from earlier to t. It is zero when earlier is the later one, and the longest duration when the time does not fit in i64 milliseconds. It never panics.
  5. std-time.timestamp.checked-sincet.checked_since(earlier) is .Some of that time, and .None when earlier is the later one or the time does not fit in i64 milliseconds.
  6. std-time.instant.declstd.time declares Instant, a reading of the monotonic clock as whole milliseconds since an origin that the provider chooses, with private fields.
  7. std-time.instant.api-millisecondsThe public API of Instant is Instant::from_milliseconds(milliseconds: i64), i.as_milliseconds() -> i64, i.since(earlier: Instant) -> Duration, and i.checked_since(earlier: Instant) -> Duration?.
  8. std-time.instant.sincei.since(earlier) is the time from earlier to i, with the cases of std-time.timestamp.since-saturating: zero when earlier is the later one, and the longest duration when the time does not fit.
  9. std-time.instant.checked-sincei.checked_since(earlier) is .Some of that time, and .None in the cases of std-time.timestamp.checked-since.
  10. std-time.instant.as-millisecondsi.as_milliseconds() returns the milliseconds since the origin that i holds, so Instant::from_milliseconds(m).as_milliseconds() is m for every m.
  11. std-time.time.eq-ordTimestamp and Instant implement Eq and Ord, which order them by time.
  12. std-time.timestamp.displayTimestamp implements Display, and its text is t.to_rfc3339(). Instant does not implement Display.
  13. std-time.time.importCode imports both, as in use std.time.{Instant, Timestamp}.
  14. std-time.time.valueTimestamp and Instant are structs, values with private fields, so copying one copies its milliseconds.
use std.time.{Instant, Timestamp}

fn epoch() -> Timestamp:
    Timestamp::from_unix_milliseconds(0)   # displays as 1970-01-01T00:00:00Z

fn same_milliseconds(m: i64) -> bool:
    Timestamp::from_unix_milliseconds(m).unix_milliseconds() == m && Instant::from_milliseconds(m).as_milliseconds() == m

#Clock Helpers

use std.time.{Clock, Duration, Timestamp, now, sleep}

fn wait_and_stamp!(pause: Duration) -> Timestamp $ Clock:
    sleep!(pause)
    now()
use std.time.{Clock, Duration, elapsed, monotonic}

fn timed_sum() -> Duration $ Clock:
    start := monotonic()
    let total = 0
    for i in 0..1000:
        total = total + i
    elapsed(start)
  1. std-time.helper.nowstd.time declares pub fn now() -> Timestamp $ Clock, which returns now() of the Clock provider that covers the call.
  2. std-time.helper.sleepstd.time declares pub fn sleep!(duration: Duration) -> void $ Clock, which calls sleep!(duration) on that provider.
  3. std-time.helper.monotonicstd.time declares pub fn monotonic() -> Instant $ Clock, which returns monotonic() of the Clock provider that covers the call.
  4. std-time.helper.elapsedstd.time declares pub fn elapsed(since: Instant) -> Duration $ Clock, which returns monotonic().since(since): the time from since to the provider's monotonic reading now.
  5. std-time.helper.elapsed.manualUnder a ManualClock, elapsed(start) is the total that was slept since start was read.

Why. Timing a block is two lines, as Go's time.Since and Rust's Instant::elapsed make it. The helpers take the provider from the requirement row, so the Clock trait is unchanged and a test times a block in virtual time.

#Manual Clock

ManualClock is the deterministic Clock provider. Its virtual time moves only when code sleeps, and a sleep returns at once:

use std.time.{Clock, ManualClock, Timestamp, s}

fn wait_twice!() -> Timestamp $ Clock:
    let mut clock = $.use(Clock)
    clock.sleep!(2s)
    clock.sleep!(3s)
    clock.now()

tests:
    use std.testing.assert

    it("sleeps in virtual time"):
        clock := ManualClock::new(Timestamp::from_unix_milliseconds(0))
        $.with(Clock=clock):
            assert(wait_twice!() == Timestamp::from_unix_milliseconds(5000), reason="five virtual seconds")
  1. std-time.manual.decl-refstd.time declares the struct ManualClock, which implements Clock, with private fields. Code imports it, as in use std.time.ManualClock. Its sleep! takes &mut self, as the trait's does, so a ManualClock serves as a provider through a reference to it.
  2. std-time.manual.stateA ManualClock's only state is its current time, a Timestamp.
  3. std-time.manual.new-refManualClock::new(start: Timestamp) -> *mut ManualClock returns a clock whose current time is start, a new object. A provider installed from that reference, and every copy of it, share the one clock, so a sleep through the provider moves the clock the caller holds.
  4. std-time.manual.nownow returns the current time.
  5. std-time.manual.monotonicmonotonic returns Instant::from_milliseconds(m), where m is the current time in milliseconds since the Unix epoch.
  6. std-time.manual.sleepsleep!(duration) completes without waiting. The duration is added to the current time.
  7. std-time.manual.sleep.zeroA zero duration leaves the current time unchanged.
  8. std-time.manual.readsnow and monotonic never change the current time.
  9. std-time.manual.no-hostA ManualClock reads nothing from the host's clock.

Why. Virtual time advances by itself. A test of a timeout or a backoff sleeps through it in no real time and needs no extra call. A test that only moves the clock calls sleep! on it.

See also: Host Capabilities, Mutable Providers.

#Dates

A Date is a calendar day in UTC, and t.date() gives the day that a timestamp falls on:

use std.time.{Date, Timestamp}

fn day_of(millis: i64) -> Date:
    Timestamp::from_unix_milliseconds(millis).date()  # 2000-02-29 for 951782400000

std.time declares it with three public fields:

pub struct Date:
    pub year: i32
    pub month: i32
    pub day: i32
  1. std-time.date.decl-structstd.time declares the struct Date, a value with the public fields year, month, and day, each an i32. Copying a Date copies its fields. Code imports it, as in use std.time.Date.
  2. std-time.date.calendarA Date names a day of the proleptic Gregorian calendar: the Gregorian leap-year rule holds for every year, including years before 1582.
  3. std-time.date.yearyear uses astronomical numbering: year 0 is 1 BC, and year -1 is 2 BC.
  4. std-time.date.fieldsmonth counts from 1, January, to 12, and day counts from 1.
  5. std-time.date.of-timestampt.date() returns the UTC date of the day that contains t.
  6. std-time.date.before-epochA timestamp before the epoch belongs to the day that contains it, so the timestamp of -1 milliseconds falls on 1969-12-31.
  7. std-time.date.traitsDate implements Eq, which compares the three fields, and Debug.
  8. std-time.date.utc-onlystd.time has no local time and no time-zone type. A fixed offset appears only in parsed text, by std-time.rfc3339.parse.offset.
Milliseconds date()
0 1970-01-01
-1 1969-12-31
951782400000 2000-02-29
-2203891200000 1900-03-01
-12219379200000 1582-10-14
-62167219200000 0000-01-01

Note. The fields are public, so a literal may hold a day that does not exist, such as month 13. Only date() promises a real day, and no std function takes a Date.

Why. The proleptic Gregorian calendar with a year 0 is the one ISO 8601 and RFC 3339 use. So a date needs no table of calendar reforms. Time zones need a database, and Rust, Kotlin, and Zig keep them out of their standard libraries too.

#RFC 3339 Text

to_rfc3339 writes a timestamp as RFC 3339 text in UTC:

use std.time.Timestamp

fn stamp(millis: i64) -> string:
    Timestamp::from_unix_milliseconds(millis).to_rfc3339()  # "2023-11-14T22:13:20.500Z" for 1700000000500

The text is YYYY-MM-DDThh:mm:ss, then .fff when the milliseconds are not zero, then Z:

Part Text
YYYY the year of t.date()
MM, DD the month and day of t.date(), two digits each
T the letter T
hh, mm, ss the UTC hour 00 to 23, minute 00 to 59, and second 00 to 59, two digits each
.fff a point and the millisecond of the second, 001 to 999, in three digits
Z the letter Z
  1. std-time.rfc3339.textt.to_rfc3339() returns the text of t in UTC with the parts in the table above, in order, and nothing else.
  2. std-time.rfc3339.text.fractionThe fraction is left out when the millisecond of the second is zero. Otherwise it has exactly three digits, so 500 milliseconds is .500.
  3. std-time.rfc3339.text.yearA year from 0 to 9999 has exactly four digits. A later year has all its digits, and a negative year is - and at least four digits, as in -0001.
  4. std-time.rfc3339.text.no-panicto_rfc3339 never panics.
Milliseconds to_rfc3339()
0 1970-01-01T00:00:00Z
1700000000500 2023-11-14T22:13:20.500Z
1700000000007 2023-11-14T22:13:20.007Z
-1 1969-12-31T23:59:59.999Z
-62198755200000 -0001-01-01T00:00:00Z
253402300800000 10000-01-01T00:00:00Z

Note. RFC 3339 allows only the years 0000 to 9999. So the text of a year outside them is not RFC 3339, and parsing it as a Timestamp rejects it. Go's time.Format writes such a year the same way.

Why. Whole seconds stay short, and a fraction shows every millisecond a Timestamp holds, as Rust's chrono writes it.

#ISO 8601 Dates

to_iso8601 writes a date as the calendar form of ISO 8601:

use std.time.Timestamp

fn day_text(millis: i64) -> string:
    Timestamp::from_unix_milliseconds(millis).date().to_iso8601()  # "2023-11-14" for 1700000000500
  1. std-time.date.iso8601d.to_iso8601() returns YYYY-MM-DD: the year, the month in two digits, and the day in two digits, joined by -.
  2. std-time.date.iso8601.yearThe year is written as std-time.rfc3339.text.year writes it, so -0001-01-01 is the first day of 2 BC.
  3. std-time.date.iso8601.fieldsThe month and day are the fields, padded to two digits. A Date literal that holds a day that does not exist is written as it is. to_iso8601 never panics.
  4. std-time.date.iso8601.same-datet.date().to_iso8601() is the first ten characters of t.to_rfc3339() for a year from 0 to 9999.

#Layouts

format writes a timestamp in UTC by a layout of % specifiers, as strftime does:

use std.time.Timestamp

fn clock_text(millis: i64) -> string:
    Timestamp::from_unix_milliseconds(millis).format("%Y-%m-%d %H:%M")  # "2023-11-14 22:13"
Specifier Text
%Y the year, as std-time.rfc3339.text.year writes it
%m, %d the month and the day of t.date(), two digits each
%H, %M, %S the UTC hour 00 to 23, minute, and second, two digits each
%z the UTC offset, always +0000
%% a %
  1. std-time.formatt.format(layout: string) -> string returns layout with each specifier in the table above replaced by its text, and every other character kept.
  2. std-time.format.unknownA % followed by any other character, or by the end of layout, is kept as written. So %q stays %q, and a final % stays %. format never panics.
  3. std-time.format.no-localeformat has no locale, no names of months or days, and no specifier for the milliseconds. A program writes the milliseconds with to_rfc3339.
  4. std-time.format.rfc3339t.format("%Y-%m-%dT%H:%M:%SZ") is t.to_rfc3339() when the milliseconds of t are zero.

Why. Dates, times, and an offset cover log lines and file names. The specifiers are those of C, Python, and Rust's chrono, which agents already write, where Go's reference-date layouts are easy to misspell. The set stays small because there is no time zone and no locale.

#Parsing RFC 3339

impl Parse for Timestamp reads the date-time form of RFC 3339, section 5.6, the text its Display writes:

use std.time.{ParseTimeError, Timestamp}

fn read_stamp(text: string) -> Timestamp?:
    match text.parse::[Timestamp]():  # "2026-10-03T12:34:56.789+05:30"
        .Ok(stamp) => .Some(stamp)
        .Err(_) => .None
Part Form Range
year four digits 0000 to 9999
month - and two digits 01 to 12
day - and two digits 01 to the last day of the month
separator T or t
hour two digits 00 to 23
minute : and two digits 00 to 59
second : and two digits 00 to 59
fraction optional: . and one or more digits any
offset Z, z, or + or -, two hour digits, :, and two minute digits hours 00 to 23, minutes 00 to 59
  1. std-time.rfc3339.parsestd.time declares impl Parse for Timestamp with type Error = ParseTimeError, and no inherent parse function. text.parse::[Timestamp]() returns .Ok of the instant that text names, or .Err of the first error.
  2. std-time.rfc3339.parse.grammartext must hold the parts in the table above, in order, each in its range, and nothing before or after them.
  3. std-time.rfc3339.parse.caseThe separator may be T or t, and the UTC offset Z or z.
  4. std-time.rfc3339.parse.offsetA numeric offset is the local time's offset from UTC, so the instant is the local time minus the offset. -00:00 and +00:00 mean UTC, as Z does.
  5. std-time.rfc3339.parse.fractionThe fraction may have any number of digits. Its digits after the third are ignored, so the time is truncated to the millisecond.
  6. std-time.rfc3339.parse.leap-dayFebruary 29 is in range only in a leap year: a year divisible by 4, except one divisible by 100 but not by 400.
  7. std-time.rfc3339.parse.leap-secondSecond 60 is out of range.
  8. std-time.rfc3339.parse.round-tripFor every timestamp t whose year is 0 to 9999, t.to_string().parse::[Timestamp]() is .Ok(t).
  9. std-time.rfc3339.parse.no-panicThe Parse impl of Timestamp never panics.
Text Parsed as a Timestamp, gives the milliseconds
1970-01-01T00:00:00Z 0
2000-02-29t00:00:00z 951782400000
2026-10-03T12:34:56.789+05:30 1791011096789
1999-12-31T23:00:00-02:00 946688400000
1969-12-31T23:59:59.9999999Z -1

Why. A Timestamp counts Unix milliseconds, which have no leap seconds, so :60 has no instant to name; Go's time.Parse rejects it too. RFC 3339 allows a space for the T only in a note, so the parser keeps to its grammar.

#Time Parse Errors

One error type serves all time text: RFC 3339 timestamps and duration text. A failed parse reports one of six errors, each at a position:

pub enum ParseTimeError:
    InvalidCharacter(position: usize)
    OutOfRange(position: usize)
    TooShort(position: usize)
    MissingUnit(position: usize)
    UnknownUnit(position: usize)
    Inexact(position: usize)
  1. std-time.parse-error.declaredstd.time declares the enum ParseTimeError with the variants above, each with one field position: usize. Code imports it, as in use std.time.ParseTimeError.
  2. std-time.parse-error.position-bytesA position is a byte offset into the text, counted in bytes of its UTF-8 encoding from 0, as slice counts.
  3. std-time.parse-error.invalid-characterA character that the grammar does not allow at its place gives InvalidCharacter at that character. So does a character after a complete timestamp.
  4. std-time.parse-error.out-of-rangeA field whose digits are well formed but whose value is out of its range gives OutOfRange at the field's first digit.
  5. std-time.parse-error.too-shortText that ends before the timestamp is complete gives TooShort, whose position is the text's length.
  6. std-time.parse-error.orderThe text is read from the left, and the first error found wins. A field's range is checked as soon as its last digit is read.
  7. std-time.parse-error.traitsParseTimeError implements Eq, Debug, Display, and Error.
  8. std-time.parse-error.duration-variantsMissingUnit, UnknownUnit, and Inexact come only from duration text, as Duration Parse Errors says. A timestamp parse gives only InvalidCharacter, OutOfRange, and TooShort.
Text Parsed as a Timestamp, gives
"" .Err(TooShort(0))
"2026-10-03" .Err(TooShort(10))
"2026-10-03 12:00:00Z" .Err(InvalidCharacter(10))
"2026-10-03T12:00:00+0530" .Err(InvalidCharacter(22))
"2026-10-03T12:00:00Zx" .Err(InvalidCharacter(20))
"2026-13-03T12:00:00Z" .Err(OutOfRange(5))
"1900-02-29T12:00:00Z" .Err(OutOfRange(8))
"2016-12-31T23:59:60Z" .Err(OutOfRange(17))
"2026-02-30" .Err(OutOfRange(8)), not TooShort

See also: Decode Errors, Integer Parsing.

#Serialization

Duration and Instant give their serialization opt-in as their whole milliseconds, and Timestamp as its RFC 3339 text:

use std.json.encode
use std.serde.{Serialize, Deserialize}
use std.time.{Duration, Timestamp}

@derive(Serialize, Deserialize)
struct Lease:
    holder: string
    granted_at: Timestamp
    term: Duration

fn record(lease: Lease) -> string:
    encode(lease)  # {"holder":"ada","granted_at":"2023-11-14T22:13:20Z","term":30000}
  1. std-time.serde.implsDuration, Timestamp, and Instant each implement std.serde.Serialize and std.serde.Deserialize.
  2. std-time.serde.int-formA Duration and an Instant each write one int holding their milliseconds, as as_milliseconds() gives.
  3. std-time.serde.text-formA Timestamp writes one text: its RFC 3339 text in UTC with milliseconds, the same text its Display gives.
  4. std-time.serde.int-readAn Instant, and a Duration whose value is a number, each read one int with the type's name as expected, as in "Duration", and hold that many milliseconds.
  5. std-time.serde.negative-readA Duration whose number is negative is a decode error naming Duration, as one the parser rejects is. No negative Duration is built.
  6. std-time.serde.text-readA Timestamp reads one text with "Timestamp" as expected, and holds what Parse for Timestamp accepts. A text the parser rejects is a decode error naming Timestamp.
  7. std-time.serde.duration-text-readA Duration whose value is text reads one text with "Duration" as expected, and holds what Parse for Duration accepts, as "30s". A text the parser rejects is a decode error naming Duration. Writing stays whole milliseconds, so a Duration reads both forms and writes one.

Why. A timestamp reads the way it displays, so JSON carries the same text a log line does. Durations and instants stay integers of milliseconds, which round trip exactly, and the private fields stay private to code.

Note. The implementations let a Clock provider's Timestamp and Instant cross the host boundary into hd, and sleep!'s Duration cross out, by module.boundary.in.