Data model

The data model is normative and defined independently of any host language. It is what makes "the same document" mean the same thing in Rust, JavaScript, Python, and Go.

An STF value is exactly one of eleven kinds.

KindDescription
NullThe absence of a value.
Booleantrue or false.
NumberAn IEEE 754 binary64 value.
StringA finite sequence of Unicode scalar values.
ArrayAn ordered sequence of values.
ObjectAn ordered sequence of unique key/value members.
BigIntAn arbitrary-precision integer.
DecimalAn exact signed decimal: a coefficient and a scale.
DateA wall date, with no time and no offset.
TimestampAn absolute instant, with a mandatory UTC offset.
BinaryA finite sequence of octets, possibly empty.

The kinds are mutually distinct

This is the rule the whole format exists to enforce. A String must not be represented as, or be indistinguishable from, any other kind — and no other kind may be represented as a String.

In particular, an implementation must not encode BigInt, Decimal, Date, Timestamp, or Binary as a string carrying a marker prefix or any other in-band sentinel.

STF
{ price: DECIMAL(1.5) }        # a Decimal
{ price: `$decimal:1.5` }      # a String that merely looks like one — non-conformant as a
                               # representation of the value above

An in-band sentinel makes a user-authored string indistinguishable from a typed value, silently promotes a plain string to a constructor on the way out, and can produce output that will not parse. Constructors exist precisely to remove that ambiguity. Implementations must provide distinct host types — a wrapper struct, a class, a tagged union, or the language's native equivalent.

There is no cross-kind numeric coercion either. Number(1), BigInt(1), and Decimal(1) are three different values, and comparing across kinds is always unequal.

Equality

KindRule
Null, BooleanIdentity.
NumberIEEE 754 equality. NaN cannot occur, and +0.0 and -0.0 are distinguished for round-tripping.
StringEquality of the Unicode scalar sequence.
BigIntMathematical integer equality.
DecimalScale-sensitive: equal only if coefficient and scale match.
Date, TimestampEquality of the written fields — the offset is preserved data.
BinaryEquality of the octet sequence; the base64 spelling is not data.
ArrayEqual length, pairwise-equal elements, in order.
ObjectEqual key sets with pairwise-equal values. Member order does not affect equality.

Two consequences are worth stating outright, because they are where host languages disagree with STF:

DECIMAL(1.5) ≠ DECIMAL(1.50). Scale is data. Python's Decimal, Go's shopspring/decimal, and Rust's rust_decimal all compare numerically with ==, so a conformant implementation must compare coefficient and scale explicitly rather than lean on the library.

TIMESTAMP(2026-01-15T10:00:00Z) ≠ TIMESTAMP(2026-01-15T15:30:00+05:30). Both denote the same instant, but the offset an author wrote is information about where the event was recorded, so it survives a round trip and participates in equality.

Order is preserved but not significant

Objects keep the order their members were written in, so formatting a document does not reshuffle it and a diff stays readable. Equality ignores that order. An implementation therefore needs a structure that preserves insertion order while comparing order-independently — a plain hash map is not sufficient on its own.

Round-tripping

parse(serialize(v)) ≡ v is a must. A serializer may not inspect the contents of a string to decide to emit a constructor — that is the sentinel problem from the other direction — and must fail rather than emit output it could not parse back.

Last updated on
Edit this page