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 is | Field type | Bare default |
|---|---|---|
| an instant on the timeline | Timestamp | none — the attribute is required |
| a civil date + clock + fixed offset | DateTime | RFC 3339 |
| a calendar date, zone-free | Date | ISO 8601 (YYYY-MM-DD) |
| a clock time, zone-free | Time | ISO 8601 (HH:MM:SS) |
| a span | core::time::Duration | none — 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 requireZ). ForTimestampstring formats the default is UTC; forDateTimethe 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)emits1355517523.000005as 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,subsectruncation). Defaultfloor— time truncates by convention."unnecessary"errors instead of silently losing precision.accept = "..."(repeatable) — alternate formats honored on decode only. Encode always usesformat; decode triesformatfirst, then eachacceptin 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_decode | Reading 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:
| Name | Matches | Origin |
|---|---|---|
@zero-date | 0000-00-00 (and the datetime form) | MySQL zero date |
@epoch | the exact instant 1970-01-01T00:00:00Z | epoch-zero defaulting |
@end-of-time | date 9999-12-31, any clock | SQL “no end date” |
@smalldatetime-max | date 2079-06-06, any clock | SQL Server smalldatetime ceiling |
@sqlserver-min | date 1753-01-01, any clock | SQL Server datetime floor |
@ole-zero | date 1899-12-30, any clock | OLE 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 arei128Unix nanoseconds; leap seconds do not exist (a wire23:59:60clamps to59on parse and is never emitted). - Offsets are fixed — a
TimeZoneis a whole-second offset east of UTC (TimeZone::UTC,from_fixed_offset_secs), read back throughDateTime::time_zoneand re-attached withwith_time_zone; no tzdb, no DST. Zone-aware logic belongs in the application, converted at the boundary via the interop features:jiff,chrono, andtimeare symmetric optional features that addFrom/TryFromconversions 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) backsTimestamp::nowandDateTime::now_utcwith the system clock, and on browser wasm itsjscapability reads the browser’s clock and normalizes JS-flavoured date values. DateTimecompares by instant; it also offersparse_http_date/format_http_dateand the flexibleparse_iso8601(calendar, week, and ordinal forms).Date,DateTime, andTimehaveparse_strftime/format_strftime;Timestampis an instant with no calendar form of its own, so it does not.- The
durationmodule parses and formats ISO 8601, Go, and protobuf duration text; thecalendarmodule 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.