Canonical Bytes
A value and wire shape have exactly one valid encoding. This is a stronger guarantee than successful round-tripping, and it determines several format choices.
Key order is derived
Section titled “Key order is derived”Field order is the rank of the field’s name in UTF-16 code-unit ascending order — JavaScript’s default string comparison.
const Person = z.object({ name: z.string(), // rank 1 age: z.int().nonnegative(), // rank 0 sex: z.enum(["M", "F", "X"]), // rank 2});
encode(Person, { name: "Grace", age: 45, sex: "F" });// [45, 5, 71, 114, 97, 99, 101, 0]// ^age ^len "Grace" ^sexage is written first even though it was declared second. The order is derived from field names, so validators and the high- and low-level APIs all produce the same result.
The encoder applies the sort. The m API cannot override it because canonical field order is a wire-format rule, not a schema option.
Enum members are sorted too
Section titled “Enum members are sorted too”z.enum(["M", "F", "X"]); // sorted: ["F", "M", "X"] → 0, 1, 2Declaring ["X", "F", "M"] produces identical bytes. It also means adding a member shifts every index at or after it — one of the three edits that corrupts silently. See Fingerprinting.
Cross-vendor identity
Section titled “Cross-vendor identity”z.object({ name: z.string(), age: z.int().nonnegative() });v.object({ name: v.string(), age: v.pipe(v.number(), v.integer(), v.minValue(0)) });type({ name: "string", age: "number.integer >= 0" });// all three: the same bytes, the same fingerprintThis works because the wire shape comes from JSON Schema. The signature excludes rejectUnknown, which validators handle differently but which does not change the encoded bytes.
Integers have one spelling
Section titled “Integers have one spelling”Overlong varints are rejected: 1 must be 0x01, never 0x81 0x00. This keeps the encoding unique, which is required for content addressing, deduplication, and byte-level equality.
What canonicality does not cover
Section titled “What canonicality does not cover”- Floats. float64, little-endian, always 8 bytes.
-0and0are distinct byte strings;NaNis refused by every vendor before shorn sees it. - String normalization.
"é"as one code point and aseplus a combining accent are different strings, both encoded faithfully. Normalize first if you need them equal. - Decoded key order. The bytes are canonical; the decoded object’s key order is shorn’s, not your original object’s.
Round-tripping is a fixed point
Section titled “Round-tripping is a fixed point”decode(encode(x)) returns x, not merely an equivalent value. Converting a format: "date-time" string to an epoch integer could reduce it from about 25 bytes to 5, but it would not preserve the original ISO-8601 spelling. For example, Z could come back as +00:00.
That conversion preserves the instant but changes its representation. shorn therefore leaves it to the application. See ADR 0003.