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

Serialization

With the serde feature, every value serializes as its spelling where a person reads the format, and as its count where none does. The modules under t2t::serde read and write a count in a named unit, for a field that holds one. The schemars feature adds each spelling’s JSON Schema, and the zerocopy-08 feature reads and writes values as bytes.

This chapter’s listings are t2t-core’s tests, since they depend on serde_json, serde_test and the other crates’ types; they name the crate t2t_core, and the facade has the same items under t2t.

Two Forms

A serializer that says it is human-readable, as serde_json and TOML’s do, takes each value’s spelling; one that does not, as bincode and postcard do, takes its count:

ValueWhere a person reads the formatWhere none does
Timestamp"2026-09-16T07:45:35.123456789Z"i64
TaiTimestamp"2026-09-16T07:46:12.123456789 TAI"i64
Timedelta, Uptime, RawUptime, BootUptime"1m30s"i64
Tickstamp, Tickdelta"24 ticks"i64
TickRate"24000000 Hz"u64

Each is read from the form it is written in. A rate of zero is refused, as serde refuses a zero for a NonZeroU64.

fn a_value_is_written_as_its_spelling_where_a_person_reads_the_format() {
    use serde::{Deserialize, Serialize};
    use serde_json::{from_str, to_string};
    use t2t_core::{Timedelta, Timestamp};

    /// A job's settings, as a config file holds them.
    #[derive(Debug, PartialEq, Serialize, Deserialize)]
    struct Job {
        /// When the job first runs.
        start: Timestamp,
        /// How often it runs after that.
        every: Timedelta,
    }

    let job: Job = from_str(r#"{"start":"2026-09-16T07:45:00Z","every":"1m30s"}"#).expect("a job");
    assert_eq!(job.every, Timedelta::from_secs(90), "a span, from its spelling");

    let written = to_string(&job).expect("a timestamp and a span always serialize");
    let expected = r#"{"start":"2026-09-16T07:45:00.000000000Z","every":"1m30s"}"#;
    assert_eq!(written, expected, "and written back as the spellings `Display` writes");
}

serde_test shows both forms, its readable and compact standing for the two kinds of format:

fn a_format_no_person_reads_takes_the_count() {
    use serde_test::{Configure as _, Token, assert_tokens};
    use t2t_core::{TickRate, Timedelta, Timestamp};

    let span = Timedelta::from_secs(90);
    assert_tokens(&span.readable(), &[Token::Str("1m30s")]);
    assert_tokens(&span.compact(), &[Token::I64(90_000_000_000)]);

    assert_tokens(&Timestamp::UNIX_EPOCH.compact(), &[Token::I64(0)]);
    let rate = TickRate::from_hertz(24_000_000).expect("a rate above zero");
    assert_tokens(&rate.compact(), &[Token::U64(24_000_000)]);
}

Counts in a Named Unit

A service or a peer that sends a count names its unit at the field, and the field’s #[serde(with = …)] names the module:

ModuleReads and writes
timestamp::secs, millis, microswhole units since the Unix epoch
timestamp::nanosnanoseconds since the Unix epoch
timedelta::secs, millis, micros, nanosa span’s count in the unit

Each has an option module, for an Option field, as timedelta::secs::option. Where a person reads the format, a module reads a count as a number or as a decimal string; where none does, as an i64. It writes a number, but for nanoseconds since the epoch where a person reads the format: those pass what an f64 holds exactly, so a JSON reader backed by one would round them, and they are written as a string.

fn a_count_in_a_named_unit_takes_a_module() {
    use serde::{Deserialize, Serialize};
    use serde_json::{from_str, to_string};
    use t2t_core::serde::{timedelta, timestamp};
    use t2t_core::{Timedelta, Timestamp};

    /// A job, as a peer's API sends it.
    #[derive(Debug, PartialEq, Serialize, Deserialize)]
    struct Job {
        /// When the peer received it, in milliseconds since the epoch.
        #[serde(with = "timestamp::millis")]
        received: Timestamp,
        /// When the peer started it, in nanoseconds since the epoch.
        #[serde(with = "timestamp::nanos")]
        started: Timestamp,
        /// How long it may run, in seconds, where it has a limit at all.
        #[serde(with = "timedelta::secs::option")]
        timeout: Option<Timedelta>,
    }

    let sent = r#"{"received":1789544735123,"started":"1789544735123456789","timeout":"30"}"#;
    let job: Job = from_str(sent).expect("a job");
    assert_eq!(job.received, Timestamp::from_millis(1_789_544_735_123), "a number");
    assert_eq!(job.timeout, Some(Timedelta::from_secs(30)), "or a decimal string");

    let written = to_string(&job).expect("each field serializes as a number or a string");
    let expected = r#"{"received":1789544735123,"started":"1789544735123456789","timeout":30}"#;
    assert_eq!(written, expected, "nanoseconds since the epoch in a string, the rest as numbers");
}

A count past what the type holds is refused, with the reason OutOfRangeError gives. Writing a coarser unit drops what is finer: a timestamp is written as the unit it falls in, rounded down, and a span as its whole units toward zero.

fn a_count_past_the_range_is_refused_and_a_finer_one_rounded_down() {
    use serde::{Deserialize, Serialize};
    use serde_json::{from_str, to_string};
    use t2t_core::Timestamp;
    use t2t_core::serde::timestamp;

    /// A stamp, in whole seconds since the epoch.
    #[derive(Debug, Serialize, Deserialize)]
    struct Stamp {
        /// When the stamped thing was published.
        #[serde(with = "timestamp::secs")]
        published: Timestamp,
    }

    let refused = from_str::<Stamp>(r#"{"published":253402300799}"#).expect_err("the year 9999");
    let reason = refused.to_string();
    assert!(reason.starts_with("out of range error"), "past 2262: {reason}");

    let stamp = Stamp { published: Timestamp::from_millis(1_789_544_735_999) };
    let written = to_string(&stamp).expect("a count of seconds always serializes");
    assert_eq!(written, r#"{"published":1789544735}"#, "the second the moment falls in");
}

JSON Schema

With schemars, which turns serde on, every value with a spelling implements JsonSchema: an inlined string schema with a description and a pattern, and for a Timestamp the format date-time. Each pattern takes what its parser reads, which t2t’s tests check on spellings the parser takes and on ones it refuses; a date that does not exist, or a count past the range, matches the pattern and is the parser’s alone to refuse.

fn an_instant_and_a_span_have_the_json_schemas_of_their_spellings() {
    use schemars::schema_for;
    use t2t_core::{Timedelta, Timestamp};

    let instant = schema_for!(Timestamp);
    let field = |name: &str| instant.get(name).and_then(|value| value.as_str());
    assert_eq!(field("type"), Some("string"), "an instant is a string");
    assert_eq!(field("format"), Some("date-time"), "in RFC 3339's format");

    let span = schema_for!(Timedelta);
    let pattern = span.get("pattern").and_then(|value| value.as_str());
    let expected = "^(0|-?(?=[0-9])([0-9]+d)?([0-9]+h)?([0-9]+m)?([0-9]+s)?([0-9]+ms)?([0-9]+us)?([0-9]+ns)?)$";
    assert_eq!(pattern, Some(expected), "a span's pattern takes what its parser reads");
}

A span’s pattern holds a lookahead, which JSON Schema’s ECMA-262 regular expressions read; Rust’s regex crate refuses a lookahead, so a validator built on it cannot use the pattern.

Bytes

With zerocopy-08, each type derives the zerocopy traits it can honour. A type’s bytes are native-endian, and with the feature on, its layout is part of its API.

TypeTraitsLeft out, and why
every point, Timedelta, TickdeltaFromBytes, IntoBytes, Immutable, KnownLayoutUnaligned: each is an 8-byte-aligned i64
Timed<T, S>FromBytes, Immutable, KnownLayoutIntoBytes: zerocopy’s derive takes a generic struct only where every field is Unaligned, and no stamp is
UtcDateTimeFromBytes, Immutable, KnownLayoutIntoBytes: three bytes of padding sit between its seconds and its nanoseconds
TickRateImmutable, KnownLayoutFromBytes and TryFromBytes: a derived check would test that the rate is not zero, and take factors that do not match it; a rate crosses as its hertz

A Timed<u64> is still read from bytes, its stamp’s then its value’s, and any sixteen bytes make a UtcDateTime, so one read from bytes is checked with is_valid, or by to_timestamp, before it is used.

fn a_value_is_read_from_bytes_and_a_point_written_to_them() {
    use t2t_core::{Timed, Timestamp, UtcDateTime};
    use zerocopy::{FromBytes, IntoBytes};

    // A point is its count, native-endian, both ways.
    let instant = Timestamp::from_secs(1_700_000_000);
    let bytes = instant.as_bytes();
    assert_eq!(bytes, 1_700_000_000_000_000_000_i64.to_ne_bytes(), "the count's bytes");
    assert_eq!(Timestamp::read_from_bytes(bytes), Ok(instant), "and back");

    // A stamped value is read from bytes: the stamp's, then the value's.
    let mut record = [0_u8; 16];
    record[..8].copy_from_slice(bytes);
    record[8..].copy_from_slice(&101_u64.to_ne_bytes());
    let sample = Timed::<u64>::read_from_bytes(&record).expect("sixteen bytes");
    assert_eq!((sample.stamp, sample.value), (instant, 101), "a stamped word");

    // Any bytes make a date and time, so one read from them is checked before it is used.
    let date_time = UtcDateTime::read_from_bytes(&[0xFF; 16]).expect("sixteen bytes");
    assert!(!date_time.is_valid(), "month 255 is no month");
    assert_eq!(date_time.to_timestamp(), None, "so it names no instant");
}