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:
| Value | Where a person reads the format | Where 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:
| Module | Reads and writes |
|---|---|
timestamp::secs, millis, micros | whole units since the Unix epoch |
timestamp::nanos | nanoseconds since the Unix epoch |
timedelta::secs, millis, micros, nanos | a 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.
| Type | Traits | Left out, and why |
|---|---|---|
every point, Timedelta, Tickdelta | FromBytes, IntoBytes, Immutable, KnownLayout | Unaligned: each is an 8-byte-aligned i64 |
Timed<T, S> | FromBytes, Immutable, KnownLayout | IntoBytes: zerocopy’s derive takes a generic struct only where every field is Unaligned, and no stamp is |
UtcDateTime | FromBytes, Immutable, KnownLayout | IntoBytes: three bytes of padding sit between its seconds and its nanoseconds |
TickRate | Immutable, KnownLayout | FromBytes 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");
}