Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Dates, Times, and Durations

The rule, stated once: the field type carries the semantics; the attribute carries the contract. You reason about five value kinds — Timestamp, DateTime, Date, Time, and core::time::Duration — and every “what do I actually put on the wire, and what do I assume off it?” question is answered by the #[wire(...)] annotation written on the field, once, machine-readably. #[capi] surfaces that contract into the field’s rustdoc so readers of the model never have to guess; a field opts out of that generated line with #[wire(doc = false)], which has no wire effect.

Why datetimes get this much machinery

This is by far the largest attribute family in the wire model, and the size is worth explaining before you wade into it.

Dates and timestamps appear in nearly every API, and almost no two services agree on how to write one. Epoch seconds, or millis, or the same number quoted as a string. RFC 3339, sometimes insisting on a trailing Z. A bespoke %m/%d/%Y. A field called a date that is really a UTC-midnight instant. An empty string that means null. None of this is exotic — it is an ordinary Tuesday’s worth of API integration.

Today that variety lands on whoever is integrating. You read the documentation if it covers the question, and when it doesn’t — which is often — you find out by trial and error against a live service, or by staring at a sample payload and guessing. The knowledge exists, but it lives in someone’s head or in a thread somewhere, not in the code.

Capi’s premise is that this knowledge should be written down once, by the person best positioned to have it. A client library’s author works intimately with the service: they already know it sends epoch millis, or wants a Z, or encodes “no end date” as 9999-12-31. The attribute families are where they record it — on the field, machine-readably, one time.

Everyone downstream then gets it for free. A consumer of the client works with Timestamp and Date and the right bytes go out; they never have to learn the service’s conventions, because the conventions are already in the type. And if they do want to know, the answer is not buried — the annotation sits on the endpoint’s field, and #[capi] lifts it into the field’s rustdoc, so the wire contract is legible without reading the source.

So read the length of this chapter correctly: it is a menu for client authors, not a burden on the people who use their clients. If you are consuming a client rather than writing one, you can comfortably stop here. If you are writing one, the rest of the chapter is the vocabulary for saying exactly what your service does — and most of its design exists to make a silent wrong unit or offset, the costliest mistake in API integration, impossible to write by accident.

The five kinds

The value isField typeBare default
an instant on the timelineTimestampnone — the attribute is required
a civil date + clock + fixed offsetDateTimeRFC 3339
a calendar date, zone-freeDateISO 8601 (YYYY-MM-DD)
a clock time, zone-freeTimeISO 8601 (HH:MM:SS)
a spancore::time::Durationnone — the attribute is required

Timestamp and Duration deliberately have no default wire form: neither has a universal convention, and a silent wrong unit is the costliest mistake in API integration. An unannotated field of those types does not compile.

/// Creation time (RFC 3339 — the bare default).
pub create_time: Option<DateTime>,

/// Unix creation time.
#[wire(timestamp(format = "epoch_seconds"))]
pub created: Timestamp,

/// Token lifetime in whole seconds.
#[wire(duration(unit = "seconds"))]
pub expires_in: Duration,

Formats

Each family accepts format = "...": the named formats (rfc3339, iso8601, httpdate, and — for timestamps — epoch_seconds / epoch_millis / epoch_micros / epoch_nanos) keep hand-tuned parsers; anything else is a %-string over the directive set %Y %m %d %e %B %b %a %H %M %S %.f %:z %z. Durations take unit = "seconds" | "millis" | "micros" | "nanos" for numeric wires or format = "iso8601" (PT1H30M), format = "go" (1h30m0s), or format = "protobuf" (3.5s, the google.protobuf.Duration JSON mapping a gRPC-transcoded API speaks).

Format strings compile inside the derive: an unsupported directive or a kind-mismatched format is a compile error with a span on the attribute. The kind checks are strict — a date format rejects clock directives, a time format rejects date directives, and an instant kind requires at least year, month, day, and hour. An instant field never decodes from date-only text; an API that sends a bare date for something instant-like is modeled as Date with the midnight bridge (below), which forces the convention to be written down.

#[wire(timestamp(format = "%Y-%m-%d %H:%M:%S", assume = "utc"))]
pub processed: Timestamp,

#[wire(date(format = "%m/%d/%Y"))]
pub due: Date,

#[wire(time(format = "%H:%M"))]
pub opens_at: Time,

The knobs

Every knob answers a question a real API forces.

  • as = "string" — quoted epoch/duration numbers: "1736380800".
  • assume = "utc" | "+05:30" — the offset convention of an offsetless textual format, applied symmetrically: encode converts the instant to the assumed offset’s civil time before formatting; decode attaches the offset after parsing. Required whenever an instant kind uses a format with no offset directive. "local" is deliberately not a value — in a client library “whose local?” is a question, not an answer.
  • convert = "utc" | "+05:30" — encode-side offset normalization for offset-carrying formats (APIs that require Z). For Timestamp string formats the default is UTC; for DateTime the default is the value’s own offset.
  • subsec = 0..=9 — encode-side fractional-second digits (decode always accepts any). Composes with epoch formats: timestamp(format = "epoch_seconds", subsec = 6) emits 1355517523.000005 as an exact token — no float in the middle. Default: as needed (shortest, omitting a zero fraction).
  • rounding = "floor" | "half_even" | ... | "unnecessary" — how precision-dropping encodes round (nanos → epoch seconds, subsec truncation). Default floor — time truncates by convention. "unnecessary" errors instead of silently losing precision.
  • accept = "..." (repeatable) — alternate formats honored on decode only. Encode always uses format; decode tries format first, then each accept in order.
#[wire(timestamp(format = "rfc3339", accept = "epoch_seconds"))]
pub seen_at: Timestamp,

#[wire(datetime(format = "rfc3339", convert = "utc", subsec = 3))]
pub modified: DateTime,

The midnight bridge

Some services store calendar dates as UTC-midnight instants and serialize them as full datetimes. Model the field as what it means — a Date — and declare the convention:

/// The service stores hire dates as UTC-midnight instants.
#[wire(date(format = "rfc3339", midnight = "utc"))]
pub hire_date: Date,

Encode renders midnight at the declared offset (2024-03-01T00:00:00Z). Decode policy is on_decode:

on_decodeReading 2024-02-29T19:00:00-05:00 (= 2024-03-01T00:00:00Z)
"normalize" (default)it is an instant: convert to the declared offset, take the date → 2024-03-01
"written"the civil date as printed → 2024-02-29 — “the date the user saw”, and the fallback for DST-aware-local-midnight services
"strict"require exactly midnight at the declared offset → loud decode error on drift

Sentinels: none_when

Some services encode absence in-band — an empty string for a datetime, -1 for a count, a real-looking 9999-12-31 for “no end date”. The wire-level #[wire(none_when = "...")] attribute (repeatable, on an Option scalar-leaf field of any kind — datetimes, numbers, strings) maps such sentinels to None on decode; None encodes as the first listed sentinel (unless the field is omission-guarded, in which case omission wins).

Literals compare against the raw wire token before parsing. Named @ sentinels are the industry’s sentinel dates — values that parse as perfectly valid dates, where the name carries the meaning and the framework carries the date:

NameMatchesOrigin
@zero-date0000-00-00 (and the datetime form)MySQL zero date
@epochthe exact instant 1970-01-01T00:00:00Zepoch-zero defaulting
@end-of-timedate 9999-12-31, any clockSQL “no end date”
@smalldatetime-maxdate 2079-06-06, any clockSQL Server smalldatetime ceiling
@sqlserver-mindate 1753-01-01, any clockSQL Server datetime floor
@ole-zerodate 1899-12-30, any clockOLE Automation day zero
/// Completion time; the service sends "" while the job is running.
#[wire(datetime(format = "rfc3339"), none_when = "")]
pub completed_at: Option<DateTime>,

/// "No end date" arrives as a real-looking 9999-12-31.
#[wire(date, none_when = "@end-of-time")]
pub valid_until: Option<Date>,

Named sentinels match the civil date as written, regardless of time-of-day or offset — servers are sloppy about the clock part of a sentinel. Escape a literal @ as @@.

The value types at runtime

Values are always valid: construction and parsing are eager.

let d = Date::new(2026, 3, 7)?;
let dt = DateTime::parse_rfc3339("2024-06-15T12:30:45+05:30")?;
let ts = Timestamp::from_second(1_736_380_800)?;
let t = Time::parse_strftime("%H:%M", "09:30")?;
  • The calendar is proleptic Gregorian, years -9999..=9999; instants are i128 Unix nanoseconds; leap seconds do not exist (a wire 23:59:60 clamps to 59 on parse and is never emitted).
  • Offsets are fixed — a TimeZone is a whole-second offset east of UTC (TimeZone::UTC, from_fixed_offset_secs), read back through DateTime::time_zone and re-attached with with_time_zone; no tzdb, no DST. Zone-aware logic belongs in the application, converted at the boundary via the interop features: jiff, chrono, and time are symmetric optional features that add From/TryFrom conversions and nothing else. No feature anywhere in a dependency graph changes another crate’s behavior.
  • The crate’s other features are the platform pair: std (the default) backs Timestamp::now and DateTime::now_utc with the system clock, and on browser wasm its js capability reads the browser’s clock and normalizes JS-flavoured date values.
  • DateTime compares by instant; it also offers parse_http_date / format_http_date and the flexible parse_iso8601 (calendar, week, and ordinal forms). Date, DateTime, and Time have parse_strftime / format_strftime; Timestamp is an instant with no calendar form of its own, so it does not.
  • The duration module parses and formats ISO 8601, Go, and protobuf duration text; the calendar module exposes the civil conversions (days_from_civil / civil_from_days, days_in_month) the whole framework shares.
  • capi_time, in the Capi framework workspace, is a different thing: the framework’s clock and sleep layer (Instant, SystemTime, ApiClock) that the request context, rate limiters, and retries run on. It is not a wire type and never appears in a model.

The calendar engine is hand-rolled and oracle-verified: a differential suite compares it against jiff over all 7,304,484 days in years −9999..=9999 in CI, while jiff itself stays a dev-dependency.

Escape hatch

A genuinely weird format is a custom WireScalar type or #[wire(via = ...)] — the same extensibility story as the rest of the framework. The attribute families are the front door, not the only door.

Next: Enums on the Wire.